Compare commits

...

12 Commits

19 changed files with 936 additions and 215 deletions

3
.gitignore vendored
View File

@@ -1,4 +1,5 @@
.idea/ .idea/
__pycache__/ __pycache__/
venv/ venv/
build/ build/
banking_breakdown.egg-info

6
MANIFEST.in Normal file
View 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

View File

@@ -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()

View File

@@ -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()

View 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)

View 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}},
},
}

View File

@@ -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}

View 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()

View File

@@ -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()

View File

@@ -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

View File

@@ -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()

View File

@@ -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()

View File

@@ -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>

View File

@@ -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()

View File

Before

Width:  |  Height:  |  Size: 634 B

After

Width:  |  Height:  |  Size: 634 B

4
requirements.txt Normal file
View 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
View 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,
)