Compare commits

...

32 Commits

Author SHA1 Message Date
6c89dae2d1 Implement category renaming 2024-01-15 11:19:53 +01:00
f53a948c8f Sort category list 2024-01-15 10:54:23 +01:00
564f22725a Disable autoscroll 2024-01-15 10:50:51 +01:00
feabd3a4fd Merge branch 'feature/smooth_scrolling' into feature/installable_package 2024-01-15 10:43:16 +01:00
8bd120dfde Add smooth scrolling 2024-01-15 10:40:21 +01:00
2297f4a2bb Make package installable 2024-01-13 16:26:18 +01:00
e9a218bb88 Change net income figure; Add table 2024-01-07 18:54:24 +01:00
7c75bd3c37 Round values 2024-01-07 18:15:52 +01:00
1e4efa0c5b Add expense-by-category figure 2024-01-06 22:42:46 +01:00
99a7920118 Restructure data access to pandas model 2024-01-05 14:32:45 +01:00
8d660ea8c2 Reset sorting to normal 2024-01-05 13:15:33 +01:00
be3f3b604e Fix delete category 2024-01-05 13:12:42 +01:00
6a497c8575 Fix sorting for floats 2024-01-04 22:21:00 +01:00
ab1a1c3aad Finish reorganization of ui code 2024-01-04 21:16:15 +01:00
050f5f0ae4 Separate WarningItem and HeaderContextMenu from MainWindow 2024-01-04 19:34:01 +01:00
393f654a57 Change the way PandasModel operates 2024-01-04 18:36:53 +01:00
29aaa9066f Restructure UI code and clean up PandasModel 2024-01-04 17:22:14 +01:00
8c2fa9296d Preserve sort on data reload 2024-01-04 15:37:28 +01:00
0fdaff0fa2 Implement 'assign type' action 2024-01-04 15:31:26 +01:00
ba6b6c2e4a Add clear button; Disable buttons when function unavailable 2024-01-04 14:13:46 +01:00
f4b73064ff Automatically update categories from loaded dataframe 2024-01-04 05:29:21 +01:00
e2bf90d4d8 Implement clearing of category upon deletion 2024-01-04 05:17:32 +01:00
3dad927bf7 Implement category assignment 2024-01-04 05:12:02 +01:00
7c72e59a99 Implement category creation and deletion 2024-01-04 04:50:57 +01:00
d8f571bf31 Fixed bugs in tableview header context menu 2024-01-04 04:36:37 +01:00
ebe5857d37 Code cleanup 2024-01-04 03:30:56 +01:00
97e770ab0d Replace listView by listWidget 2024-01-04 03:28:32 +01:00
0816ebd26b Add category column if it does not exist 2024-01-04 03:19:30 +01:00
8a0d7f748f Implement show/hide warnings depending on columns 2024-01-04 02:29:34 +01:00
14e830ead0 Implement column deletion and renaming 2024-01-04 01:31:36 +01:00
d4211b6744 Changed the way ui files are handled 2024-01-03 23:08:26 +01:00
c72885258b Clean up MainWindow implementation 2024-01-03 22:32:06 +01:00
21 changed files with 1416 additions and 416 deletions

3
.gitignore vendored
View File

