From 5e787519219ff0ee11462d1ce8af36567700b5e5 Mon Sep 17 00:00:00 2001 From: Andreas Tsouchlos Date: Wed, 3 Jan 2024 02:47:43 +0100 Subject: [PATCH] Change GUI and start implementing CLI --- README.md | 2 +- banking_breakdown/__main__.py | 48 ++++++++- banking_breakdown/statement_parser.py | 150 ++++++++++++++------------ banking_breakdown/ui.py | 111 ++++++++++++++++--- res/main_window.ui | 82 ++++++++------ 5 files changed, 274 insertions(+), 119 deletions(-) diff --git a/README.md b/README.md index 7f9f502..f190aa1 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ # banking-breakdown -Visualize banking statements. \ No newline at end of file +Visualize bank statements. \ No newline at end of file diff --git a/banking_breakdown/__main__.py b/banking_breakdown/__main__.py index 8f3af0c..8aee9c0 100644 --- a/banking_breakdown/__main__.py +++ b/banking_breakdown/__main__.py @@ -1,10 +1,54 @@ from banking_breakdown import document_builder from banking_breakdown import statement_parser +from banking_breakdown import ui + +import argparse + + +def categorize_func(args): + from banking_breakdown.statement_parser import get_stripped_statement + + df = None + if args.i is not None: + df = get_stripped_statement(args.i) + + ui.show_main_window("res/main_window.ui", df=df) + + +def report_func(args): + print("Report") + + +# +# Define CLI +# def main(): - report_data = statement_parser.parse_statement("res/banking_statement_2023.csv") - document_builder.build_document(report_data) + parser = argparse.ArgumentParser(prog='banking_breakdown', + description='Visualize bank statements') + + subparsers = parser.add_subparsers() + + categorize_parser = subparsers.add_parser("categorize") + categorize_parser.set_defaults(func=categorize_func) + categorize_parser.add_argument('-i', required=False, + help="Bank statement CSV") + categorize_parser.add_argument('-f', required=False, + help="JSON file containing regexes to" + " pre-categorize statement entries") + + 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") + + args = parser.parse_args() + + if hasattr(args, 'func'): + args.func(args) + else: + parser.print_help() if __name__ == "__main__": diff --git a/banking_breakdown/statement_parser.py b/banking_breakdown/statement_parser.py index b64da83..fce14df 100644 --- a/banking_breakdown/statement_parser.py +++ b/banking_breakdown/statement_parser.py @@ -6,76 +6,92 @@ 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 _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 _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: +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') - 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) + 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) - 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() + return result diff --git a/banking_breakdown/ui.py b/banking_breakdown/ui.py index 5ea4ed6..cc167c2 100644 --- a/banking_breakdown/ui.py +++ b/banking_breakdown/ui.py @@ -1,41 +1,118 @@ import sys +import typing -from PyQt5.QtWidgets import QMainWindow, QApplication, QPushButton, QListView, \ - QTableView -from PyQt5 import uic +import pandas as pd +from PyQt5.QtWidgets import QMainWindow, QApplication, QPushButton, \ + QListView, QTableView +from PyQt5 import uic, QtGui, QtCore +import datetime + + +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, QtCore.Qt.Horizontal, column) + + def setHeaderData(self, section, orientation, data, + role=QtCore.Qt.EditRole): + if orientation == QtCore.Qt.Horizontal and role in ( + QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): + try: + self.horizontalHeaders[section] = data + return True + except: + return False + return super().setHeaderData(section, orientation, data, role) + + def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole): + if orientation == QtCore.Qt.Horizontal and role == QtCore.Qt.DisplayRole: + try: + return self.horizontalHeaders[section] + except: + pass + 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=QtCore.Qt.DisplayRole): + if index.isValid(): + if role == QtCore.Qt.DisplayRole: + 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)) + return QtCore.QVariant() class MainWindow(QMainWindow): - def __init__(self, ui_file): + def __init__(self, ui_file: str, categories: typing.Sequence): super(MainWindow, self).__init__() uic.loadUi(ui_file, self) - self.createCategoryButton \ + self._createCategoryButton \ = self.findChild(QPushButton, "createCategoryButton") - self.deleteCategoryButton \ + self._deleteCategoryButton \ = self.findChild(QPushButton, "deleteCategoryButton") - self.applyCategoryButton \ + self._applyCategoryButton \ = self.findChild(QPushButton, "applyCategoryButton") - self.categoryListView = self.findChild(QListView, "categoryListView") - self.statementTableView = self.findChild(QTableView, - "statementTableView") + self._categoryListView = self.findChild(QListView, "categoryListView") + self._statementTableView = self.findChild(QTableView, + "statementTableView") - self.cancelButton = self.findChild(QPushButton, "cancelButton") - self.doneButton = self.findChild(QPushButton, "doneButton") + self._set_categories(categories) - self._define_event_handlers() + self._statementTableView.setSelectionBehavior(QTableView.SelectRows) - def _define_event_handlers(self): - self.cancelButton.clicked.connect(self.close) + def _set_categories(self, categories: typing.Sequence[str]): + model = QtGui.QStandardItemModel() + self._categoryListView.setModel(model) + + for category in categories: + item = QtGui.QStandardItem(category) + model.appendRow(item) + + def set_statement_data(self, df: pd.DataFrame): + model = PandasModel(df) + self._statementTableView.setModel(model) -def main(): +def show_main_window(ui_file, categories: typing.Sequence[str] = None, + df: pd.DataFrame = None): + if categories is None: + categories = [] + app = QApplication(sys.argv) - window = MainWindow("../res/main_window.ui") + window = MainWindow(ui_file, categories) + + if df is not None: + window.set_statement_data(df) + 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("../res/main_window.ui", categories, df) + + if __name__ == "__main__": main() diff --git a/res/main_window.ui b/res/main_window.ui index 434b1ac..cd803a2 100644 --- a/res/main_window.ui +++ b/res/main_window.ui @@ -21,6 +21,12 @@ Qt::Horizontal + + + 0 + 0 + + Categories @@ -85,40 +91,18 @@ - + + + Bank Statement + + + + + + + - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Cancel - - - - - - - Done - - - - - @@ -130,8 +114,42 @@ 23 + + + File + + + + + + + + + + Save + + + Ctrl+S + + + + + Save As... + + + Ctrl+Shift+S + + + + + Open... + + + Ctrl+O + +