feat: transactions view with filtering and inline categorization
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
from PySide6.QtWidgets import QMainWindow, QHBoxLayout, QWidget, QStackedWidget, QLabel
|
||||
from PySide6.QtCore import Qt
|
||||
from src.ui.sidebar import Sidebar
|
||||
from src.ui.transactions_view import TransactionsView
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self, session):
|
||||
super().__init__()
|
||||
self.session = session
|
||||
self.setWindowTitle("SpendingAnalysis")
|
||||
self.setMinimumSize(1200, 800)
|
||||
|
||||
central = QWidget()
|
||||
self.setCentralWidget(central)
|
||||
layout = QHBoxLayout(central)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
self.sidebar = Sidebar()
|
||||
layout.addWidget(self.sidebar)
|
||||
|
||||
self.stack = QStackedWidget()
|
||||
layout.addWidget(self.stack)
|
||||
|
||||
# Build views — real implementations where available, placeholders otherwise
|
||||
self._views = {}
|
||||
self._transactions_view = TransactionsView(session)
|
||||
for label, key in Sidebar.VIEWS:
|
||||
if key == "transactions":
|
||||
self._views[key] = self.stack.addWidget(self._transactions_view)
|
||||
else:
|
||||
placeholder = QLabel(f"{label} View")
|
||||
placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
placeholder.setStyleSheet("font-size: 24px; color: #888;")
|
||||
self._views[key] = self.stack.addWidget(placeholder)
|
||||
|
||||
self.sidebar.view_changed.connect(self._switch_view)
|
||||
|
||||
def _switch_view(self, key: str):
|
||||
self.stack.setCurrentIndex(self._views[key])
|
||||
@@ -0,0 +1,423 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import (
|
||||
QAbstractTableModel,
|
||||
QDate,
|
||||
QModelIndex,
|
||||
QSortFilterProxyModel,
|
||||
Qt,
|
||||
)
|
||||
from PySide6.QtGui import QColor
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDateEdit,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QStyledItemDelegate,
|
||||
QTableView,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from src.models.account import Account
|
||||
from src.models.category import Category
|
||||
from src.models.household import HouseholdMember
|
||||
from src.models.rule import CategorizationRule
|
||||
from src.models.transaction import Transaction
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Table model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
COLUMNS = ["Date", "Description", "Amount", "Account", "Category", "Tag", "Person"]
|
||||
|
||||
|
||||
class TransactionTableModel(QAbstractTableModel):
|
||||
"""Wraps a list of Transaction ORM objects for display in a QTableView."""
|
||||
|
||||
def __init__(self, session: Session, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self._session = session
|
||||
self._transactions: list[Transaction] = []
|
||||
self._categories: list[Category] = []
|
||||
self._refresh_categories()
|
||||
|
||||
# -- public helpers -----------------------------------------------------
|
||||
|
||||
def refresh(self, filters: dict[str, Any] | None = None) -> None:
|
||||
"""Re-query the database with the given *filters* dict and reset the model."""
|
||||
self.beginResetModel()
|
||||
self._transactions = self._query(filters or {})
|
||||
self._refresh_categories()
|
||||
self.endResetModel()
|
||||
|
||||
@property
|
||||
def categories(self) -> list[Category]:
|
||||
return list(self._categories)
|
||||
|
||||
def transaction_at(self, row: int) -> Transaction | None:
|
||||
if 0 <= row < len(self._transactions):
|
||||
return self._transactions[row]
|
||||
return None
|
||||
|
||||
# -- Qt overrides -------------------------------------------------------
|
||||
|
||||
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: # noqa: N802
|
||||
return len(self._transactions)
|
||||
|
||||
def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: # noqa: N802
|
||||
return len(COLUMNS)
|
||||
|
||||
def headerData(self, section: int, orientation: Qt.Orientation, role: int = Qt.ItemDataRole.DisplayRole): # noqa: N802
|
||||
if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal:
|
||||
return COLUMNS[section]
|
||||
return None
|
||||
|
||||
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
|
||||
if not index.isValid():
|
||||
return None
|
||||
|
||||
txn = self._transactions[index.row()]
|
||||
col = index.column()
|
||||
|
||||
if role == Qt.ItemDataRole.DisplayRole:
|
||||
return self._display_value(txn, col)
|
||||
|
||||
if role == Qt.ItemDataRole.ForegroundRole and col == 2:
|
||||
amount = float(txn.amount) if txn.amount is not None else 0.0
|
||||
return QColor("red") if amount < 0 else QColor("green")
|
||||
|
||||
# Store the category id for the delegate
|
||||
if role == Qt.ItemDataRole.UserRole and col == 4:
|
||||
return txn.category_id
|
||||
|
||||
return None
|
||||
|
||||
def flags(self, index: QModelIndex) -> Qt.ItemFlag:
|
||||
base = super().flags(index)
|
||||
if index.column() == 4: # Category column is editable
|
||||
return base | Qt.ItemFlag.ItemIsEditable
|
||||
return base
|
||||
|
||||
def setData(self, index: QModelIndex, value: Any, role: int = Qt.ItemDataRole.EditRole) -> bool: # noqa: N802
|
||||
if role != Qt.ItemDataRole.EditRole or index.column() != 4:
|
||||
return False
|
||||
|
||||
txn = self._transactions[index.row()]
|
||||
new_category_id: int | None = value
|
||||
|
||||
if new_category_id == txn.category_id:
|
||||
return False
|
||||
|
||||
txn.category_id = new_category_id
|
||||
self._session.commit()
|
||||
self.dataChanged.emit(index, index, [Qt.ItemDataRole.DisplayRole])
|
||||
return True
|
||||
|
||||
# -- internal -----------------------------------------------------------
|
||||
|
||||
def _refresh_categories(self) -> None:
|
||||
self._categories = list(self._session.query(Category).order_by(Category.name).all())
|
||||
|
||||
def _display_value(self, txn: Transaction, col: int) -> str:
|
||||
if col == 0:
|
||||
return str(txn.date)
|
||||
if col == 1:
|
||||
return txn.description or ""
|
||||
if col == 2:
|
||||
amount = txn.amount
|
||||
if amount is None:
|
||||
return ""
|
||||
return f"{float(amount):,.2f}"
|
||||
if col == 3:
|
||||
return txn.account.name if txn.account else ""
|
||||
if col == 4:
|
||||
return txn.category.name if txn.category else ""
|
||||
if col == 5:
|
||||
return txn.tag or ""
|
||||
if col == 6:
|
||||
return txn.attributed_to.name if txn.attributed_to else ""
|
||||
return ""
|
||||
|
||||
def _query(self, filters: dict[str, Any]) -> list[Transaction]:
|
||||
q = (
|
||||
self._session.query(Transaction)
|
||||
.options(
|
||||
joinedload(Transaction.account),
|
||||
joinedload(Transaction.category),
|
||||
joinedload(Transaction.attributed_to),
|
||||
)
|
||||
)
|
||||
|
||||
if filters.get("date_from"):
|
||||
q = q.filter(Transaction.date >= filters["date_from"])
|
||||
if filters.get("date_to"):
|
||||
q = q.filter(Transaction.date <= filters["date_to"])
|
||||
if filters.get("account_id"):
|
||||
q = q.filter(Transaction.account_id == filters["account_id"])
|
||||
if filters.get("person_id"):
|
||||
q = q.filter(Transaction.attributed_to_id == filters["person_id"])
|
||||
if filters.get("category_id"):
|
||||
q = q.filter(Transaction.category_id == filters["category_id"])
|
||||
if filters.get("uncategorized_only"):
|
||||
q = q.filter(Transaction.category_id.is_(None))
|
||||
if filters.get("search"):
|
||||
pattern = f"%{filters['search']}%"
|
||||
q = q.filter(Transaction.description.ilike(pattern))
|
||||
|
||||
return q.order_by(Transaction.date.desc()).all()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Category combo-box delegate (inline editing)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CategoryDelegate(QStyledItemDelegate):
|
||||
"""Provides a QComboBox editor for the Category column."""
|
||||
|
||||
def __init__(self, model: TransactionTableModel, session: Session, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self._table_model = model
|
||||
self._session = session
|
||||
|
||||
def createEditor(self, parent: QWidget, option, index: QModelIndex) -> QWidget: # noqa: N802
|
||||
combo = QComboBox(parent)
|
||||
combo.addItem("", None)
|
||||
for cat in self._table_model.categories:
|
||||
combo.addItem(cat.name, cat.id)
|
||||
return combo
|
||||
|
||||
def setEditorData(self, editor: QComboBox, index: QModelIndex) -> None: # noqa: N802
|
||||
cat_id = index.data(Qt.ItemDataRole.UserRole)
|
||||
if cat_id is not None:
|
||||
idx = editor.findData(cat_id)
|
||||
if idx >= 0:
|
||||
editor.setCurrentIndex(idx)
|
||||
|
||||
def setModelData(self, editor: QComboBox, model, index: QModelIndex) -> None: # noqa: N802
|
||||
new_cat_id = editor.currentData()
|
||||
|
||||
# Resolve the *source* model index when a proxy is in use
|
||||
source_index = index
|
||||
if isinstance(model, QSortFilterProxyModel):
|
||||
source_index = model.mapToSource(index)
|
||||
source_model = model.sourceModel()
|
||||
else:
|
||||
source_model = model
|
||||
|
||||
txn = source_model.transaction_at(source_index.row())
|
||||
if txn is None:
|
||||
return
|
||||
|
||||
old_cat_id = txn.category_id
|
||||
if new_cat_id == old_cat_id:
|
||||
return
|
||||
|
||||
source_model.setData(source_index, new_cat_id)
|
||||
|
||||
# Ask whether to create a categorization rule
|
||||
cat_name = editor.currentText() or "Uncategorized"
|
||||
pattern = _extract_merchant_pattern(txn.description)
|
||||
answer = QMessageBox.question(
|
||||
editor.parent(),
|
||||
"Create Rule",
|
||||
f'Create rule for "{pattern}"?',
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
)
|
||||
if answer == QMessageBox.StandardButton.Yes and new_cat_id is not None:
|
||||
rule = CategorizationRule(
|
||||
pattern=pattern,
|
||||
category_id=new_cat_id,
|
||||
attributed_to_id=txn.attributed_to_id,
|
||||
priority=0,
|
||||
)
|
||||
self._session.add(rule)
|
||||
self._session.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transactions view widget
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TransactionsView(QWidget):
|
||||
"""Full transactions page: filter bar + table."""
|
||||
|
||||
def __init__(self, session: Session, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self._session = session
|
||||
|
||||
root_layout = QVBoxLayout(self)
|
||||
root_layout.setContentsMargins(8, 8, 8, 8)
|
||||
|
||||
# -- filter bar -----------------------------------------------------
|
||||
filter_bar = QHBoxLayout()
|
||||
root_layout.addLayout(filter_bar)
|
||||
|
||||
# Date range
|
||||
filter_bar.addWidget(QLabel("From:"))
|
||||
self._date_from = QDateEdit()
|
||||
self._date_from.setCalendarPopup(True)
|
||||
self._date_from.setDate(QDate.currentDate().addMonths(-3))
|
||||
filter_bar.addWidget(self._date_from)
|
||||
|
||||
filter_bar.addWidget(QLabel("To:"))
|
||||
self._date_to = QDateEdit()
|
||||
self._date_to.setCalendarPopup(True)
|
||||
self._date_to.setDate(QDate.currentDate())
|
||||
filter_bar.addWidget(self._date_to)
|
||||
|
||||
# Account dropdown
|
||||
filter_bar.addWidget(QLabel("Account:"))
|
||||
self._account_combo = QComboBox()
|
||||
self._account_combo.addItem("All", None)
|
||||
for acct in session.query(Account).order_by(Account.name).all():
|
||||
self._account_combo.addItem(acct.name, acct.id)
|
||||
filter_bar.addWidget(self._account_combo)
|
||||
|
||||
# Person dropdown
|
||||
filter_bar.addWidget(QLabel("Person:"))
|
||||
self._person_combo = QComboBox()
|
||||
self._person_combo.addItem("All", None)
|
||||
for member in session.query(HouseholdMember).order_by(HouseholdMember.name).all():
|
||||
self._person_combo.addItem(member.name, member.id)
|
||||
filter_bar.addWidget(self._person_combo)
|
||||
|
||||
# Category dropdown
|
||||
filter_bar.addWidget(QLabel("Category:"))
|
||||
self._category_combo = QComboBox()
|
||||
self._category_combo.addItem("All", None)
|
||||
for cat in session.query(Category).order_by(Category.name).all():
|
||||
self._category_combo.addItem(cat.name, cat.id)
|
||||
filter_bar.addWidget(self._category_combo)
|
||||
|
||||
# Uncategorized checkbox
|
||||
self._uncategorized_cb = QCheckBox("Uncategorized only")
|
||||
filter_bar.addWidget(self._uncategorized_cb)
|
||||
|
||||
# Search
|
||||
filter_bar.addWidget(QLabel("Search:"))
|
||||
self._search_edit = QLineEdit()
|
||||
self._search_edit.setPlaceholderText("Filter by description...")
|
||||
filter_bar.addWidget(self._search_edit)
|
||||
|
||||
# -- table ----------------------------------------------------------
|
||||
self._table_model = TransactionTableModel(session, self)
|
||||
self._proxy_model = QSortFilterProxyModel(self)
|
||||
self._proxy_model.setSourceModel(self._table_model)
|
||||
|
||||
self._table = QTableView()
|
||||
self._table.setModel(self._proxy_model)
|
||||
self._table.setSortingEnabled(True)
|
||||
self._table.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows)
|
||||
self._table.setAlternatingRowColors(True)
|
||||
self._table.horizontalHeader().setStretchLastSection(True)
|
||||
self._table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
|
||||
self._table.verticalHeader().setVisible(False)
|
||||
|
||||
# Delegate for inline category editing
|
||||
self._cat_delegate = CategoryDelegate(self._table_model, session, self._table)
|
||||
self._table.setItemDelegateForColumn(4, self._cat_delegate)
|
||||
|
||||
root_layout.addWidget(self._table)
|
||||
|
||||
# -- signals --------------------------------------------------------
|
||||
self._date_from.dateChanged.connect(self._apply_filters)
|
||||
self._date_to.dateChanged.connect(self._apply_filters)
|
||||
self._account_combo.currentIndexChanged.connect(self._apply_filters)
|
||||
self._person_combo.currentIndexChanged.connect(self._apply_filters)
|
||||
self._category_combo.currentIndexChanged.connect(self._apply_filters)
|
||||
self._uncategorized_cb.stateChanged.connect(self._apply_filters)
|
||||
self._search_edit.textChanged.connect(self._apply_filters)
|
||||
|
||||
# Initial load
|
||||
self._apply_filters()
|
||||
|
||||
# -- public API ---------------------------------------------------------
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""Reload dropdown options and re-query transactions."""
|
||||
self._reload_combos()
|
||||
self._apply_filters()
|
||||
|
||||
# -- internals ----------------------------------------------------------
|
||||
|
||||
def _build_filters(self) -> dict[str, Any]:
|
||||
filters: dict[str, Any] = {}
|
||||
filters["date_from"] = self._date_from.date().toPython()
|
||||
filters["date_to"] = self._date_to.date().toPython()
|
||||
|
||||
acct_id = self._account_combo.currentData()
|
||||
if acct_id is not None:
|
||||
filters["account_id"] = acct_id
|
||||
|
||||
person_id = self._person_combo.currentData()
|
||||
if person_id is not None:
|
||||
filters["person_id"] = person_id
|
||||
|
||||
cat_id = self._category_combo.currentData()
|
||||
if cat_id is not None:
|
||||
filters["category_id"] = cat_id
|
||||
|
||||
if self._uncategorized_cb.isChecked():
|
||||
filters["uncategorized_only"] = True
|
||||
|
||||
text = self._search_edit.text().strip()
|
||||
if text:
|
||||
filters["search"] = text
|
||||
|
||||
return filters
|
||||
|
||||
def _apply_filters(self) -> None:
|
||||
self._table_model.refresh(self._build_filters())
|
||||
|
||||
def _reload_combos(self) -> None:
|
||||
"""Re-populate the filter dropdowns from the database."""
|
||||
self._reload_combo(self._account_combo, self._session.query(Account).order_by(Account.name).all(), "name")
|
||||
self._reload_combo(self._person_combo, self._session.query(HouseholdMember).order_by(HouseholdMember.name).all(), "name")
|
||||
self._reload_combo(self._category_combo, self._session.query(Category).order_by(Category.name).all(), "name")
|
||||
|
||||
@staticmethod
|
||||
def _reload_combo(combo: QComboBox, items: list, label_attr: str) -> None:
|
||||
current = combo.currentData()
|
||||
combo.blockSignals(True)
|
||||
combo.clear()
|
||||
combo.addItem("All", None)
|
||||
for item in items:
|
||||
combo.addItem(getattr(item, label_attr), item.id)
|
||||
# Restore previous selection if still present
|
||||
idx = combo.findData(current)
|
||||
combo.setCurrentIndex(idx if idx >= 0 else 0)
|
||||
combo.blockSignals(False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _extract_merchant_pattern(description: str) -> str:
|
||||
"""Derive a simple merchant matching pattern from a transaction description.
|
||||
|
||||
Strategy: take the first two significant tokens (ignoring common noise
|
||||
words and trailing numbers/dates) and lower-case them so they can serve
|
||||
as a case-insensitive substring match.
|
||||
"""
|
||||
if not description:
|
||||
return ""
|
||||
# Remove digits that look like dates, reference numbers, etc.
|
||||
cleaned = re.sub(r"\b\d{2,}\b", "", description)
|
||||
# Remove common noise tokens
|
||||
noise = {"the", "of", "and", "for", "in", "at", "to", "on", "a", "an"}
|
||||
tokens = [t for t in cleaned.lower().split() if t not in noise and len(t) > 1]
|
||||
# Take up to the first two meaningful tokens
|
||||
pattern_tokens = tokens[:2]
|
||||
return " ".join(pattern_tokens) if pattern_tokens else description.strip().lower()
|
||||
Reference in New Issue
Block a user