@@ -1,4 +1,5 @@
.idea/
__pycache__/
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,22 +1,26 @@
from banking_breakdown import document_builder
from banking_breakdown import statement_parser
from banking_breakdown import ui
from banking_breakdown import ui, regex_categorizer, statement_parser, \
document_builder
import argparse
def categorize_func(args):
from banking_breakdown.statement_parser import get_stripped_statement
import pandas as pd
df = None
if args.i is not None:
df = get_stripped_statement(args.i)
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
signal.signal(signal.SIGINT, signal.SIG_DFL)
ui.show_main_window(df=df)
def report_func(args):
print("Report")
report_data = statement_parser.parse_statement(args.i)
document_builder.build_document(args.o, report_data)
#
@@ -32,16 +36,22 @@ def main():
categorize_parser = subparsers.add_parser("categorize")
categorize_parser.set_defaults(func=categorize_func)
categorize_parser.add_argument('-i', required=False,
categorize_parser.add_argument('-i', required=True,
help="Bank statement CSV")
categorize_parser.add_argument('-f', required=False,
help="JSON file containing regexes to"
" pre-categorize statement entries")
categorize_parser.add_argument('-d', required=False,
help="Delimiter to use when reading the"
" bank statement", default=',')
report_parser = subparsers.add_parser("report")
report_parser.set_defaults(func=report_func)
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()

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}
@@ -38,13 +38,13 @@
\newcommand{\slice}[6]{
\pgfmathparse{0.5*#1+0.5*#2}
\let\midangle\pgfmathresult
% slice
\fill[thick,color=#5] (0,0) -- (#1:1) arc (#1:#2+1:1) -- (0,0);
% outer label
\node[label=\midangle:#4] at (\midangle:1) {};
% inner label
\pgfmathparse{min((#2-#1-10)/110*(-0.3),0)}
\let\temp\pgfmathresult
@@ -58,13 +58,13 @@
\newcounter{pieSliceB}
\newcommand{\pie}[1]{
% Count elements
\setcounter{pieElem}{0}%
\setcounter{pieElem}{0}%
\foreach\pieElem in {#1}{\stepcounter{pieElem}}%
\edef\numElements{\arabic{pieElem}}
% Draw pie chart
\setcounter{pieSliceA}{0}%
\setcounter{pieSliceB}{0}%
\setcounter{pieSliceA}{0}%
\setcounter{pieSliceB}{0}%
\foreach \xi/\t [count=\xk from 0] in {#1} {
% Get colors
\pgfmathparse{1000 / (\numElements - 1) * \xk}
@@ -81,16 +81,16 @@
\newcommand{\csvPie}[1]{
% Count elements
\setcounter{pieElem}{0}%
\setcounter{pieElem}{0}%
\csvreader[head to column names]{#1}{}{%
\stepcounter{pieElem}
}
\edef\numElements{\arabic{pieElem}}
% Draw pie chart
\setcounter{pieElem}{0}%
\setcounter{pieSliceA}{0}%
\setcounter{pieSliceB}{0}%
\setcounter{pieElem}{0}%
\setcounter{pieSliceA}{0}%
\setcounter{pieSliceB}{0}%
\csvreader[head to column names]{#1}{}{%
% Get colors
\pgfmathparse{1000 / (\numElements - 1) * \thepieElem}
@@ -157,7 +157,7 @@
]
% Dummy plot to set x axis ticks
\addplot[draw=none]
table[col sep=comma, x=t, y=value]
table[col sep=comma, x=t, y=net]
{net_income.csv};
% Dummy plot to set x axis scale
@@ -165,11 +165,17 @@
table[col sep=comma, x=t, y expr=0]
{detailed_balance.csv};
\addplot[ybar, color=scol2, fill=scol2, line width=1pt]
table[col sep=comma, x=t, y=value, discard if lt={value}{0}]
\addplot[ybar, bar width=0.4cm, draw=none, fill=scol2!30, line width=1pt]
table[col sep=comma, x=t, y=income]
{net_income.csv};
\addplot[ybar, color=scol0, fill=scol0, line width=1pt]
table[col sep=comma, x=t, y=value, discard if gt={value}{0}]
\addplot[ybar, bar width=0.4cm, draw=none, fill=scol0!30, line width=1pt]
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};
\end{axis}
\end{tikzpicture}
@@ -211,6 +217,105 @@
\caption{Development of account balance over time}
\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}

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,92 +6,117 @@ import re
import numpy as np
# def _read_regex_dict(regex_file: str = "res/category_regexes.json"):
# with open(regex_file, 'r') as f:
# 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/banking_statement_2023.csv")
#
#
# if __name__ == "__main__":
# main()
def _escape_string(to_escape: str):
return to_escape.translate(str.maketrans({"&": r"\&"}))
def get_stripped_statement(filename: str) -> pd.DataFrame:
df = pd.read_csv(filename, delimiter=';', decimal=",")
df["Valutadatum"] = pd.to_datetime(df["Valutadatum"], format='%d.%m.%Y')
def _compute_total_balance(df: pd.DataFrame) -> pd.DataFrame:
stripped_df = pd.DataFrame(
{'t': df["t"], 'value': df["balance"]})
result = pd.DataFrame({'t': df["Valutadatum"],
'other party': df["Name Zahlungsbeteiligter"],
'value': df["Betrag"],
'balance afterwards': df["Saldo nach Buchung"],
'description': df["Buchungstext"],
'purpose': df["Verwendungszweck"]
})
result['category'] = [''] * len(result.index)
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:
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
class ReportData:
category_overview: pd.DataFrame
expenses_by_category: pd.DataFrame
net_income: pd.DataFrame
total_value: pd.DataFrame
detailed_balance: pd.DataFrame

View File

@@ -1,47 +1,17 @@
import sys
import typing
import pandas as pd
from PyQt6.QtWidgets import QApplication
from banking_breakdown.ui.generated_wrapper import GeneratedWindowWrapper
from banking_breakdown.ui.main_window import MainWindow
def show_main_window(categories: typing.Sequence[str] = None,
df: pd.DataFrame = None):
def show_main_window(df: pd.DataFrame = None):
app = QApplication(sys.argv)
window = GeneratedWindowWrapper()
if categories is not None:
window.add_categories(categories)
window = MainWindow()
if df is not None:
window.set_statement_data(df)
window.add_warning_text("The column 't' does not exist. Please rename the"
" column containing the dates of the transactions"
" to 't'.")
window.add_warning_text("The column 'balance' does not exist. Please"
" rename the column containing the balance after"
" each transaction to 'balance'")
window.add_warning_text("The column 'value' does not exist. Please rename"
" the column containing the values of the"
" transactions to 'value'.")
window.show()
app.exec()
def main():
from banking_breakdown.statement_parser import get_stripped_statement
categories = ["Food", "Rent & Utilities", "Hobbies", "Education",
"Transportation", "Social Life", "Other"]
df = get_stripped_statement("../../res/banking_statement_2023.csv")
show_main_window(categories, df)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,165 @@
import pandas as pd
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QPixmap, QAction
from PyQt6.QtWidgets import QHBoxLayout, QLabel, QMenu, QInputDialog, \
QMessageBox
import banking_breakdown.ui
from banking_breakdown.ui.pandas_model import PandasModel
from importlib import resources
class WarningItem(QHBoxLayout):
"""Item appearing at top of Window with warning icon."""
def __init__(self, text: str, parent=None):
super(WarningItem, self).__init__()
self._warningIcon = QLabel()
pixmap_path = f"{resources.files(banking_breakdown.ui)}/warning.png"
pixmap = QPixmap(pixmap_path)
self._warningIcon.setPixmap(pixmap)
self._label = QLabel(text)
self._label.setWordWrap(True)
self.addWidget(self._warningIcon)
self.addWidget(self._label)
self.setStretch(0, 0)
self.setStretch(1, 1)
def hide(self):
self._label.hide()
self._warningIcon.hide()
class HeaderContextMenu(QMenu):
"""Context menu appearing when right-clicking the header of the QTableView.
"""
def __init__(self, column_index, pandas_model: PandasModel, callback=None,
parent=None):
super(HeaderContextMenu, self).__init__()
self._pandas_model = pandas_model
self._callback = callback
self._column_index = column_index
self._column_text \
= self._pandas_model.headerData(self._column_index,
Qt.Orientation.Horizontal)
# Define assign action
assign_menu = QMenu("Assign type", self)
assign_date_action = QAction("date", self)
assign_float_action = QAction("float", self)
assign_menu.addAction(assign_date_action)
assign_menu.addAction(assign_float_action)
assign_date_action.triggered.connect(self._assign_date_handler)
assign_float_action.triggered.connect(self._assign_float_handler)
# Define other actions
rename_action = QAction("Rename", self)
delete_action = QAction("Delete", self)
switch_action = QAction("Switch position", self)
rename_action.triggered.connect(self._rename_handler)
delete_action.triggered.connect(self._delete_handler)
switch_action.triggered.connect(self._switch_handler)
# Add actions to menu
self.addAction(rename_action)
self.addAction(delete_action)
self.addAction(switch_action)
self.addAction(assign_menu.menuAction())
def _rename_handler(self):
new_name, flag = QInputDialog.getText(self, "Rename column",
"New name:",
text=self._column_text)
if not flag:
return
if (new_name != self._column_text) and (new_name != ''):
try:
self._pandas_model.rename_column(self._column_text, new_name)
except:
QMessageBox.warning(self, "No action performed",
"An error occurred.")
if self._callback:
self._callback()
def _delete_handler(self):
button = QMessageBox.question(self, "Delete column",
f"Are you sure you want to delete"
f" column '{self._column_text}'?")
if button == QMessageBox.StandardButton.Yes:
self._pandas_model.delete_column_by_index(self._column_index)
if self._callback:
self._callback()
def _switch_handler(self):
df = self._pandas_model.get_dataframe()
columns = [column for column in df.columns
if column != self._column_text]
other_name, flag = QInputDialog.getItem(self, "Switch column position",
f"Switch position of colum "
f"'{self._column_text}' with:",
columns, editable=False)
if not flag:
return
self._pandas_model.switch_columns(self._column_text, other_name)
if self._callback:
self._callback()
def _assign_date_handler(self):
date_format, flag = QInputDialog.getText(self, "Format",
"Format:",
text="%d.%m.%Y")
if not flag:
return
try:
self._pandas_model.assign_date_column(self._column_text,
date_format)
except:
QMessageBox.warning(self, "No action performed",
"An error occurred.")
if self._callback:
self._callback()
def _assign_float_handler(self):
chars = ['.', ',']
decimal_sep, flag = QInputDialog.getItem(self, "Decimal separator",
"Decimal separator:",
chars, editable=False)
if not flag:
return
try:
self._pandas_model.assign_float_column(self._column_text,
decimal_sep)
except:
QMessageBox.warning(self, "No action performed",
"An error occurred.")
if self._callback:
self._callback()

View File

@@ -1,121 +0,0 @@
import typing
from functools import partial
import pandas as pd
from PyQt6.QtGui import QPixmap
from PyQt6.QtWidgets import QMainWindow, QTableView, QHBoxLayout, QLabel
from PyQt6 import uic, QtGui, QtCore
from PyQt6.QtCore import Qt
from banking_breakdown.ui.main_window import Ui_MainWindow
class PandasModel(QtCore.QAbstractTableModel):
def __init__(self, df: pd.DataFrame, parent=None):
QtCore.QAbstractTableModel.__init__(self, parent)
self._data = df
self._horizontalHeaders = [''] * len(df.columns)
for i, column in enumerate(df.columns):
self.setHeaderData(i, Qt.Orientation.Horizontal, column)
def setHeaderData(self, section, orientation, data,
role=Qt.ItemDataRole.EditRole):
if ((orientation == Qt.Orientation.Horizontal)
and ((role == Qt.ItemDataRole.DisplayRole)
or (role == Qt.ItemDataRole.EditRole))):
self._horizontalHeaders[section] = data
return True
else:
return super().setHeaderData(section, orientation, data, role)
def headerData(self, section, orientation,
role=Qt.ItemDataRole.DisplayRole):
if (orientation == Qt.Orientation.Horizontal
and role == Qt.ItemDataRole.DisplayRole):
return self._horizontalHeaders[section]
else:
return super().headerData(section, orientation, role)
def rowCount(self, parent=None):
return len(self._data.values)
def columnCount(self, parent=None):
return self._data.columns.size
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
if not index.isValid():
return QtCore.QVariant()
if role != Qt.ItemDataRole.DisplayRole:
return QtCore.QVariant()
item = self._data.iloc[index.row()].iloc[index.column()]
if type(item) is pd.Timestamp:
return QtCore.QVariant(item.strftime('%Y-%m-%d'))
else:
return QtCore.QVariant(str(item))
class GeneratedWindowWrapper(QMainWindow):
def __init__(self):
super(GeneratedWindowWrapper, self).__init__()
self._window = Ui_MainWindow()
self._window.setupUi(self)
# Set window behavior
self._window.statementTableView.setSelectionBehavior(
QTableView.SelectionBehavior.SelectRows)
# Populate categories
self._category_model = QtGui.QStandardItemModel()
self._window.categoryListView.setModel(self._category_model)
# Onclick handlers
self._window.createCategoryButton.clicked.connect(
self._handle_create_click)
def add_categories(self, categories: typing.Sequence[str]):
for category in categories:
item = QtGui.QStandardItem(category)
self._category_model.appendRow(item)
def set_statement_data(self, df: pd.DataFrame):
model = PandasModel(df)
self._window.statementTableView.setModel(model)
def _handle_create_click(self):
self.add_categories(['asdf'])
def _handle_delete_click(self):
pass
def _handle_apply_click(self):
pass
def add_warning_text(self, text: str):
layout = QHBoxLayout()
warningIcon = QLabel()
pixmap = QPixmap('res/warning.png')
warningIcon.setPixmap(pixmap)
label = QLabel(text)
label.setWordWrap(True)
layout.addWidget(warningIcon)
layout.addWidget(label)
layout.setStretch(0, 0)
layout.setStretch(1, 1)
self._window.warningLayout.addLayout(layout)

View File

@@ -1,95 +1,283 @@
# Form implementation generated from reading ui file 'res/main_window.ui'
#
# Created by: PyQt6 UI code generator 6.6.1
#
# WARNING: Any manual changes made to this file will be lost when pyuic6 is
# run again. Do not edit this file unless you know what you are doing.
import typing
import pandas as pd
from PyQt6 import uic
from PyQt6.QtCore import Qt, QSortFilterProxyModel
from PyQt6.QtGui import QAction
from PyQt6.QtWidgets import QMainWindow, QPushButton, QVBoxLayout, \
QTableView, QInputDialog, QMessageBox, QFileDialog, QListWidget
import banking_breakdown.ui
from banking_breakdown.ui.pandas_model import PandasModel
from banking_breakdown.ui.custom_ui_items import WarningItem, HeaderContextMenu
from PyQt6 import QtCore, QtGui, QtWidgets
from importlib import resources
class MainWindow(QMainWindow):
def __init__(self):
super(MainWindow, self).__init__()
class Ui_MainWindow(object):
def setupUi(self, MainWindow):
MainWindow.setObjectName("MainWindow")
MainWindow.resize(800, 600)
self.centralwidget = QtWidgets.QWidget(parent=MainWindow)
self.centralwidget.setObjectName("centralwidget")
self.verticalLayout_5 = QtWidgets.QVBoxLayout(self.centralwidget)
self.verticalLayout_5.setSpacing(0)
self.verticalLayout_5.setObjectName("verticalLayout_5")
self.horizontalLayout_5 = QtWidgets.QHBoxLayout()
self.horizontalLayout_5.setObjectName("horizontalLayout_5")
self.warningWidget = QtWidgets.QWidget(parent=self.centralwidget)
self.warningWidget.setMaximumSize(QtCore.QSize(600, 16777215))
self.warningWidget.setObjectName("warningWidget")
self.warningLayout = QtWidgets.QVBoxLayout(self.warningWidget)
self.warningLayout.setContentsMargins(-1, 0, -1, 9)
self.warningLayout.setObjectName("warningLayout")
self.horizontalLayout_5.addWidget(self.warningWidget)
self.verticalLayout_5.addLayout(self.horizontalLayout_5)
self.horizontalLayout_3 = QtWidgets.QHBoxLayout()
self.horizontalLayout_3.setObjectName("horizontalLayout_3")
self.groupBox = QtWidgets.QGroupBox(parent=self.centralwidget)
self.groupBox.setObjectName("groupBox")
self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.groupBox)
self.verticalLayout_2.setObjectName("verticalLayout_2")
self.horizontalLayout = QtWidgets.QHBoxLayout()
self.horizontalLayout.setObjectName("horizontalLayout")
self.createCategoryButton = QtWidgets.QPushButton(parent=self.groupBox)
self.createCategoryButton.setStatusTip("")
self.createCategoryButton.setObjectName("createCategoryButton")
self.horizontalLayout.addWidget(self.createCategoryButton)
self.deleteCategoryButton = QtWidgets.QPushButton(parent=self.groupBox)
self.deleteCategoryButton.setObjectName("deleteCategoryButton")
self.horizontalLayout.addWidget(self.deleteCategoryButton)
self.verticalLayout_2.addLayout(self.horizontalLayout)
self.categoryListView = QtWidgets.QListView(parent=self.groupBox)
self.categoryListView.setSizeAdjustPolicy(QtWidgets.QAbstractScrollArea.SizeAdjustPolicy.AdjustToContents)
self.categoryListView.setObjectName("categoryListView")
self.verticalLayout_2.addWidget(self.categoryListView)
self.horizontalLayout_2 = QtWidgets.QHBoxLayout()
self.horizontalLayout_2.setObjectName("horizontalLayout_2")
spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum)
self.horizontalLayout_2.addItem(spacerItem)
self.applyCategoryButton = QtWidgets.QPushButton(parent=self.groupBox)
self.applyCategoryButton.setObjectName("applyCategoryButton")
self.horizontalLayout_2.addWidget(self.applyCategoryButton)
self.verticalLayout_2.addLayout(self.horizontalLayout_2)
self.horizontalLayout_3.addWidget(self.groupBox)
self.groupBox_2 = QtWidgets.QGroupBox(parent=self.centralwidget)
self.groupBox_2.setObjectName("groupBox_2")
self.verticalLayout = QtWidgets.QVBoxLayout(self.groupBox_2)
self.verticalLayout.setObjectName("verticalLayout")
self.statementTableView = QtWidgets.QTableView(parent=self.groupBox_2)
self.statementTableView.setSortingEnabled(False)
self.statementTableView.setWordWrap(True)
self.statementTableView.setObjectName("statementTableView")
self.statementTableView.horizontalHeader().setSortIndicatorShown(False)
self.verticalLayout.addWidget(self.statementTableView)
self.horizontalLayout_3.addWidget(self.groupBox_2)
self.horizontalLayout_3.setStretch(1, 1)
self.verticalLayout_5.addLayout(self.horizontalLayout_3)
MainWindow.setCentralWidget(self.centralwidget)
self.menubar = QtWidgets.QMenuBar(parent=MainWindow)
self.menubar.setGeometry(QtCore.QRect(0, 0, 800, 23))
self.menubar.setObjectName("menubar")
MainWindow.setMenuBar(self.menubar)
self.statusbar = QtWidgets.QStatusBar(parent=MainWindow)
self.statusbar.setObjectName("statusbar")
MainWindow.setStatusBar(self.statusbar)
ui_path = f"{resources.files(banking_breakdown.ui)}/main_window.ui"
uic.loadUi(ui_path, self)
self.setContentsMargins(9, 9, 9, 9)
self.retranslateUi(MainWindow)
QtCore.QMetaObject.connectSlotsByName(MainWindow)
self._warnings = []
def retranslateUi(self, MainWindow):
_translate = QtCore.QCoreApplication.translate
MainWindow.setWindowTitle(_translate("MainWindow", "MainWindow"))
self.groupBox.setTitle(_translate("MainWindow", "Categories"))
self.createCategoryButton.setToolTip(_translate("MainWindow", "Create new category"))
self.createCategoryButton.setText(_translate("MainWindow", "Create"))
self.deleteCategoryButton.setToolTip(_translate("MainWindow", "Delete selected category"))
self.deleteCategoryButton.setText(_translate("MainWindow", "Delete"))
self.applyCategoryButton.setToolTip(_translate("MainWindow", "Apply selected category to selected transactions"))
self.applyCategoryButton.setText(_translate("MainWindow", "Apply"))
self.groupBox_2.setTitle(_translate("MainWindow", "Bank statement"))
# Extract elements
self._warning_layout \
= self.findChild(QVBoxLayout, "warningLayout")
self._create_button \
= self.findChild(QPushButton, "createCategoryButton")
self._rename_button \
= self.findChild(QPushButton, "renameCategoryButton")
self._delete_button \
= self.findChild(QPushButton, "deleteCategoryButton")
self._apply_button \
= self.findChild(QPushButton, "applyCategoryButton")
self._clear_button \
= self.findChild(QPushButton, "clearCategoryButton")
self._list_widget \
= self.findChild(QListWidget, "categoryListWidget")
self._table_view \
= self.findChild(QTableView, "transactionTableView")
self._action_save \
= self.findChild(QAction, "actionSave")
# Set scrolling behavior
self._table_view.horizontalScrollBar().setSingleStep(10)
self._table_view.verticalScrollBar().setSingleStep(10)
# Set up QTableView model
self._pandas_model = PandasModel(self)
self._proxy_model = QSortFilterProxyModel(self)
self._proxy_model.setSourceModel(self._pandas_model)
self._table_view.setModel(self._proxy_model)
self._proxy_model.setSortRole(Qt.ItemDataRole.EditRole)
self._proxy_model.setDynamicSortFilter(False)
# Set event handlers
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._clear_button.clicked.connect(self._handle_clear_click)
self._apply_button.clicked.connect(self._handle_apply_click)
self._list_widget.itemActivated.connect(self._handle_item_double_click)
self._action_save.triggered.connect(self._handle_save)
header = self._table_view.horizontalHeader()
header.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
header.customContextMenuRequested.connect(
self._handle_header_right_click)
self._list_widget.itemSelectionChanged.connect(
self._handle_list_selection_changed)
self._table_view.selectionModel().selectionChanged.connect(
self._handle_table_selection_changed)
#
# Table data updates
#
def set_statement_data(self, df: pd.DataFrame):
self._pandas_model.set_dataframe(df)
self._dataframe_update_callback()
self._resize_table_columns_to_content()
def get_statement_data(self) -> pd.DataFrame:
return self._pandas_model.get_dataframe()
def _dataframe_update_callback(self):
self._show_warnings()
self._update_categories_from_dataframe()
def _resize_table_columns_to_content(self):
df = self._pandas_model.get_dataframe()
# Resize columns
if len(df.columns) < 10: # Experimentally determined threshold
# Properly resize columns (takes longer)
self._table_view.resizeColumnsToContents()
else:
# Quickly approximate sizes
for i, col in enumerate(df.columns):
max_char = max(max([len(str(x)) for x in df[col].values]),
len(col))
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
#
def _add_categories(self, categories: typing.Sequence[str]):
for category in categories:
self._list_widget.addItem(category)
def _update_categories_from_dataframe(self):
df_categories = self._pandas_model.get_categories()
current_categories = [self._list_widget.item(x).text() for x
in range(self._list_widget.count())]
missing = list(set(df_categories) - set(current_categories))
self._add_categories([category for category
in missing if category != ' '])
#
# Warnings
#
def _add_warning_item(self, text: str):
warning_item = WarningItem(text=text, parent=self)
self._warning_layout.addLayout(warning_item)
self._warnings.append(warning_item)
def _show_warnings(self):
for warning_item in self._warnings:
warning_item.hide()
self._warning_layout.removeItem(warning_item)
columns = self._pandas_model.get_columns()
if 't' not in columns:
self._add_warning_item(
"The column 't' does not exist. Please rename the column"
" containing the dates of the transactions to 't'.")
if 'value' not in columns:
self._add_warning_item(
"The column 'value' does not exist. Please rename the column"
" containing the values of the transactions to 'value'.")
if 'balance' not in columns:
self._add_warning_item(
"The column 'balance' does not exist. Please rename the column"
" containing the balance after each transaction to 'balance'")
#
# Event handlers
#
def _handle_header_right_click(self, pos):
column = self._table_view.horizontalHeader().logicalIndexAt(pos)
context = HeaderContextMenu(parent=self, column_index=column,
pandas_model=self._pandas_model,
callback=self._dataframe_update_callback)
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):
new_name, flag = QInputDialog.getText(self, "Create category",
"New category:",
text="Category")
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._add_categories([new_name])
else:
QMessageBox.warning(self, "No action performed",
f"Category '{new_name}' already exists.")
def _handle_delete_click(self):
selected_item = self._list_widget.currentItem()
button = QMessageBox.question(self, "Delete category",
f"Are you sure you want to delete"
f" category '{selected_item.text()}'?")
if button == QMessageBox.StandardButton.Yes:
self._pandas_model.delete_category(selected_item.text())
self._list_widget.takeItem(self._list_widget.row(selected_item))
def _handle_clear_click(self):
self._assign_category_to_selected_transactions(' ')
def _handle_apply_click(self):
category = self._list_widget.selectedItems()[0].text()
self._assign_category_to_selected_transactions(category)
def _handle_item_double_click(self, item):
self._assign_category_to_selected_transactions(item.text())
def _handle_save(self):
filename, _ = QFileDialog.getSaveFileName(self, 'Save File')
if filename == '':
return
df = self.get_statement_data()
df.to_csv(filename, index=False)
#
# Enable / Disable buttons
#
def _check_enable_delete_button(self):
if len(self._list_widget.selectedItems()) > 0:
self._delete_button.setEnabled(True)
else:
self._delete_button.setEnabled(False)
def _check_enable_clear_button(self):
if len(self._table_view.selectionModel().selectedRows()) > 0:
self._clear_button.setEnabled(True)
else:
self._clear_button.setEnabled(False)
def _check_enable_apply_button(self):
if ((len(self._table_view.selectionModel().selectedRows()) > 0)
and (len(self._list_widget.selectedItems()) > 0)):
self._apply_button.setEnabled(True)
else:
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):
self._check_enable_delete_button()
self._check_enable_apply_button()
self._check_enable_rename_button()
def _handle_table_selection_changed(self):
self._check_enable_clear_button()
self._check_enable_apply_button()

View File

@@ -6,22 +6,22 @@
<rect>
<x>0</x>
<y>0</y>
<width>800</width>
<height>600</height>
<width>1300</width>
<height>731</height>
</rect>
</property>
<property name="windowTitle">
<string>MainWindow</string>
</property>
<widget class="QWidget" name="centralwidget">
<layout class="QVBoxLayout" name="verticalLayout_5">
<layout class="QVBoxLayout" name="verticalLayout_4">
<property name="spacing">
<number>0</number>
</property>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_5">
<layout class="QHBoxLayout" name="horizontalLayout_6">
<item>
<widget class="QWidget" name="warningWidget" native="true">
<widget class="QWidget" name="widget" native="true">
<property name="maximumSize">
<size>
<width>600</width>
@@ -33,7 +33,7 @@
<number>0</number>
</property>
<property name="bottomMargin">
<number>9</number>
<number>0</number>
</property>
</layout>
</widget>
@@ -55,35 +55,28 @@
<property name="toolTip">
<string>Create new category</string>
</property>
<property name="statusTip">
<string/>
</property>
<property name="text">
<string>Create</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="deleteCategoryButton">
<widget class="QPushButton" name="renameCategoryButton">
<property name="enabled">
<bool>false</bool>
</property>
<property name="toolTip">
<string>Delete selected category</string>
<string>Rename selected category</string>
</property>
<property name="text">
<string>Delete</string>
<string>Rename</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QListView" name="categoryListView">
<property name="sizeAdjustPolicy">
<enum>QAbstractScrollArea::AdjustToContents</enum>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<layout class="QHBoxLayout" name="horizontalLayout_4">
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
@@ -97,8 +90,51 @@
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="deleteCategoryButton">
<property name="enabled">
<bool>false</bool>
</property>
<property name="toolTip">
<string>Delete selected category</string>
</property>
<property name="text">
<string>Delete</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QListWidget" name="categoryListWidget">
<property name="sizeAdjustPolicy">
<enum>QAbstractScrollArea::AdjustToContents</enum>
</property>
<property name="sortingEnabled">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="QPushButton" name="clearCategoryButton">
<property name="enabled">
<bool>false</bool>
</property>
<property name="toolTip">
<string>Clear category field of selected transactions</string>
</property>
<property name="text">
<string>Clear</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="applyCategoryButton">
<property name="enabled">
<bool>false</bool>
</property>
<property name="toolTip">
<string>Apply selected category to selected transactions</string>
</property>
@@ -115,19 +151,31 @@
<item>
<widget class="QGroupBox" name="groupBox_2">
<property name="title">
<string>Bank statement</string>
<string>Transactions</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QTableView" name="statementTableView">
<property name="sortingEnabled">
<widget class="QTableView" name="transactionTableView">
<property name="autoScroll">
<bool>false</bool>
</property>
<property name="wordWrap">
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectRows</enum>
</property>
<property name="verticalScrollMode">
<enum>QAbstractItemView::ScrollPerPixel</enum>
</property>
<property name="horizontalScrollMode">
<enum>QAbstractItemView::ScrollPerPixel</enum>
</property>
<property name="sortingEnabled">
<bool>true</bool>
</property>
<attribute name="horizontalHeaderShowSortIndicator" stdset="0">
<bool>false</bool>
<bool>true</bool>
</attribute>
<attribute name="horizontalHeaderStretchLastSection">
<bool>true</bool>
</attribute>
</widget>
</item>
@@ -143,12 +191,27 @@
<rect>
<x>0</x>
<y>0</y>
<width>800</width>
<width>1300</width>
<height>23</height>
</rect>
</property>
<widget class="QMenu" name="menuFile">
<property name="title">
<string>File</string>
</property>
<addaction name="actionSave"/>
</widget>
<addaction name="menuFile"/>
</widget>
<widget class="QStatusBar" name="statusbar"/>
<action name="actionSave">
<property name="text">
<string>Save</string>
</property>
<property name="shortcut">
<string>Ctrl+S</string>
</property>
</action>
</widget>
<resources/>
<connections/>

View File

@@ -0,0 +1,180 @@
import typing
import numpy
import pandas as pd
from PyQt6 import QtCore
from PyQt6.QtCore import Qt, QModelIndex, QSortFilterProxyModel
def _get_str_dataframe(df: pd.DataFrame) -> pd.DataFrame:
"""Return a given dataframe with all values turned into strings.
When the data given to the PandasModel class contains non-strings,
an attached QTableView seems to respond rather slowly. This function
turns all data in the DataFrame into strings, yielding a better experience.
"""
return df.astype(str)
class PandasModel(QtCore.QAbstractTableModel):
def __init__(self, parent=None):
QtCore.QAbstractTableModel.__init__(self, parent)
self._data = pd.DataFrame()
self._data_str = pd.DataFrame()
#
# Overloaded functions
#
def rowCount(self, parent=None):
return len(self._data_str.values)
def columnCount(self, parent=None):
return self._data_str.columns.size
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
if not index.isValid():
return QtCore.QVariant()
if (role != Qt.ItemDataRole.DisplayRole) and (
role != Qt.ItemDataRole.EditRole):
return QtCore.QVariant()
if role == Qt.ItemDataRole.DisplayRole:
item = self._data_str.iloc[index.row(), index.column()]
return QtCore.QVariant(item)
elif role == Qt.ItemDataRole.EditRole:
item = self._data.iloc[index.row(), index.column()]
if type(item) is numpy.float64:
return QtCore.QVariant(float(item))
else:
return QtCore.QVariant(item)
def headerData(self, section, orientation,
role=Qt.ItemDataRole.DisplayRole):
if not ((orientation == Qt.Orientation.Horizontal)
and (role == Qt.ItemDataRole.DisplayRole)):
return super().headerData(section, orientation, role)
return self._data_str.columns[section]
#
# 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):
self._data = df
self._data_str = _get_str_dataframe(df)
self.layoutAboutToBeChanged.emit()
self.layoutChanged.emit()
def get_dataframe(self) -> pd.DataFrame:
return self._data

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