banking-breakdown/banking_breakdown/ui/main_window.py

394 lines
14 KiB
Python

import typing
from functools import partial
import pandas as pd
from PyQt6 import uic
from PyQt6.QtCore import Qt, QSortFilterProxyModel
from PyQt6.QtGui import QPixmap, QAction
from PyQt6.QtWidgets import QMainWindow, QPushButton, QHBoxLayout, QLabel, \
QVBoxLayout, QMenu, QTableView, QInputDialog, QMessageBox, QFileDialog, \
QListWidget
from banking_breakdown.ui.pandas_model import PandasModel
class WarningItem(QHBoxLayout):
def __init__(self, text: str, parent=None):
super(WarningItem, self).__init__()
self._warningIcon = QLabel()
pixmap = QPixmap("res/warning.png")
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):
def __init__(self, column, pandas_model: PandasModel, callback=None,
parent=None):
super(HeaderContextMenu, self).__init__()
self._column = column
self._pandas_model = pandas_model
self._callback = callback
self._column_text \
= self._pandas_model.headerData(self._column,
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 != ''):
df = self._pandas_model.get_dataframe()
df = df.rename(columns={self._column_text: new_name})
self._pandas_model.set_dataframe(df)
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:
df = self._pandas_model.get_dataframe()
df = df.iloc[:, [j for j, c
in enumerate(df.columns) if j != self._column]]
self._pandas_model.set_dataframe(df)
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
column_titles = list(df.columns)
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:
self._callback()
def _assign_date_handler(self):
date_format, flag = QInputDialog.getText(self, "Format",
"Format:",
text="%d.%m.%Y")
if not flag:
return
df = self._pandas_model.get_dataframe()
try:
df[self._column_text] \
= pd.to_datetime(df[self._column_text], format=date_format)
except:
QMessageBox.warning(self, "No action performed",
"An error occurred.")
self._pandas_model.set_dataframe(df)
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
df = self._pandas_model.get_dataframe()
try:
if decimal_sep == ',':
df[self._column_text] \
= df[self._column_text].str.replace(',', '.').astype(float)
else:
df[self._column_text] = df[self._column_text].astype(float)
except:
QMessageBox.warning(self, "No action performed",
"An error occurred.")
self._pandas_model.set_dataframe(df)
if self._callback:
self._callback()
class MainWindow(QMainWindow):
def __init__(self):
super(MainWindow, self).__init__()
uic.loadUi("res/main_window.ui", self)
self.setContentsMargins(9, 9, 9, 9)
self._warnings = []
# Extract elements
self._warning_layout \
= self.findChild(QVBoxLayout, "warningLayout")
self._create_button \
= self.findChild(QPushButton, "createCategoryButton")
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 up QTableView model
self._pandas_model = PandasModel(self)
self._proxyModel = QSortFilterProxyModel(self)
self._proxyModel.setSourceModel(self._pandas_model)
self._table_view.setModel(self._proxyModel)
self._proxyModel.setDynamicSortFilter(False)
# Set event handlers
self._create_button.clicked.connect(self._handle_create_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._header_right_clicked)
self._list_widget.itemSelectionChanged.connect(
self._handle_list_selection_changed)
self._table_view.selectionModel().selectionChanged.connect(
self._handle_table_selection_changed)
def set_statement_data(self, df: pd.DataFrame):
self._pandas_model.set_dataframe(df)
self._dataframe_update_callback()
def get_statement_data(self) -> pd.DataFrame:
return self._pandas_model.get_dataframe()
def _dataframe_update_callback(self):
df = self._pandas_model.get_dataframe()
self._show_warnings()
self._update_categories_from_dataframe(df)
# 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 _add_categories(self, categories: typing.Sequence[str]):
for category in categories:
self._list_widget.addItem(category)
def _update_categories_from_dataframe(self, df: pd.DataFrame):
df_categories = df['category'].unique()
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 != ' '])
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)
df = self._pandas_model.get_dataframe()
if 't' not in df.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 df.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 df.columns:
self._add_warning_item(
"The column 'balance' does not exist. Please rename the column"
" containing the balance after each transaction to 'balance'")
def _header_right_clicked(self, pos):
column = self._table_view.horizontalHeader().logicalIndexAt(pos)
context = HeaderContextMenu(parent=self, column=column,
pandas_model=self._pandas_model,
callback=self._dataframe_update_callback)
context.exec(self.sender().mapToGlobal(pos))
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.selectedItems()[0]
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._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):
self._assign_category(' ')
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.get_statement_data()
df.loc[row_indices, 'category'] = category
self.set_statement_data(df)
def _handle_apply_click(self):
category = self._list_widget.selectedItems()[0].text()
self._assign_category(category)
def _handle_item_double_click(self, item):
self._assign_category(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)
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 _handle_list_selection_changed(self):
self._check_enable_delete_button()
self._check_enable_apply_button()
def _handle_table_selection_changed(self):
self._check_enable_clear_button()
self._check_enable_apply_button()