Compare commits
12 Commits
6a497c8575
...
feature/in
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c89dae2d1 | |||
| f53a948c8f | |||
| 564f22725a | |||
| feabd3a4fd | |||
| 8bd120dfde | |||
| 2297f4a2bb | |||
| e9a218bb88 | |||
| 7c75bd3c37 | |||
| 1e4efa0c5b | |||
| 99a7920118 | |||
| 8d660ea8c2 | |||
| be3f3b604e |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
.idea/
|
.idea/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
venv/
|
venv/
|
||||||
build/
|
build/
|
||||||
|
banking_breakdown.egg-info
|
||||||
|
|||||||
6
MANIFEST.in
Normal file
6
MANIFEST.in
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
include banking_breakdown/ui/main_window.ui
|
||||||
|
include banking_breakdown/ui/warning.png
|
||||||
|
include banking_breakdown/document_builder/.latexmkrc
|
||||||
|
include banking_breakdown/document_builder/Dockerfile.alpine
|
||||||
|
include banking_breakdown/document_builder/report.tex
|
||||||
|
include banking_breakdown/document_builder/common.tex
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
from banking_breakdown import document_builder
|
from banking_breakdown import ui, regex_categorizer, statement_parser, \
|
||||||
from banking_breakdown import statement_parser
|
document_builder
|
||||||
from banking_breakdown import ui
|
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
@@ -10,6 +9,9 @@ def categorize_func(args):
|
|||||||
|
|
||||||
df = pd.read_csv(args.i, delimiter=args.d)
|
df = pd.read_csv(args.i, delimiter=args.d)
|
||||||
|
|
||||||
|
if args.f is not None:
|
||||||
|
df = regex_categorizer.assign_categories(df, args.f)
|
||||||
|
|
||||||
import signal
|
import signal
|
||||||
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
||||||
|
|
||||||
@@ -17,7 +19,8 @@ def categorize_func(args):
|
|||||||
|
|
||||||
|
|
||||||
def report_func(args):
|
def report_func(args):
|
||||||
print("Report")
|
report_data = statement_parser.parse_statement(args.i)
|
||||||
|
document_builder.build_document(args.o, report_data)
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -45,7 +48,10 @@ def main():
|
|||||||
report_parser = subparsers.add_parser("report")
|
report_parser = subparsers.add_parser("report")
|
||||||
report_parser.set_defaults(func=report_func)
|
report_parser.set_defaults(func=report_func)
|
||||||
report_parser.add_argument('-i', required=True,
|
report_parser.add_argument('-i', required=True,
|
||||||
help="CSV output file from categorization step")
|
help="CSV file containing bank statement data (output file of categorization step)")
|
||||||
|
report_parser.add_argument('-o', required=False,
|
||||||
|
help="Output directory ('build' by default)",
|
||||||
|
default='build')
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import subprocess
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
from banking_breakdown import types
|
|
||||||
|
|
||||||
|
|
||||||
def _copy_resources():
|
|
||||||
os.makedirs(os.path.dirname("build/report.tex"), exist_ok=True)
|
|
||||||
shutil.copyfile("res/report.tex", "build/report.tex")
|
|
||||||
shutil.copyfile("res/.latexmkrc", "build/.latexmkrc")
|
|
||||||
|
|
||||||
|
|
||||||
def _serialize_report_data(report_data: types.ReportData):
|
|
||||||
report_data.net_income.to_csv('build/net_income.csv', index=False)
|
|
||||||
report_data.category_overview.to_csv('build/category_overview.csv',
|
|
||||||
index=False)
|
|
||||||
report_data.total_value.to_csv('build/total_value.csv', index=False)
|
|
||||||
report_data.detailed_balance.to_csv('build/detailed_balance.csv',
|
|
||||||
index=False)
|
|
||||||
|
|
||||||
|
|
||||||
def _compile_document():
|
|
||||||
subprocess.call("docker build . -f res/Dockerfile.alpine"
|
|
||||||
" -t banking-breakdown",
|
|
||||||
shell=True)
|
|
||||||
subprocess.call("docker run --rm -u `id -u`:`id -g`"
|
|
||||||
" -v $(realpath .):/project"
|
|
||||||
" -w /project/build banking-breakdown"
|
|
||||||
" latexmk -pdf report.tex",
|
|
||||||
shell=True)
|
|
||||||
|
|
||||||
|
|
||||||
def build_document(report_data: types.ReportData):
|
|
||||||
_copy_resources()
|
|
||||||
_serialize_report_data(report_data)
|
|
||||||
_compile_document()
|
|
||||||
48
banking_breakdown/document_builder/__init__.py
Normal file
48
banking_breakdown/document_builder/__init__.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import subprocess
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from banking_breakdown import types
|
||||||
|
|
||||||
|
import banking_breakdown.document_builder
|
||||||
|
|
||||||
|
from importlib import resources
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_resources(build_dir: str):
|
||||||
|
res_prefix = resources.files(banking_breakdown.document_builder)
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(f"{build_dir}/report.tex"), exist_ok=True)
|
||||||
|
|
||||||
|
shutil.copyfile(f"{res_prefix}/report.tex", f"{build_dir}/report.tex")
|
||||||
|
shutil.copyfile(f"{res_prefix}/common.tex", f"{build_dir}/common.tex")
|
||||||
|
shutil.copyfile(f"{res_prefix}/.latexmkrc", f"{build_dir}/.latexmkrc")
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_report_data(build_dir: str, report_data: types.ReportData):
|
||||||
|
report_data.net_income.to_csv(f'{build_dir}/net_income.csv', index=False)
|
||||||
|
report_data.category_overview.to_csv(f'{build_dir}/category_overview.csv',
|
||||||
|
index=False)
|
||||||
|
report_data.expenses_by_category.to_csv(f'{build_dir}/expenses_by_category.csv',
|
||||||
|
index=False)
|
||||||
|
report_data.total_value.to_csv(f'{build_dir}/total_value.csv', index=False)
|
||||||
|
report_data.detailed_balance.to_csv(f'{build_dir}/detailed_balance.csv',
|
||||||
|
index=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _compile_document(build_dir):
|
||||||
|
res_prefix = resources.files(banking_breakdown.document_builder)
|
||||||
|
|
||||||
|
subprocess.call(f"docker build . -f {res_prefix}/Dockerfile.alpine"
|
||||||
|
" -t banking-breakdown",
|
||||||
|
shell=True)
|
||||||
|
subprocess.call("docker run --rm -u `id -u`:`id -g`"
|
||||||
|
" -v $(realpath .):/project"
|
||||||
|
f" -w /project/{build_dir} banking-breakdown"
|
||||||
|
" latexmk -pdf report.tex",
|
||||||
|
shell=True)
|
||||||
|
|
||||||
|
|
||||||
|
def build_document(build_dir: str, report_data: types.ReportData):
|
||||||
|
_copy_resources(build_dir)
|
||||||
|
_serialize_report_data(build_dir, report_data)
|
||||||
|
_compile_document(build_dir)
|
||||||
317
banking_breakdown/document_builder/common.tex
Normal file
317
banking_breakdown/document_builder/common.tex
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
% Author: Andreas Tsouchlos
|
||||||
|
%
|
||||||
|
% Collection of useful commands and definitions
|
||||||
|
%
|
||||||
|
% ||====================================================================||
|
||||||
|
% || WARNING ||
|
||||||
|
% ||====================================================================||
|
||||||
|
% || The following packages have to be included before using this file: ||
|
||||||
|
% || amsmath ||
|
||||||
|
% || pgfplots ||
|
||||||
|
% ||====================================================================||
|
||||||
|
|
||||||
|
|
||||||
|
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
||||||
|
% Math Symbols %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
||||||
|
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
||||||
|
|
||||||
|
|
||||||
|
\DeclareMathOperator*{\argmin}{\arg\!\min}
|
||||||
|
\DeclareMathOperator*{\argmax}{\arg\!\max}
|
||||||
|
\DeclareMathOperator\sign{sign}
|
||||||
|
|
||||||
|
|
||||||
|
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
||||||
|
% Data Manipulation %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
||||||
|
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
||||||
|
|
||||||
|
|
||||||
|
%
|
||||||
|
% Filters for Pgfplots
|
||||||
|
% Source: https://tex.stackexchange.com/a/58563 (modified)
|
||||||
|
%
|
||||||
|
|
||||||
|
\pgfplotsset{
|
||||||
|
discard if/.style 2 args={
|
||||||
|
x filter/.append code={
|
||||||
|
\edef\tempa{\thisrow{#1}}
|
||||||
|
\edef\tempb{#2}
|
||||||
|
\ifx\tempa\tempb
|
||||||
|
\def\pgfmathresult{inf}
|
||||||
|
\fi
|
||||||
|
}
|
||||||
|
},
|
||||||
|
discard if not/.style 2 args={
|
||||||
|
x filter/.append code={
|
||||||
|
\edef\tempa{\thisrow{#1}}
|
||||||
|
\edef\tempb{#2}
|
||||||
|
\ifx\tempa\tempb
|
||||||
|
\else
|
||||||
|
\def\pgfmathresult{inf}
|
||||||
|
\fi
|
||||||
|
}
|
||||||
|
},
|
||||||
|
discard if gt/.style 2 args={
|
||||||
|
x filter/.append code={
|
||||||
|
\edef\tempa{\thisrow{#1}}
|
||||||
|
\edef\tempb{#2}
|
||||||
|
\ifdim\tempa pt > \tempb pt
|
||||||
|
\def\pgfmathresult{inf}
|
||||||
|
\fi
|
||||||
|
}
|
||||||
|
},
|
||||||
|
discard if lt/.style 2 args={
|
||||||
|
x filter/.append code={
|
||||||
|
\edef\tempa{\thisrow{#1}}
|
||||||
|
\edef\tempb{#2}
|
||||||
|
\ifdim\tempa pt < \tempb pt
|
||||||
|
\def\pgfmathresult{inf}
|
||||||
|
\fi
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
||||||
|
% Graphics & Plotting %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
||||||
|
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
||||||
|
|
||||||
|
%
|
||||||
|
% Colors
|
||||||
|
%
|
||||||
|
|
||||||
|
% KIT Colors
|
||||||
|
|
||||||
|
\definecolor{kit-green100}{rgb}{0,.59,.51}
|
||||||
|
\definecolor{kit-green70}{rgb}{.3,.71,.65}
|
||||||
|
\definecolor{kit-green50}{rgb}{.50,.79,.75}
|
||||||
|
\definecolor{kit-green30}{rgb}{.69,.87,.85}
|
||||||
|
\definecolor{kit-green15}{rgb}{.85,.93,.93}
|
||||||
|
\definecolor{KITgreen}{rgb}{0,.59,.51}
|
||||||
|
|
||||||
|
\definecolor{KITpalegreen}{RGB}{130,190,60}
|
||||||
|
\colorlet{kit-maigreen100}{KITpalegreen}
|
||||||
|
\colorlet{kit-maigreen70}{KITpalegreen!70}
|
||||||
|
\colorlet{kit-maigreen50}{KITpalegreen!50}
|
||||||
|
\colorlet{kit-maigreen30}{KITpalegreen!30}
|
||||||
|
\colorlet{kit-maigreen15}{KITpalegreen!15}
|
||||||
|
|
||||||
|
\definecolor{KITblue}{rgb}{.27,.39,.66}
|
||||||
|
\definecolor{kit-blue100}{rgb}{.27,.39,.67}
|
||||||
|
\definecolor{kit-blue70}{rgb}{.49,.57,.76}
|
||||||
|
\definecolor{kit-blue50}{rgb}{.64,.69,.83}
|
||||||
|
\definecolor{kit-blue30}{rgb}{.78,.82,.9}
|
||||||
|
\definecolor{kit-blue15}{rgb}{.89,.91,.95}
|
||||||
|
|
||||||
|
\definecolor{KITyellow}{rgb}{.98,.89,0}
|
||||||
|
\definecolor{kit-yellow100}{cmyk}{0,.05,1,0}
|
||||||
|
\definecolor{kit-yellow70}{cmyk}{0,.035,.7,0}
|
||||||
|
\definecolor{kit-yellow50}{cmyk}{0,.025,.5,0}
|
||||||
|
\definecolor{kit-yellow30}{cmyk}{0,.015,.3,0}
|
||||||
|
\definecolor{kit-yellow15}{cmyk}{0,.0075,.15,0}
|
||||||
|
|
||||||
|
\definecolor{KITorange}{rgb}{.87,.60,.10}
|
||||||
|
\definecolor{kit-orange100}{cmyk}{0,.45,1,0}
|
||||||
|
\definecolor{kit-orange70}{cmyk}{0,.315,.7,0}
|
||||||
|
\definecolor{kit-orange50}{cmyk}{0,.225,.5,0}
|
||||||
|
\definecolor{kit-orange30}{cmyk}{0,.135,.3,0}
|
||||||
|
\definecolor{kit-orange15}{cmyk}{0,.0675,.15,0}
|
||||||
|
|
||||||
|
\definecolor{KITred}{rgb}{.63,.13,.13}
|
||||||
|
\definecolor{kit-red100}{cmyk}{.25,1,1,0}
|
||||||
|
\definecolor{kit-red70}{cmyk}{.175,.7,.7,0}
|
||||||
|
\definecolor{kit-red50}{cmyk}{.125,.5,.5,0}
|
||||||
|
\definecolor{kit-red30}{cmyk}{.075,.3,.3,0}
|
||||||
|
\definecolor{kit-red15}{cmyk}{.0375,.15,.15,0}
|
||||||
|
|
||||||
|
\definecolor{KITpurple}{RGB}{160,0,120}
|
||||||
|
\colorlet{kit-purple100}{KITpurple}
|
||||||
|
\colorlet{kit-purple70}{KITpurple!70}
|
||||||
|
\colorlet{kit-purple50}{KITpurple!50}
|
||||||
|
\colorlet{kit-purple30}{KITpurple!30}
|
||||||
|
\colorlet{kit-purple15}{KITpurple!15}
|
||||||
|
|
||||||
|
\definecolor{KITcyanblue}{RGB}{80,170,230}
|
||||||
|
\colorlet{kit-cyanblue100}{KITcyanblue}
|
||||||
|
\colorlet{kit-cyanblue70}{KITcyanblue!70}
|
||||||
|
\colorlet{kit-cyanblue50}{KITcyanblue!50}
|
||||||
|
\colorlet{kit-cyanblue30}{KITcyanblue!30}
|
||||||
|
\colorlet{kit-cyanblue15}{KITcyanblue!15}
|
||||||
|
|
||||||
|
% Matplotlib Colors
|
||||||
|
|
||||||
|
\definecolor{Mpl1}{HTML}{1f77b4}
|
||||||
|
\definecolor{Mpl2}{HTML}{ff7f0e}
|
||||||
|
\definecolor{Mpl3}{HTML}{2ca02c}
|
||||||
|
\definecolor{Mpl4}{HTML}{d62728}
|
||||||
|
\definecolor{Mpl5}{HTML}{9467bd}
|
||||||
|
\definecolor{Mpl6}{HTML}{8c564b}
|
||||||
|
\definecolor{Mpl7}{HTML}{e377c2}
|
||||||
|
\definecolor{Mpl8}{HTML}{7f7f7f}
|
||||||
|
\definecolor{Mpl9}{HTML}{bcbd22}
|
||||||
|
\definecolor{Mpl10}{HTML}{17becf}
|
||||||
|
|
||||||
|
%
|
||||||
|
% Color Schemes
|
||||||
|
%
|
||||||
|
|
||||||
|
% Define colormaps
|
||||||
|
|
||||||
|
\pgfplotsset{
|
||||||
|
colormap={mako}{
|
||||||
|
rgb=(0.18195582, 0.11955283, 0.23136943)
|
||||||
|
rgb=(0.25307401, 0.23772973, 0.48316271)
|
||||||
|
rgb=(0.21607792, 0.39736958, 0.61948028)
|
||||||
|
rgb=(0.20344718, 0.56074869, 0.65649508)
|
||||||
|
rgb=(0.25187832, 0.71827158, 0.67872193)
|
||||||
|
rgb=(0.54578602, 0.8544913, 0.69848331)
|
||||||
|
},
|
||||||
|
colormap={rocket}{
|
||||||
|
rgb=(0.20973515, 0.09747934, 0.24238489)
|
||||||
|
rgb=(0.43860848, 0.12177004, 0.34119475)
|
||||||
|
rgb=(0.67824099, 0.09192342, 0.3504148)
|
||||||
|
rgb=(0.8833417, 0.19830556, 0.26014181)
|
||||||
|
rgb=(0.95381595, 0.46373781, 0.31769923)
|
||||||
|
rgb=(0.96516917, 0.70776351, 0.5606593)
|
||||||
|
},
|
||||||
|
colormap={cividis}{
|
||||||
|
rgb=(0.130669, 0.231458, 0.43284)
|
||||||
|
rgb=(0.298421, 0.332247, 0.423973)
|
||||||
|
rgb=(0.42512, 0.431334, 0.447692)
|
||||||
|
rgb=(0.555393, 0.537807, 0.471147)
|
||||||
|
rgb=(0.695985, 0.648334, 0.440072)
|
||||||
|
rgb=(0.849223, 0.771947, 0.359729)
|
||||||
|
},
|
||||||
|
colormap={cel}{
|
||||||
|
color=(KITred!90!black);
|
||||||
|
color=(kit-blue100);
|
||||||
|
color=(kit-green70);
|
||||||
|
color=(kit-yellow70!80!kit-orange70);
|
||||||
|
},
|
||||||
|
colormap={matplotlib}{
|
||||||
|
% Source: https://github.com/matplotlib/matplotlib/blob/e5a85f960b2d47eac371cff709b830d52c36d267/lib/matplotlib/_cm.py#L1114
|
||||||
|
rgb=(0.2298057, 0.298717966, 0.753683153)
|
||||||
|
rgb=(0.26623388, 0.353094838, 0.801466763)
|
||||||
|
rgb=(0.30386891, 0.406535296, 0.84495867 )
|
||||||
|
rgb=(0.342804478, 0.458757618, 0.883725899)
|
||||||
|
rgb=(0.38301334, 0.50941904, 0.917387822)
|
||||||
|
rgb=(0.424369608, 0.558148092, 0.945619588)
|
||||||
|
rgb=(0.46666708, 0.604562568, 0.968154911)
|
||||||
|
rgb=(0.509635204, 0.648280772, 0.98478814 )
|
||||||
|
rgb=(0.552953156, 0.688929332, 0.995375608)
|
||||||
|
rgb=(0.596262162, 0.726149107, 0.999836203)
|
||||||
|
rgb=(0.639176211, 0.759599947, 0.998151185)
|
||||||
|
rgb=(0.681291281, 0.788964712, 0.990363227)
|
||||||
|
rgb=(0.722193294, 0.813952739, 0.976574709)
|
||||||
|
rgb=(0.761464949, 0.834302879, 0.956945269)
|
||||||
|
rgb=(0.798691636, 0.849786142, 0.931688648)
|
||||||
|
rgb=(0.833466556, 0.860207984, 0.901068838)
|
||||||
|
rgb=(0.865395197, 0.86541021, 0.865395561)
|
||||||
|
rgb=(0.897787179, 0.848937047, 0.820880546)
|
||||||
|
rgb=(0.924127593, 0.827384882, 0.774508472)
|
||||||
|
rgb=(0.944468518, 0.800927443, 0.726736146)
|
||||||
|
rgb=(0.958852946, 0.769767752, 0.678007945)
|
||||||
|
rgb=(0.96732803, 0.734132809, 0.628751763)
|
||||||
|
rgb=(0.969954137, 0.694266682, 0.579375448)
|
||||||
|
rgb=(0.966811177, 0.650421156, 0.530263762)
|
||||||
|
rgb=(0.958003065, 0.602842431, 0.481775914)
|
||||||
|
rgb=(0.943660866, 0.551750968, 0.434243684)
|
||||||
|
rgb=(0.923944917, 0.49730856, 0.387970225)
|
||||||
|
rgb=(0.89904617, 0.439559467, 0.343229596)
|
||||||
|
rgb=(0.869186849, 0.378313092, 0.300267182)
|
||||||
|
rgb=(0.834620542, 0.312874446, 0.259301199)
|
||||||
|
rgb=(0.795631745, 0.24128379, 0.220525627)
|
||||||
|
rgb=(0.752534934, 0.157246067, 0.184115123)
|
||||||
|
rgb=(0.705673158, 0.01555616, 0.150232812)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
% Define cycle lists
|
||||||
|
|
||||||
|
\pgfplotscreateplotcyclelist{mako}{%
|
||||||
|
[samples of colormap={4} of mako]%
|
||||||
|
}
|
||||||
|
\pgfplotscreateplotcyclelist{rocket}{%
|
||||||
|
[samples of colormap={4} of rocket]%
|
||||||
|
}
|
||||||
|
\pgfplotscreateplotcyclelist{cividis}{%
|
||||||
|
[samples of colormap={4} of cividis]%
|
||||||
|
}
|
||||||
|
\pgfplotscreateplotcyclelist{viridis}{%
|
||||||
|
[samples of colormap={4} of viridis]%
|
||||||
|
}
|
||||||
|
\pgfplotscreateplotcyclelist{cel}{%
|
||||||
|
[samples of colormap={4} of cel]%
|
||||||
|
}
|
||||||
|
\pgfplotscreateplotcyclelist{matplotlib}{%
|
||||||
|
{Mpl1},{Mpl2},{Mpl3},{Mpl4}
|
||||||
|
}
|
||||||
|
|
||||||
|
% Define 'scolX' colors
|
||||||
|
|
||||||
|
\makeatletter
|
||||||
|
|
||||||
|
\def\extractcolormapcolor#1#2{%
|
||||||
|
\expandafter\pgfplotscolormapaccess\expandafter[\pgfplotspointmetatransformedrange]%
|
||||||
|
[1.0]%
|
||||||
|
{#2}%
|
||||||
|
{\pgfkeysvalueof{/pgfplots/colormap name}}%
|
||||||
|
\def\pgfplots@loc@TMPb{\pgfutil@definecolor{#1}{\csname pgfpl@cm@\pgfkeysvalueof{/pgfplots/colormap name}@colspace\endcsname}}%
|
||||||
|
\expandafter\pgfplots@loc@TMPb\expandafter{\pgfmathresult}%
|
||||||
|
}%
|
||||||
|
|
||||||
|
\def\getcolorbyvalue#1{
|
||||||
|
\csname pgfpl@cm@\pgfkeysvalueof{/pgfplots/colormap name}@colspace\endcsname
|
||||||
|
}
|
||||||
|
|
||||||
|
\makeatother
|
||||||
|
|
||||||
|
\def\setschemecolorsfrommap{
|
||||||
|
\extractcolormapcolor{scol0}{0}
|
||||||
|
\extractcolormapcolor{scol1}{333}
|
||||||
|
\extractcolormapcolor{scol2}{666}
|
||||||
|
\extractcolormapcolor{scol3}{1000}
|
||||||
|
}
|
||||||
|
|
||||||
|
\newcommand{\setschemecolorsmanually}[4]{
|
||||||
|
\colorlet{scol0}{#1}
|
||||||
|
\colorlet{scol1}{#2}
|
||||||
|
\colorlet{scol2}{#3}
|
||||||
|
\colorlet{scol3}{#4}
|
||||||
|
}
|
||||||
|
|
||||||
|
% Define color schemes
|
||||||
|
|
||||||
|
\pgfplotsset{
|
||||||
|
/pgfplots/colorscheme/cel/.style={
|
||||||
|
colormap name={cel},
|
||||||
|
cycle list name={cel},
|
||||||
|
/utils/exec={\setschemecolorsfrommap},
|
||||||
|
},
|
||||||
|
/pgfplots/colorscheme/rocket/.style={
|
||||||
|
colormap name={rocket},
|
||||||
|
cycle list name={rocket},
|
||||||
|
/utils/exec={\setschemecolorsfrommap},
|
||||||
|
},
|
||||||
|
/pgfplots/colorscheme/viridis/.style={
|
||||||
|
colormap name={viridis},
|
||||||
|
cycle list name={viridis},
|
||||||
|
/utils/exec={\setschemecolorsfrommap},
|
||||||
|
},
|
||||||
|
/pgfplots/colorscheme/mako/.style={
|
||||||
|
colormap name={mako},
|
||||||
|
cycle list name={mako},
|
||||||
|
/utils/exec={\setschemecolorsfrommap},
|
||||||
|
},
|
||||||
|
/pgfplots/colorscheme/cividis/.style={
|
||||||
|
colormap name={cividis},
|
||||||
|
cycle list name={cividis},
|
||||||
|
/utils/exec={\setschemecolorsfrommap},
|
||||||
|
},
|
||||||
|
/pgfplots/colorscheme/matplotlib/.style={
|
||||||
|
colormap name={matplotlib},
|
||||||
|
cycle list name={matplotlib},
|
||||||
|
/utils/exec={\setschemecolorsmanually{Mpl1}{Mpl2}{Mpl3}{Mpl4}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
||||||
|
|
||||||
|
|
||||||
\input{../lib/latex-common/common.tex}
|
\input{common.tex}
|
||||||
\pgfplotsset{colorscheme/rocket}
|
\pgfplotsset{colorscheme/rocket}
|
||||||
|
|
||||||
|
|
||||||
@@ -38,13 +38,13 @@
|
|||||||
\newcommand{\slice}[6]{
|
\newcommand{\slice}[6]{
|
||||||
\pgfmathparse{0.5*#1+0.5*#2}
|
\pgfmathparse{0.5*#1+0.5*#2}
|
||||||
\let\midangle\pgfmathresult
|
\let\midangle\pgfmathresult
|
||||||
|
|
||||||
% slice
|
% slice
|
||||||
\fill[thick,color=#5] (0,0) -- (#1:1) arc (#1:#2+1:1) -- (0,0);
|
\fill[thick,color=#5] (0,0) -- (#1:1) arc (#1:#2+1:1) -- (0,0);
|
||||||
|
|
||||||
% outer label
|
% outer label
|
||||||
\node[label=\midangle:#4] at (\midangle:1) {};
|
\node[label=\midangle:#4] at (\midangle:1) {};
|
||||||
|
|
||||||
% inner label
|
% inner label
|
||||||
\pgfmathparse{min((#2-#1-10)/110*(-0.3),0)}
|
\pgfmathparse{min((#2-#1-10)/110*(-0.3),0)}
|
||||||
\let\temp\pgfmathresult
|
\let\temp\pgfmathresult
|
||||||
@@ -58,13 +58,13 @@
|
|||||||
\newcounter{pieSliceB}
|
\newcounter{pieSliceB}
|
||||||
\newcommand{\pie}[1]{
|
\newcommand{\pie}[1]{
|
||||||
% Count elements
|
% Count elements
|
||||||
\setcounter{pieElem}{0}%
|
\setcounter{pieElem}{0}%
|
||||||
\foreach\pieElem in {#1}{\stepcounter{pieElem}}%
|
\foreach\pieElem in {#1}{\stepcounter{pieElem}}%
|
||||||
\edef\numElements{\arabic{pieElem}}
|
\edef\numElements{\arabic{pieElem}}
|
||||||
|
|
||||||
% Draw pie chart
|
% Draw pie chart
|
||||||
\setcounter{pieSliceA}{0}%
|
\setcounter{pieSliceA}{0}%
|
||||||
\setcounter{pieSliceB}{0}%
|
\setcounter{pieSliceB}{0}%
|
||||||
\foreach \xi/\t [count=\xk from 0] in {#1} {
|
\foreach \xi/\t [count=\xk from 0] in {#1} {
|
||||||
% Get colors
|
% Get colors
|
||||||
\pgfmathparse{1000 / (\numElements - 1) * \xk}
|
\pgfmathparse{1000 / (\numElements - 1) * \xk}
|
||||||
@@ -81,16 +81,16 @@
|
|||||||
|
|
||||||
\newcommand{\csvPie}[1]{
|
\newcommand{\csvPie}[1]{
|
||||||
% Count elements
|
% Count elements
|
||||||
\setcounter{pieElem}{0}%
|
\setcounter{pieElem}{0}%
|
||||||
\csvreader[head to column names]{#1}{}{%
|
\csvreader[head to column names]{#1}{}{%
|
||||||
\stepcounter{pieElem}
|
\stepcounter{pieElem}
|
||||||
}
|
}
|
||||||
\edef\numElements{\arabic{pieElem}}
|
\edef\numElements{\arabic{pieElem}}
|
||||||
|
|
||||||
% Draw pie chart
|
% Draw pie chart
|
||||||
\setcounter{pieElem}{0}%
|
\setcounter{pieElem}{0}%
|
||||||
\setcounter{pieSliceA}{0}%
|
\setcounter{pieSliceA}{0}%
|
||||||
\setcounter{pieSliceB}{0}%
|
\setcounter{pieSliceB}{0}%
|
||||||
\csvreader[head to column names]{#1}{}{%
|
\csvreader[head to column names]{#1}{}{%
|
||||||
% Get colors
|
% Get colors
|
||||||
\pgfmathparse{1000 / (\numElements - 1) * \thepieElem}
|
\pgfmathparse{1000 / (\numElements - 1) * \thepieElem}
|
||||||
@@ -157,7 +157,7 @@
|
|||||||
]
|
]
|
||||||
% Dummy plot to set x axis ticks
|
% Dummy plot to set x axis ticks
|
||||||
\addplot[draw=none]
|
\addplot[draw=none]
|
||||||
table[col sep=comma, x=t, y=value]
|
table[col sep=comma, x=t, y=net]
|
||||||
{net_income.csv};
|
{net_income.csv};
|
||||||
|
|
||||||
% Dummy plot to set x axis scale
|
% Dummy plot to set x axis scale
|
||||||
@@ -165,11 +165,17 @@
|
|||||||
table[col sep=comma, x=t, y expr=0]
|
table[col sep=comma, x=t, y expr=0]
|
||||||
{detailed_balance.csv};
|
{detailed_balance.csv};
|
||||||
|
|
||||||
\addplot[ybar, color=scol2, fill=scol2, line width=1pt]
|
\addplot[ybar, bar width=0.4cm, draw=none, fill=scol2!30, line width=1pt]
|
||||||
table[col sep=comma, x=t, y=value, discard if lt={value}{0}]
|
table[col sep=comma, x=t, y=income]
|
||||||
{net_income.csv};
|
{net_income.csv};
|
||||||
\addplot[ybar, color=scol0, fill=scol0, line width=1pt]
|
\addplot[ybar, bar width=0.4cm, draw=none, fill=scol0!30, line width=1pt]
|
||||||
table[col sep=comma, x=t, y=value, discard if gt={value}{0}]
|
table[col sep=comma, x=t, y=expenses]
|
||||||
|
{net_income.csv};
|
||||||
|
\addplot[ybar, bar width=0.3cm, draw=none, fill=scol0, line width=1pt]
|
||||||
|
table[col sep=comma, x=t, y=net, discard if gt={net}{0}]
|
||||||
|
{net_income.csv};
|
||||||
|
\addplot[ybar, bar width=0.3cm, draw=none, fill=scol2, line width=1pt]
|
||||||
|
table[col sep=comma, x=t, y=net, discard if lt={net}{0}]
|
||||||
{net_income.csv};
|
{net_income.csv};
|
||||||
\end{axis}
|
\end{axis}
|
||||||
\end{tikzpicture}
|
\end{tikzpicture}
|
||||||
@@ -211,6 +217,105 @@
|
|||||||
\caption{Development of account balance over time}
|
\caption{Development of account balance over time}
|
||||||
\end{figure}
|
\end{figure}
|
||||||
|
|
||||||
|
\begin{figure}
|
||||||
|
\centering
|
||||||
|
|
||||||
|
|
||||||
|
\csvautotabular{net_income.csv}
|
||||||
|
\end{figure}
|
||||||
|
|
||||||
|
|
||||||
|
\begin{figure}[H]
|
||||||
|
\centering
|
||||||
|
|
||||||
|
% Read table
|
||||||
|
\pgfplotstableread[col sep=comma]{expenses_by_category.csv}\expbycattable
|
||||||
|
\pgfplotstablegetcolsof{\expbycattable}
|
||||||
|
\pgfmathtruncatemacro\NumCols{\pgfplotsretval-1}
|
||||||
|
|
||||||
|
\begin{subfigure}[c]{\textwidth}
|
||||||
|
\centering
|
||||||
|
|
||||||
|
\begin{tikzpicture}
|
||||||
|
\begin{axis}[
|
||||||
|
stack plots=y,
|
||||||
|
area style,
|
||||||
|
date coordinates in=x,
|
||||||
|
width=\textwidth,
|
||||||
|
height=0.375\textwidth,
|
||||||
|
xticklabel=\month.\shortyear{\year},
|
||||||
|
xtick=data,
|
||||||
|
enlargelimits=false,
|
||||||
|
xticklabel style={
|
||||||
|
rotate=60,
|
||||||
|
anchor=near xticklabel,
|
||||||
|
},
|
||||||
|
legend columns=5,
|
||||||
|
legend style={at={(0.5,-0.6)},anchor=south},
|
||||||
|
ylabel={Expenses in €},
|
||||||
|
ymin=0,
|
||||||
|
]
|
||||||
|
% For each
|
||||||
|
\pgfplotsinvokeforeach{0,...,\NumCols/2 -1}{
|
||||||
|
% Define color
|
||||||
|
\pgfmathparse{1000 / (\NumCols/2 -1) * #1}
|
||||||
|
\extractcolormapcolor{tempcol#1}{\pgfmathresult}
|
||||||
|
|
||||||
|
% Add plot
|
||||||
|
\addplot+[tempcol#1]
|
||||||
|
table[col sep=comma, x=t, y index=#1]
|
||||||
|
{\expbycattable} \closedcycle;
|
||||||
|
|
||||||
|
% Add legend entry (https://tex.stackexchange.com/a/405018)
|
||||||
|
\pgfplotstablegetcolumnnamebyindex{#1}\of{\expbycattable}\to\pgfplotsretval
|
||||||
|
\expandafter\addlegendentry\expandafter{\pgfplotsretval}
|
||||||
|
}
|
||||||
|
\end{axis}
|
||||||
|
\end{tikzpicture}
|
||||||
|
\end{subfigure}\\[1em]
|
||||||
|
\begin{subfigure}[c]{\textwidth}
|
||||||
|
\centering
|
||||||
|
|
||||||
|
\begin{tikzpicture}
|
||||||
|
\begin{axis}[
|
||||||
|
stack plots=y,
|
||||||
|
area style,
|
||||||
|
date coordinates in=x,
|
||||||
|
width=\textwidth,
|
||||||
|
height=0.375\textwidth,
|
||||||
|
xticklabel=\month.\shortyear{\year},
|
||||||
|
xtick=data,
|
||||||
|
enlargelimits=false,
|
||||||
|
xticklabel style={
|
||||||
|
rotate=60,
|
||||||
|
anchor=near xticklabel,
|
||||||
|
},
|
||||||
|
legend columns=5,
|
||||||
|
legend style={at={(0.5,-0.6)},anchor=south},
|
||||||
|
ylabel={Expenses in €},
|
||||||
|
ymin=0,
|
||||||
|
]
|
||||||
|
% For each
|
||||||
|
\pgfplotsinvokeforeach{\NumCols/2,...,\NumCols-1}{
|
||||||
|
% Define color
|
||||||
|
\pgfmathparse{1000 * (#1 - \NumCols/2) / (\NumCols-1 - \NumCols/2)}
|
||||||
|
\extractcolormapcolor{tempcol#1}{\pgfmathresult}
|
||||||
|
|
||||||
|
% Add plot
|
||||||
|
\addplot+[tempcol#1]
|
||||||
|
table[col sep=comma, x=t, y index=#1]
|
||||||
|
{\expbycattable} \closedcycle;
|
||||||
|
|
||||||
|
% Add legend entry (https://tex.stackexchange.com/a/405018)
|
||||||
|
\pgfplotstablegetcolumnnamebyindex{#1}\of{\expbycattable}\to\pgfplotsretval
|
||||||
|
\expandafter\addlegendentry\expandafter{\pgfplotsretval}
|
||||||
|
}
|
||||||
|
\end{axis}
|
||||||
|
\end{tikzpicture}
|
||||||
|
\end{subfigure}
|
||||||
|
|
||||||
|
\caption{Expenses by category}
|
||||||
|
\end{figure}
|
||||||
|
|
||||||
\end{document}
|
\end{document}
|
||||||
|
|
||||||
54
banking_breakdown/regex_categorizer.py
Normal file
54
banking_breakdown/regex_categorizer.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import pandas as pd
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
def _is_str_column(s: pd.Series):
|
||||||
|
"""Check if the type of a pandas DataFrame column is str.
|
||||||
|
|
||||||
|
Taken from https://stackoverflow.com/a/67001213/3433817.
|
||||||
|
"""
|
||||||
|
if isinstance(s.dtype, pd.StringDtype):
|
||||||
|
# The series was explicitly created as a string series (Pandas>=1.0.0)
|
||||||
|
return True
|
||||||
|
elif s.dtype == 'object':
|
||||||
|
# Object series, check each value
|
||||||
|
return all((v is None) or isinstance(v, str) for v in s)
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _read_regex_dict(regex_file: str):
|
||||||
|
with open(regex_file, 'r') as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def assign_categories(df: pd.DataFrame, regex_file: str) -> pd.DataFrame:
|
||||||
|
if 'category' not in df.columns:
|
||||||
|
df['category'] = [' '] * len(df.index)
|
||||||
|
|
||||||
|
regex_dict = _read_regex_dict(regex_file)
|
||||||
|
|
||||||
|
df = df.fillna('')
|
||||||
|
|
||||||
|
for column in df.columns:
|
||||||
|
if not _is_str_column(df[column]):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for category in regex_dict:
|
||||||
|
for regex in regex_dict[category]:
|
||||||
|
matched = df[column].str.contains(regex, regex=True)
|
||||||
|
df.loc[matched, 'category'] = category
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
df = pd.read_csv('../res/bank_statement_2023_categorized.csv')
|
||||||
|
|
||||||
|
df = assign_categories(df, regex_file='../res/regexes.json')
|
||||||
|
|
||||||
|
print(df['category'])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -6,94 +6,117 @@ import re
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
# def _read_regex_dict(regex_file: str = "res/category_regexes.json"):
|
def _escape_string(to_escape: str):
|
||||||
# with open(regex_file, 'r') as f:
|
return to_escape.translate(str.maketrans({"&": r"\&"}))
|
||||||
# return json.load(f)
|
|
||||||
#
|
|
||||||
#
|
|
||||||
# def _tag_with_category(df: pd.DataFrame) -> pd.DataFrame:
|
|
||||||
# regex_dict = _read_regex_dict()
|
|
||||||
#
|
|
||||||
# return df
|
|
||||||
#
|
|
||||||
#
|
|
||||||
# def _compute_total_balance(df: pd.DataFrame) -> pd.DataFrame:
|
|
||||||
# stripped_df = pd.DataFrame(
|
|
||||||
# {'t': df["Valutadatum"], 'value': df["Saldo nach Buchung"]})
|
|
||||||
#
|
|
||||||
# stripped_df.index = stripped_df['t']
|
|
||||||
# gb = stripped_df.groupby(pd.Grouper(freq='M'))
|
|
||||||
#
|
|
||||||
# result = gb.tail(1)['value'].reset_index()
|
|
||||||
#
|
|
||||||
# return result
|
|
||||||
#
|
|
||||||
#
|
|
||||||
# def _compute_net_income(df: pd.DataFrame) -> pd.DataFrame:
|
|
||||||
# stripped_df = pd.DataFrame({'t': df["Valutadatum"], 'value': df["Betrag"]})
|
|
||||||
#
|
|
||||||
# stripped_df.index = stripped_df['t']
|
|
||||||
# gb = stripped_df.groupby(pd.Grouper(freq='M'))
|
|
||||||
#
|
|
||||||
# result = gb["value"].sum().reset_index()
|
|
||||||
# return result
|
|
||||||
#
|
|
||||||
#
|
|
||||||
# def _compute_category_overview(df: pd.DataFrame) -> pd.DataFrame:
|
|
||||||
# categories = ["Social life", "Other", "Food", "Hobbies",
|
|
||||||
# "Rent \\& Utilities", "Education", "Transportation"]
|
|
||||||
# values = np.array([10, 12, 53, 12, 90, 23, 32])
|
|
||||||
# values = values / values.sum() * 100
|
|
||||||
# values = np.round(values, decimals=1)
|
|
||||||
# values[-1] += 100 - np.sum(values)
|
|
||||||
#
|
|
||||||
# category_overview_df = pd.DataFrame(
|
|
||||||
# {"category": categories, "value": values})
|
|
||||||
#
|
|
||||||
# return category_overview_df
|
|
||||||
#
|
|
||||||
#
|
|
||||||
# def _compute_detailed_balance(df: pd.DataFrame) -> pd.DataFrame:
|
|
||||||
# return pd.DataFrame({'t': df["Valutadatum"],
|
|
||||||
# 'value': df["Saldo nach Buchung"]})
|
|
||||||
#
|
|
||||||
#
|
|
||||||
# def parse_statement(filename: str) -> types.ReportData:
|
|
||||||
# df = pd.read_csv(filename, delimiter=';', decimal=",")
|
|
||||||
# df["Valutadatum"] = pd.to_datetime(df["Valutadatum"], format='%d.%m.%Y')
|
|
||||||
#
|
|
||||||
# category_overview_df = _compute_category_overview(df)
|
|
||||||
# total_balance_df = _compute_total_balance(df)
|
|
||||||
# net_income_df = _compute_net_income(df)
|
|
||||||
# detailed_balance_df = _compute_detailed_balance(df)
|
|
||||||
#
|
|
||||||
# return types.ReportData(category_overview_df,
|
|
||||||
# net_income_df,
|
|
||||||
# total_balance_df,
|
|
||||||
# detailed_balance_df)
|
|
||||||
#
|
|
||||||
#
|
|
||||||
# def main():
|
|
||||||
# report_data = parse_statement("../res/bank_statement_2023.csv")
|
|
||||||
#
|
|
||||||
#
|
|
||||||
# if __name__ == "__main__":
|
|
||||||
# main()
|
|
||||||
|
|
||||||
|
|
||||||
def get_stripped_statement(filename: str) -> pd.DataFrame:
|
def _compute_total_balance(df: pd.DataFrame) -> pd.DataFrame:
|
||||||
# df = pd.read_csv(filename, delimiter=';', decimal=",")
|
stripped_df = pd.DataFrame(
|
||||||
df = pd.read_csv(filename, delimiter=';')
|
{'t': df["t"], 'value': df["balance"]})
|
||||||
df["Valutadatum"] = (pd.to_datetime(df["Valutadatum"], format='%d.%m.%Y')
|
|
||||||
.dt.strftime('%Y-%m-%d'))
|
|
||||||
|
|
||||||
result = pd.DataFrame({'t': df["Valutadatum"],
|
stripped_df.index = stripped_df['t']
|
||||||
'other party': df["Name Zahlungsbeteiligter"],
|
gb = stripped_df.groupby(pd.Grouper(freq='M'))
|
||||||
'value': df["Betrag"],
|
|
||||||
'balance': df["Saldo nach Buchung"],
|
result = gb.tail(1)['value'].reset_index()
|
||||||
'category': [''] * len(df["Valutadatum"]),
|
|
||||||
'description': df["Buchungstext"],
|
|
||||||
'purpose': df["Verwendungszweck"]
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_net_income(df: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
df.index = df['t']
|
||||||
|
income_df = df.loc[df['value'] > 0]
|
||||||
|
expenses_df = df.loc[df['value'] < 0]
|
||||||
|
|
||||||
|
income_df = income_df.groupby(pd.Grouper(freq='M'))[
|
||||||
|
'value'].sum().reset_index().round(decimals=2)
|
||||||
|
expenses_df = expenses_df.groupby(pd.Grouper(freq='M'))[
|
||||||
|
'value'].sum().reset_index().round(decimals=2)
|
||||||
|
|
||||||
|
t = income_df['t']
|
||||||
|
income = income_df['value'].round(decimals=2)
|
||||||
|
expenses = expenses_df['value'].round(decimals=2)
|
||||||
|
net = (income + expenses).round(decimals=2)
|
||||||
|
|
||||||
|
result_df = pd.DataFrame(
|
||||||
|
{'t': t, 'income': income, 'expenses': expenses, 'net': net})
|
||||||
|
|
||||||
|
return result_df
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_category_overview(df: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
df = df.loc[df['value'] < 0]
|
||||||
|
df = df.drop('t', axis=1)
|
||||||
|
df = df.groupby(['category']).sum().reset_index()
|
||||||
|
values = (df['value'] / df['value'].sum() * 100).to_numpy()
|
||||||
|
values[-1] += 100 - np.sum(values)
|
||||||
|
values = np.round(values, decimals=1)
|
||||||
|
categories = [_escape_string(category) for category in df['category']]
|
||||||
|
|
||||||
|
category_overview_df = pd.DataFrame(
|
||||||
|
{"category": categories, "value": values})
|
||||||
|
|
||||||
|
category_overview_df = category_overview_df.sort_values('value',
|
||||||
|
ascending=False)
|
||||||
|
|
||||||
|
return category_overview_df
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_expenses_by_category(complete_df: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
complete_df = complete_df.loc[complete_df['value'] < 0].copy()
|
||||||
|
complete_df['value'] = -complete_df['value']
|
||||||
|
complete_df.index = complete_df['t']
|
||||||
|
|
||||||
|
complete_gb = complete_df.groupby(pd.Grouper(freq='M'))
|
||||||
|
|
||||||
|
categories = complete_df['category'].unique()
|
||||||
|
data_dict = {category: [] for category in categories}
|
||||||
|
|
||||||
|
for (month_date, month_df) in complete_gb:
|
||||||
|
month_df = month_df.drop('t', axis=1).reset_index().drop('t', axis=1)
|
||||||
|
|
||||||
|
category_df = month_df.groupby(['category']).sum().reset_index()
|
||||||
|
|
||||||
|
for _, row in category_df.iterrows():
|
||||||
|
data_dict[row['category']].append(row['value'])
|
||||||
|
|
||||||
|
non_listed = list(set(categories) - set(category_df['category']))
|
||||||
|
for category in non_listed:
|
||||||
|
data_dict[category].append(0)
|
||||||
|
|
||||||
|
result = pd.DataFrame(data_dict)
|
||||||
|
result = result.reindex(result.mean().sort_values(ascending=False).index,
|
||||||
|
axis=1)
|
||||||
|
result = result.round(decimals=2)
|
||||||
|
result['t'] = complete_gb.tail(1).drop('t', axis=1).reset_index()['t']
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_detailed_balance(df: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
return pd.DataFrame({'t': df["t"],
|
||||||
|
'value': df["balance"]})
|
||||||
|
|
||||||
|
|
||||||
|
def parse_statement(filename: str) -> types.ReportData:
|
||||||
|
df = pd.read_csv(filename)
|
||||||
|
df["t"] = pd.to_datetime(df["t"], format='%Y-%m-%d')
|
||||||
|
|
||||||
|
category_overview_df = _compute_category_overview(df)
|
||||||
|
total_balance_df = _compute_total_balance(df)
|
||||||
|
net_income_df = _compute_net_income(df)
|
||||||
|
detailed_balance_df = _compute_detailed_balance(df)
|
||||||
|
expenses_by_category_df = _compute_expenses_by_category(df)
|
||||||
|
|
||||||
|
return types.ReportData(category_overview_df,
|
||||||
|
expenses_by_category_df,
|
||||||
|
net_income_df,
|
||||||
|
total_balance_df,
|
||||||
|
detailed_balance_df, )
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
report_data = parse_statement("../res/bank_statement_2023_categorized.csv")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import pandas as pd
|
|||||||
@dataclass
|
@dataclass
|
||||||
class ReportData:
|
class ReportData:
|
||||||
category_overview: pd.DataFrame
|
category_overview: pd.DataFrame
|
||||||
|
expenses_by_category: pd.DataFrame
|
||||||
net_income: pd.DataFrame
|
net_income: pd.DataFrame
|
||||||
total_value: pd.DataFrame
|
total_value: pd.DataFrame
|
||||||
detailed_balance: pd.DataFrame
|
detailed_balance: pd.DataFrame
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ from PyQt6.QtGui import QPixmap, QAction
|
|||||||
from PyQt6.QtWidgets import QHBoxLayout, QLabel, QMenu, QInputDialog, \
|
from PyQt6.QtWidgets import QHBoxLayout, QLabel, QMenu, QInputDialog, \
|
||||||
QMessageBox
|
QMessageBox
|
||||||
|
|
||||||
|
import banking_breakdown.ui
|
||||||
from banking_breakdown.ui.pandas_model import PandasModel
|
from banking_breakdown.ui.pandas_model import PandasModel
|
||||||
|
|
||||||
|
from importlib import resources
|
||||||
|
|
||||||
|
|
||||||
class WarningItem(QHBoxLayout):
|
class WarningItem(QHBoxLayout):
|
||||||
"""Item appearing at top of Window with warning icon."""
|
"""Item appearing at top of Window with warning icon."""
|
||||||
@@ -14,7 +17,8 @@ class WarningItem(QHBoxLayout):
|
|||||||
super(WarningItem, self).__init__()
|
super(WarningItem, self).__init__()
|
||||||
|
|
||||||
self._warningIcon = QLabel()
|
self._warningIcon = QLabel()
|
||||||
pixmap = QPixmap("res/warning.png")
|
pixmap_path = f"{resources.files(banking_breakdown.ui)}/warning.png"
|
||||||
|
pixmap = QPixmap(pixmap_path)
|
||||||
self._warningIcon.setPixmap(pixmap)
|
self._warningIcon.setPixmap(pixmap)
|
||||||
|
|
||||||
self._label = QLabel(text)
|
self._label = QLabel(text)
|
||||||
@@ -35,16 +39,16 @@ class HeaderContextMenu(QMenu):
|
|||||||
"""Context menu appearing when right-clicking the header of the QTableView.
|
"""Context menu appearing when right-clicking the header of the QTableView.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, column, pandas_model: PandasModel, callback=None,
|
def __init__(self, column_index, pandas_model: PandasModel, callback=None,
|
||||||
parent=None):
|
parent=None):
|
||||||
super(HeaderContextMenu, self).__init__()
|
super(HeaderContextMenu, self).__init__()
|
||||||
|
|
||||||
self._column = column
|
|
||||||
self._pandas_model = pandas_model
|
self._pandas_model = pandas_model
|
||||||
self._callback = callback
|
self._callback = callback
|
||||||
|
|
||||||
|
self._column_index = column_index
|
||||||
self._column_text \
|
self._column_text \
|
||||||
= self._pandas_model.headerData(self._column,
|
= self._pandas_model.headerData(self._column_index,
|
||||||
Qt.Orientation.Horizontal)
|
Qt.Orientation.Horizontal)
|
||||||
|
|
||||||
# Define assign action
|
# Define assign action
|
||||||
@@ -85,9 +89,11 @@ class HeaderContextMenu(QMenu):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if (new_name != self._column_text) and (new_name != ''):
|
if (new_name != self._column_text) and (new_name != ''):
|
||||||
df = self._pandas_model.get_dataframe()
|
try:
|
||||||
df = df.rename(columns={self._column_text: new_name})
|
self._pandas_model.rename_column(self._column_text, new_name)
|
||||||
self._pandas_model.set_dataframe(df)
|
except:
|
||||||
|
QMessageBox.warning(self, "No action performed",
|
||||||
|
"An error occurred.")
|
||||||
|
|
||||||
if self._callback:
|
if self._callback:
|
||||||
self._callback()
|
self._callback()
|
||||||
@@ -98,10 +104,7 @@ class HeaderContextMenu(QMenu):
|
|||||||
f" column '{self._column_text}'?")
|
f" column '{self._column_text}'?")
|
||||||
|
|
||||||
if button == QMessageBox.StandardButton.Yes:
|
if button == QMessageBox.StandardButton.Yes:
|
||||||
df = self._pandas_model.get_dataframe()
|
self._pandas_model.delete_column_by_index(self._column_index)
|
||||||
df = df.iloc[:, [j for j, c
|
|
||||||
in enumerate(df.columns) if j != self._column]]
|
|
||||||
self._pandas_model.set_dataframe(df)
|
|
||||||
|
|
||||||
if self._callback:
|
if self._callback:
|
||||||
self._callback()
|
self._callback()
|
||||||
@@ -112,21 +115,14 @@ class HeaderContextMenu(QMenu):
|
|||||||
if column != self._column_text]
|
if column != self._column_text]
|
||||||
|
|
||||||
other_name, flag = QInputDialog.getItem(self, "Switch column position",
|
other_name, flag = QInputDialog.getItem(self, "Switch column position",
|
||||||
f"Switch position of colum"
|
f"Switch position of colum "
|
||||||
f" '{self._column_text}' with:",
|
f"'{self._column_text}' with:",
|
||||||
columns, editable=False)
|
columns, editable=False)
|
||||||
|
|
||||||
if not flag:
|
if not flag:
|
||||||
return
|
return
|
||||||
|
|
||||||
column_titles = list(df.columns)
|
self._pandas_model.switch_columns(self._column_text, other_name)
|
||||||
index1, index2 = column_titles.index(
|
|
||||||
self._column_text), column_titles.index(other_name)
|
|
||||||
column_titles[index1], column_titles[index2] \
|
|
||||||
= column_titles[index2], column_titles[index1]
|
|
||||||
|
|
||||||
df = df.reindex(columns=column_titles)
|
|
||||||
self._pandas_model.set_dataframe(df)
|
|
||||||
|
|
||||||
if self._callback:
|
if self._callback:
|
||||||
self._callback()
|
self._callback()
|
||||||
@@ -139,14 +135,12 @@ class HeaderContextMenu(QMenu):
|
|||||||
if not flag:
|
if not flag:
|
||||||
return
|
return
|
||||||
|
|
||||||
df = self._pandas_model.get_dataframe()
|
|
||||||
try:
|
try:
|
||||||
df[self._column_text] \
|
self._pandas_model.assign_date_column(self._column_text,
|
||||||
= pd.to_datetime(df[self._column_text], format=date_format)
|
date_format)
|
||||||
except:
|
except:
|
||||||
QMessageBox.warning(self, "No action performed",
|
QMessageBox.warning(self, "No action performed",
|
||||||
"An error occurred.")
|
"An error occurred.")
|
||||||
self._pandas_model.set_dataframe(df)
|
|
||||||
|
|
||||||
if self._callback:
|
if self._callback:
|
||||||
self._callback()
|
self._callback()
|
||||||
@@ -160,19 +154,12 @@ class HeaderContextMenu(QMenu):
|
|||||||
if not flag:
|
if not flag:
|
||||||
return
|
return
|
||||||
|
|
||||||
df = self._pandas_model.get_dataframe()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if decimal_sep == ',':
|
self._pandas_model.assign_float_column(self._column_text,
|
||||||
df[self._column_text] \
|
decimal_sep)
|
||||||
= df[self._column_text].str.replace(',', '.').astype(float)
|
|
||||||
else:
|
|
||||||
df[self._column_text] = df[self._column_text].astype(float)
|
|
||||||
except:
|
except:
|
||||||
QMessageBox.warning(self, "No action performed",
|
QMessageBox.warning(self, "No action performed",
|
||||||
"An error occurred.")
|
"An error occurred.")
|
||||||
|
|
||||||
self._pandas_model.set_dataframe(df)
|
|
||||||
|
|
||||||
if self._callback:
|
if self._callback:
|
||||||
self._callback()
|
self._callback()
|
||||||
|
|||||||
@@ -6,15 +6,19 @@ from PyQt6.QtGui import QAction
|
|||||||
from PyQt6.QtWidgets import QMainWindow, QPushButton, QVBoxLayout, \
|
from PyQt6.QtWidgets import QMainWindow, QPushButton, QVBoxLayout, \
|
||||||
QTableView, QInputDialog, QMessageBox, QFileDialog, QListWidget
|
QTableView, QInputDialog, QMessageBox, QFileDialog, QListWidget
|
||||||
|
|
||||||
|
import banking_breakdown.ui
|
||||||
from banking_breakdown.ui.pandas_model import PandasModel
|
from banking_breakdown.ui.pandas_model import PandasModel
|
||||||
from banking_breakdown.ui.custom_ui_items import WarningItem, HeaderContextMenu
|
from banking_breakdown.ui.custom_ui_items import WarningItem, HeaderContextMenu
|
||||||
|
|
||||||
|
|
||||||
|
from importlib import resources
|
||||||
|
|
||||||
class MainWindow(QMainWindow):
|
class MainWindow(QMainWindow):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super(MainWindow, self).__init__()
|
super(MainWindow, self).__init__()
|
||||||
|
|
||||||
uic.loadUi("res/main_window.ui", self)
|
ui_path = f"{resources.files(banking_breakdown.ui)}/main_window.ui"
|
||||||
|
uic.loadUi(ui_path, self)
|
||||||
self.setContentsMargins(9, 9, 9, 9)
|
self.setContentsMargins(9, 9, 9, 9)
|
||||||
|
|
||||||
self._warnings = []
|
self._warnings = []
|
||||||
@@ -25,6 +29,8 @@ class MainWindow(QMainWindow):
|
|||||||
= self.findChild(QVBoxLayout, "warningLayout")
|
= self.findChild(QVBoxLayout, "warningLayout")
|
||||||
self._create_button \
|
self._create_button \
|
||||||
= self.findChild(QPushButton, "createCategoryButton")
|
= self.findChild(QPushButton, "createCategoryButton")
|
||||||
|
self._rename_button \
|
||||||
|
= self.findChild(QPushButton, "renameCategoryButton")
|
||||||
self._delete_button \
|
self._delete_button \
|
||||||
= self.findChild(QPushButton, "deleteCategoryButton")
|
= self.findChild(QPushButton, "deleteCategoryButton")
|
||||||
self._apply_button \
|
self._apply_button \
|
||||||
@@ -38,18 +44,24 @@ class MainWindow(QMainWindow):
|
|||||||
self._action_save \
|
self._action_save \
|
||||||
= self.findChild(QAction, "actionSave")
|
= self.findChild(QAction, "actionSave")
|
||||||
|
|
||||||
|
# Set scrolling behavior
|
||||||
|
|
||||||
|
self._table_view.horizontalScrollBar().setSingleStep(10)
|
||||||
|
self._table_view.verticalScrollBar().setSingleStep(10)
|
||||||
|
|
||||||
# Set up QTableView model
|
# Set up QTableView model
|
||||||
|
|
||||||
self._pandas_model = PandasModel(self)
|
self._pandas_model = PandasModel(self)
|
||||||
self._proxyModel = QSortFilterProxyModel(self)
|
self._proxy_model = QSortFilterProxyModel(self)
|
||||||
self._proxyModel.setSourceModel(self._pandas_model)
|
self._proxy_model.setSourceModel(self._pandas_model)
|
||||||
self._table_view.setModel(self._proxyModel)
|
self._table_view.setModel(self._proxy_model)
|
||||||
self._proxyModel.setDynamicSortFilter(True)
|
self._proxy_model.setSortRole(Qt.ItemDataRole.EditRole)
|
||||||
self._proxyModel.setSortRole(Qt.ItemDataRole.EditRole)
|
self._proxy_model.setDynamicSortFilter(False)
|
||||||
|
|
||||||
# Set event handlers
|
# Set event handlers
|
||||||
|
|
||||||
self._create_button.clicked.connect(self._handle_create_click)
|
self._create_button.clicked.connect(self._handle_create_click)
|
||||||
|
self._rename_button.clicked.connect(self._handle_rename_click)
|
||||||
self._delete_button.clicked.connect(self._handle_delete_click)
|
self._delete_button.clicked.connect(self._handle_delete_click)
|
||||||
self._clear_button.clicked.connect(self._handle_clear_click)
|
self._clear_button.clicked.connect(self._handle_clear_click)
|
||||||
self._apply_button.clicked.connect(self._handle_apply_click)
|
self._apply_button.clicked.connect(self._handle_apply_click)
|
||||||
@@ -99,6 +111,13 @@ class MainWindow(QMainWindow):
|
|||||||
len(col))
|
len(col))
|
||||||
self._table_view.setColumnWidth(i, max_char * 10)
|
self._table_view.setColumnWidth(i, max_char * 10)
|
||||||
|
|
||||||
|
def _assign_category_to_selected_transactions(self, category: str):
|
||||||
|
indexes = self._table_view.selectionModel().selectedRows()
|
||||||
|
row_indices = [self._table_view.model().mapToSource(index).row()
|
||||||
|
for index in indexes]
|
||||||
|
|
||||||
|
self._pandas_model.assign_category(category, row_indices)
|
||||||
|
|
||||||
#
|
#
|
||||||
# List data updates
|
# List data updates
|
||||||
#
|
#
|
||||||
@@ -108,13 +127,11 @@ class MainWindow(QMainWindow):
|
|||||||
self._list_widget.addItem(category)
|
self._list_widget.addItem(category)
|
||||||
|
|
||||||
def _update_categories_from_dataframe(self):
|
def _update_categories_from_dataframe(self):
|
||||||
df = self._pandas_model.get_dataframe()
|
df_categories = self._pandas_model.get_categories()
|
||||||
|
|
||||||
df_categories = df['category'].unique()
|
|
||||||
current_categories = [self._list_widget.item(x).text() for x
|
current_categories = [self._list_widget.item(x).text() for x
|
||||||
in range(self._list_widget.count())]
|
in range(self._list_widget.count())]
|
||||||
|
|
||||||
missing = list(set(df_categories) - set(current_categories))
|
missing = list(set(df_categories) - set(current_categories))
|
||||||
|
|
||||||
self._add_categories([category for category
|
self._add_categories([category for category
|
||||||
in missing if category != ' '])
|
in missing if category != ' '])
|
||||||
|
|
||||||
@@ -133,19 +150,19 @@ class MainWindow(QMainWindow):
|
|||||||
warning_item.hide()
|
warning_item.hide()
|
||||||
self._warning_layout.removeItem(warning_item)
|
self._warning_layout.removeItem(warning_item)
|
||||||
|
|
||||||
df = self._pandas_model.get_dataframe()
|
columns = self._pandas_model.get_columns()
|
||||||
|
|
||||||
if 't' not in df.columns:
|
if 't' not in columns:
|
||||||
self._add_warning_item(
|
self._add_warning_item(
|
||||||
"The column 't' does not exist. Please rename the column"
|
"The column 't' does not exist. Please rename the column"
|
||||||
" containing the dates of the transactions to 't'.")
|
" containing the dates of the transactions to 't'.")
|
||||||
|
|
||||||
if 'value' not in df.columns:
|
if 'value' not in columns:
|
||||||
self._add_warning_item(
|
self._add_warning_item(
|
||||||
"The column 'value' does not exist. Please rename the column"
|
"The column 'value' does not exist. Please rename the column"
|
||||||
" containing the values of the transactions to 'value'.")
|
" containing the values of the transactions to 'value'.")
|
||||||
|
|
||||||
if 'balance' not in df.columns:
|
if 'balance' not in columns:
|
||||||
self._add_warning_item(
|
self._add_warning_item(
|
||||||
"The column 'balance' does not exist. Please rename the column"
|
"The column 'balance' does not exist. Please rename the column"
|
||||||
" containing the balance after each transaction to 'balance'")
|
" containing the balance after each transaction to 'balance'")
|
||||||
@@ -157,11 +174,30 @@ class MainWindow(QMainWindow):
|
|||||||
def _handle_header_right_click(self, pos):
|
def _handle_header_right_click(self, pos):
|
||||||
column = self._table_view.horizontalHeader().logicalIndexAt(pos)
|
column = self._table_view.horizontalHeader().logicalIndexAt(pos)
|
||||||
|
|
||||||
context = HeaderContextMenu(parent=self, column=column,
|
context = HeaderContextMenu(parent=self, column_index=column,
|
||||||
pandas_model=self._pandas_model,
|
pandas_model=self._pandas_model,
|
||||||
callback=self._dataframe_update_callback)
|
callback=self._dataframe_update_callback)
|
||||||
context.exec(self.sender().mapToGlobal(pos))
|
context.exec(self.sender().mapToGlobal(pos))
|
||||||
|
|
||||||
|
def _handle_rename_click(self):
|
||||||
|
selected_item = self._list_widget.currentItem()
|
||||||
|
|
||||||
|
new_name, flag = QInputDialog.getText(self, "Rename category",
|
||||||
|
"New name:",
|
||||||
|
text=selected_item.text())
|
||||||
|
if not flag:
|
||||||
|
return
|
||||||
|
|
||||||
|
current_items = [self._list_widget.item(x).text() for x
|
||||||
|
in range(self._list_widget.count())]
|
||||||
|
|
||||||
|
if new_name not in current_items:
|
||||||
|
self._pandas_model.rename_category(selected_item.text(), new_name)
|
||||||
|
selected_item.setText(new_name)
|
||||||
|
else:
|
||||||
|
QMessageBox.warning(self, "No action performed",
|
||||||
|
f"Category '{new_name}' already exists.")
|
||||||
|
|
||||||
def _handle_create_click(self):
|
def _handle_create_click(self):
|
||||||
new_name, flag = QInputDialog.getText(self, "Create category",
|
new_name, flag = QInputDialog.getText(self, "Create category",
|
||||||
"New category:",
|
"New category:",
|
||||||
@@ -179,37 +215,25 @@ class MainWindow(QMainWindow):
|
|||||||
f"Category '{new_name}' already exists.")
|
f"Category '{new_name}' already exists.")
|
||||||
|
|
||||||
def _handle_delete_click(self):
|
def _handle_delete_click(self):
|
||||||
selected_item = self._list_widget.selectedItems()[0]
|
selected_item = self._list_widget.currentItem()
|
||||||
|
|
||||||
button = QMessageBox.question(self, "Delete category",
|
button = QMessageBox.question(self, "Delete category",
|
||||||
f"Are you sure you want to delete"
|
f"Are you sure you want to delete"
|
||||||
f" category '{selected_item.text()}'?")
|
f" category '{selected_item.text()}'?")
|
||||||
|
|
||||||
if button == QMessageBox.StandardButton.Yes:
|
if button == QMessageBox.StandardButton.Yes:
|
||||||
|
self._pandas_model.delete_category(selected_item.text())
|
||||||
self._list_widget.takeItem(self._list_widget.row(selected_item))
|
self._list_widget.takeItem(self._list_widget.row(selected_item))
|
||||||
df = self.get_statement_data()
|
|
||||||
df.loc[df['category'] == selected_item.text(), 'category'] = ' '
|
|
||||||
|
|
||||||
def _handle_clear_click(self):
|
def _handle_clear_click(self):
|
||||||
self._assign_category(' ')
|
self._assign_category_to_selected_transactions(' ')
|
||||||
|
|
||||||
def _assign_category(self, category: str):
|
|
||||||
indexes = self._table_view.selectionModel().selectedRows()
|
|
||||||
|
|
||||||
row_indices = [self._table_view.model().mapToSource(index).row()
|
|
||||||
for index in indexes]
|
|
||||||
|
|
||||||
df = self._pandas_model.get_dataframe()
|
|
||||||
df.loc[row_indices, 'category'] = category
|
|
||||||
|
|
||||||
self._pandas_model.set_dataframe(df)
|
|
||||||
|
|
||||||
def _handle_apply_click(self):
|
def _handle_apply_click(self):
|
||||||
category = self._list_widget.selectedItems()[0].text()
|
category = self._list_widget.selectedItems()[0].text()
|
||||||
self._assign_category(category)
|
self._assign_category_to_selected_transactions(category)
|
||||||
|
|
||||||
def _handle_item_double_click(self, item):
|
def _handle_item_double_click(self, item):
|
||||||
self._assign_category(item.text())
|
self._assign_category_to_selected_transactions(item.text())
|
||||||
|
|
||||||
def _handle_save(self):
|
def _handle_save(self):
|
||||||
filename, _ = QFileDialog.getSaveFileName(self, 'Save File')
|
filename, _ = QFileDialog.getSaveFileName(self, 'Save File')
|
||||||
@@ -243,9 +267,16 @@ class MainWindow(QMainWindow):
|
|||||||
else:
|
else:
|
||||||
self._apply_button.setEnabled(False)
|
self._apply_button.setEnabled(False)
|
||||||
|
|
||||||
|
def _check_enable_rename_button(self):
|
||||||
|
if (len(self._list_widget.selectedItems()) > 0):
|
||||||
|
self._rename_button.setEnabled(True)
|
||||||
|
else:
|
||||||
|
self._rename_button.setEnabled(False)
|
||||||
|
|
||||||
def _handle_list_selection_changed(self):
|
def _handle_list_selection_changed(self):
|
||||||
self._check_enable_delete_button()
|
self._check_enable_delete_button()
|
||||||
self._check_enable_apply_button()
|
self._check_enable_apply_button()
|
||||||
|
self._check_enable_rename_button()
|
||||||
|
|
||||||
def _handle_table_selection_changed(self):
|
def _handle_table_selection_changed(self):
|
||||||
self._check_enable_clear_button()
|
self._check_enable_clear_button()
|
||||||
|
|||||||
@@ -60,6 +60,36 @@
|
|||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QPushButton" name="renameCategoryButton">
|
||||||
|
<property name="enabled">
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
<property name="toolTip">
|
||||||
|
<string>Rename selected category</string>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>Rename</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="horizontalLayout_4">
|
||||||
|
<item>
|
||||||
|
<spacer name="horizontalSpacer">
|
||||||
|
<property name="orientation">
|
||||||
|
<enum>Qt::Horizontal</enum>
|
||||||
|
</property>
|
||||||
|
<property name="sizeHint" stdset="0">
|
||||||
|
<size>
|
||||||
|
<width>40</width>
|
||||||
|
<height>20</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
</spacer>
|
||||||
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<widget class="QPushButton" name="deleteCategoryButton">
|
<widget class="QPushButton" name="deleteCategoryButton">
|
||||||
<property name="enabled">
|
<property name="enabled">
|
||||||
@@ -80,6 +110,9 @@
|
|||||||
<property name="sizeAdjustPolicy">
|
<property name="sizeAdjustPolicy">
|
||||||
<enum>QAbstractScrollArea::AdjustToContents</enum>
|
<enum>QAbstractScrollArea::AdjustToContents</enum>
|
||||||
</property>
|
</property>
|
||||||
|
<property name="sortingEnabled">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
@@ -123,12 +156,24 @@
|
|||||||
<layout class="QVBoxLayout" name="verticalLayout">
|
<layout class="QVBoxLayout" name="verticalLayout">
|
||||||
<item>
|
<item>
|
||||||
<widget class="QTableView" name="transactionTableView">
|
<widget class="QTableView" name="transactionTableView">
|
||||||
|
<property name="autoScroll">
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
<property name="selectionBehavior">
|
<property name="selectionBehavior">
|
||||||
<enum>QAbstractItemView::SelectRows</enum>
|
<enum>QAbstractItemView::SelectRows</enum>
|
||||||
</property>
|
</property>
|
||||||
|
<property name="verticalScrollMode">
|
||||||
|
<enum>QAbstractItemView::ScrollPerPixel</enum>
|
||||||
|
</property>
|
||||||
|
<property name="horizontalScrollMode">
|
||||||
|
<enum>QAbstractItemView::ScrollPerPixel</enum>
|
||||||
|
</property>
|
||||||
<property name="sortingEnabled">
|
<property name="sortingEnabled">
|
||||||
<bool>true</bool>
|
<bool>true</bool>
|
||||||
</property>
|
</property>
|
||||||
|
<attribute name="horizontalHeaderShowSortIndicator" stdset="0">
|
||||||
|
<bool>true</bool>
|
||||||
|
</attribute>
|
||||||
<attribute name="horizontalHeaderStretchLastSection">
|
<attribute name="horizontalHeaderStretchLastSection">
|
||||||
<bool>true</bool>
|
<bool>true</bool>
|
||||||
</attribute>
|
</attribute>
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import typing
|
||||||
|
|
||||||
import numpy
|
import numpy
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from PyQt6 import QtCore
|
from PyQt6 import QtCore
|
||||||
from PyQt6.QtCore import Qt, QModelIndex
|
from PyQt6.QtCore import Qt, QModelIndex, QSortFilterProxyModel
|
||||||
|
|
||||||
|
|
||||||
def _get_str_dataframe(df: pd.DataFrame) -> pd.DataFrame:
|
def _get_str_dataframe(df: pd.DataFrame) -> pd.DataFrame:
|
||||||
@@ -20,9 +22,10 @@ class PandasModel(QtCore.QAbstractTableModel):
|
|||||||
|
|
||||||
self._data = pd.DataFrame()
|
self._data = pd.DataFrame()
|
||||||
self._data_str = pd.DataFrame()
|
self._data_str = pd.DataFrame()
|
||||||
self._horizontalHeaders = None
|
|
||||||
|
|
||||||
|
#
|
||||||
# Overloaded functions
|
# Overloaded functions
|
||||||
|
#
|
||||||
|
|
||||||
def rowCount(self, parent=None):
|
def rowCount(self, parent=None):
|
||||||
return len(self._data_str.values)
|
return len(self._data_str.values)
|
||||||
@@ -55,14 +58,120 @@ class PandasModel(QtCore.QAbstractTableModel):
|
|||||||
and (role == Qt.ItemDataRole.DisplayRole)):
|
and (role == Qt.ItemDataRole.DisplayRole)):
|
||||||
return super().headerData(section, orientation, role)
|
return super().headerData(section, orientation, role)
|
||||||
|
|
||||||
return self._horizontalHeaders[section]
|
return self._data_str.columns[section]
|
||||||
|
|
||||||
# Other functions
|
#
|
||||||
|
# Manipulate categories
|
||||||
|
#
|
||||||
|
|
||||||
|
def assign_category(self, category, row_indices):
|
||||||
|
if 'category' not in self._data.columns:
|
||||||
|
self.create_column('category')
|
||||||
|
|
||||||
|
self._data.loc[row_indices, 'category'] = category
|
||||||
|
self._data_str = _get_str_dataframe(self._data)
|
||||||
|
|
||||||
|
for row_index in row_indices:
|
||||||
|
start_index = self.index(row_index, 0)
|
||||||
|
stop_index = self.index(row_index, len(self._data.columns) - 1)
|
||||||
|
self.dataChanged.emit(start_index, stop_index)
|
||||||
|
|
||||||
|
def delete_category(self, category):
|
||||||
|
if 'category' not in self._data.columns:
|
||||||
|
self.create_column('category')
|
||||||
|
|
||||||
|
row_indices = self._data.loc[self._data['category'] == category].index
|
||||||
|
self.assign_category(' ', row_indices)
|
||||||
|
|
||||||
|
def rename_category(self, old_name, new_name):
|
||||||
|
if 'category' not in self._data.columns:
|
||||||
|
self.create_column('category')
|
||||||
|
|
||||||
|
row_indices = self._data.loc[self._data['category'] == old_name].index
|
||||||
|
self.assign_category(new_name, row_indices)
|
||||||
|
|
||||||
|
|
||||||
|
def get_categories(self) -> typing.List[str]:
|
||||||
|
if 'category' not in self._data.columns:
|
||||||
|
self.create_column('category')
|
||||||
|
|
||||||
|
return self._data['category'].unique()
|
||||||
|
|
||||||
|
#
|
||||||
|
# Manipulate columns
|
||||||
|
#
|
||||||
|
|
||||||
|
def create_column(self, column, initial_value=' '):
|
||||||
|
self._data[column] = [initial_value] * len(self._data.index)
|
||||||
|
self._data_str = _get_str_dataframe(self._data)
|
||||||
|
self.layoutAboutToBeChanged.emit()
|
||||||
|
self.layoutChanged.emit()
|
||||||
|
|
||||||
|
def delete_column_by_index(self, column_index):
|
||||||
|
self._data \
|
||||||
|
= self._data.iloc[:, [j for j, c in enumerate(self._data.columns)
|
||||||
|
if j != column_index]]
|
||||||
|
self._data_str = _get_str_dataframe(self._data)
|
||||||
|
|
||||||
|
self.layoutAboutToBeChanged.emit()
|
||||||
|
self.layoutChanged.emit()
|
||||||
|
|
||||||
|
def rename_column(self, old_name, new_name):
|
||||||
|
if new_name in self._data.columns:
|
||||||
|
raise Exception(
|
||||||
|
f"A column with the name '{new_name}' already exists.")
|
||||||
|
|
||||||
|
self._data = self._data.rename(columns={old_name: new_name})
|
||||||
|
self._data_str = _get_str_dataframe(self._data)
|
||||||
|
|
||||||
|
column_index = self._data.columns.get_loc(new_name)
|
||||||
|
self.headerDataChanged.emit(Qt.Orientation.Horizontal,
|
||||||
|
column_index, column_index)
|
||||||
|
|
||||||
|
def switch_columns(self, column1, column2):
|
||||||
|
column_titles = list(self._data.columns)
|
||||||
|
|
||||||
|
index1, index2 \
|
||||||
|
= column_titles.index(column1), column_titles.index(column2)
|
||||||
|
|
||||||
|
column_titles[index1], column_titles[index2] \
|
||||||
|
= column_titles[index2], column_titles[index1]
|
||||||
|
|
||||||
|
self._data = self._data.reindex(columns=column_titles)
|
||||||
|
self._data_str = _get_str_dataframe(self._data)
|
||||||
|
|
||||||
|
self.layoutAboutToBeChanged.emit()
|
||||||
|
self.layoutChanged.emit()
|
||||||
|
|
||||||
|
def get_columns(self) -> typing.List[str]:
|
||||||
|
return list(self._data.columns)
|
||||||
|
|
||||||
|
def assign_float_column(self, column, decimal_sep):
|
||||||
|
if decimal_sep == ',':
|
||||||
|
self._data[column] \
|
||||||
|
= self._data[column].str.replace(',', '.').astype(float)
|
||||||
|
else:
|
||||||
|
self._data[column] = self._data[column].astype(float)
|
||||||
|
|
||||||
|
self._data_str = _get_str_dataframe(self._data)
|
||||||
|
|
||||||
|
column_index = self._data.columns.get_loc(column)
|
||||||
|
start_index = self.index(0, column_index)
|
||||||
|
stop_index = self.index(len(self._data.index), column_index)
|
||||||
|
self.dataChanged.emit(start_index, stop_index)
|
||||||
|
|
||||||
|
def assign_date_column(self, column, date_format):
|
||||||
|
self._data[column] \
|
||||||
|
= pd.to_datetime(self._data[column], format=date_format)
|
||||||
|
self._data_str = _get_str_dataframe(self._data)
|
||||||
|
|
||||||
|
#
|
||||||
|
# Directly access dataframe
|
||||||
|
#
|
||||||
|
|
||||||
def set_dataframe(self, df):
|
def set_dataframe(self, df):
|
||||||
self._data = df
|
self._data = df
|
||||||
self._data_str = _get_str_dataframe(df)
|
self._data_str = _get_str_dataframe(df)
|
||||||
self._horizontalHeaders = list(df.columns)
|
|
||||||
|
|
||||||
self.layoutAboutToBeChanged.emit()
|
self.layoutAboutToBeChanged.emit()
|
||||||
self.layoutChanged.emit()
|
self.layoutChanged.emit()
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 634 B After Width: | Height: | Size: 634 B |
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
numpy==1.26.3
|
||||||
|
pandas==1.5.3
|
||||||
|
PyQt6==6.6.1
|
||||||
|
setuptools==69.0.3
|
||||||
20
setup.py
Normal file
20
setup.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import os
|
||||||
|
from setuptools import setup
|
||||||
|
|
||||||
|
|
||||||
|
with open('requirements.txt') as f:
|
||||||
|
required = f.read().splitlines()
|
||||||
|
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name='banking_breakdown',
|
||||||
|
version='1.0',
|
||||||
|
description='Visualize bank statements',
|
||||||
|
author='Andreas Tsouchlos',
|
||||||
|
packages=['banking_breakdown', 'banking_breakdown.ui', 'banking_breakdown.document_builder'],
|
||||||
|
install_requires=required,
|
||||||
|
package_data={'banking_breakdown.ui': ['main_window.ui', 'warning.png'],
|
||||||
|
'banking_breakdown.document_builder': ['.latexmkrc', 'Dockerfile.alpine', 'report.tex', 'common.tex']},
|
||||||
|
include_package_data=True,
|
||||||
|
)
|
||||||
|
|
||||||
Reference in New Issue
Block a user