Category overhaul #20

Merged
mathias2784 merged 18 commits from category-overhaul into master 2026-10-27 18:08:10 +00:00
7 changed files with 80 additions and 30 deletions
Showing only changes of commit ea7854473f - Show all commits
+9 -1
View File
@@ -87,13 +87,19 @@ def populated_db_b4w(
Only the first-layer ingestion table is filled; no further processing. Only the first-layer ingestion table is filled; no further processing.
``**kwargs`` are forwarded to ``B4wExportFactory.build``; callable values ``**kwargs`` are forwarded to ``B4wExportFactory.build``; callable values
are resolved per row (e.g. ``ValDt=lambda: faker.date_between(...)``). are resolved per row (e.g. ``ValDt=lambda: faker.date_between(...)``).
An optional ``setup`` callback runs after database creation and before rows
are inserted.
``Id1`` is reserved: it is set per row (``1..row_count``) and cannot be ``Id1`` is reserved: it is set per row (``1..row_count``) and cannot be
overridden via ``**kwargs``. overridden via ``**kwargs``.
The returned session is *not* closed by the fixture; close it in the test. The returned session is *not* closed by the fixture; close it in the test.
""" """
def populate_db_b4w( def populate_db_b4w(
name: str, row_count: int = DEFAULT_B4W_ROWS, **kwargs: Any name: str,
row_count: int = DEFAULT_B4W_ROWS,
*,
setup: Callable[[Session], None] | None = None,
**kwargs: Any,
) -> Session: ) -> Session:
if "Id1" in kwargs: if "Id1" in kwargs:
raise ValueError( raise ValueError(
@@ -101,6 +107,8 @@ def populated_db_b4w(
"cannot be overridden via **kwargs" "cannot be overridden via **kwargs"
) )
session = db_session_factory(name) session = db_session_factory(name)
if setup is not None:
setup(session)
for i in range(row_count): for i in range(row_count):
row_kwargs = {k: (v() if callable(v) else v) for k, v in kwargs.items()} row_kwargs = {k: (v() if callable(v) else v) for k, v in kwargs.items()}
row = B4wExportFactory.build(Id1=i + 1, **cast(dict[str, Any], row_kwargs)) row = B4wExportFactory.build(Id1=i + 1, **cast(dict[str, Any], row_kwargs))
+29 -1
View File
@@ -1,4 +1,5 @@
from decimal import Decimal from decimal import Decimal
from pathlib import Path
from typing import Any from typing import Any
from faker import Faker from faker import Faker
@@ -6,10 +7,37 @@ from polyfactory.factories.pydantic_factory import ModelFactory
from polyfactory.fields import Use from polyfactory.fields import Use
from src.models.b4w import B4wExport from src.models.b4w import B4wExport
from src.models.category import Category
from src.models.comdirect import ComdirectCreditCard, ComdirectDepot, ComdirectGiro from src.models.comdirect import ComdirectCreditCard, ComdirectDepot, ComdirectGiro
from src.models.transactions import Transaction from src.models.transactions import Transaction
DEFAULT_CATEGORY = "root_category:parent_category:leaf_category" CATEGORY_PATHS = tuple(
line.strip()
for line in (
Path(__file__).resolve().parents[1]
/ "scripts/initial_load/categories/categories.txt"
).read_text(encoding="utf-8").splitlines()
if line.strip()
)
def _category_from_path(path: str) -> Category:
category = Category.from_str(path)
if category is None:
raise ValueError("Category list contains an empty path")
return category
class CategoryFactory:
__faker__ = Faker("de_DE")
@classmethod
def build(cls) -> Category:
return _category_from_path(cls.__faker__.random_element(CATEGORY_PATHS))
@staticmethod
def build_all() -> list[Category]:
return [_category_from_path(path) for path in CATEGORY_PATHS]
def _faker_or_none(faker: Faker, non_default: Any, default: Any = None, p_value=0.5): def _faker_or_none(faker: Faker, non_default: Any, default: Any = None, p_value=0.5):
+5 -4
View File
@@ -5,6 +5,7 @@ from sqlalchemy import inspect
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from src.models.category import CategoryBudgetDb, CategoryDb from src.models.category import CategoryBudgetDb, CategoryDb
from tests.factories import CategoryFactory
def test_category_budget_schema(db_session: Session): def test_category_budget_schema(db_session: Session):
@@ -32,7 +33,7 @@ def test_category_budget_schema(db_session: Session):
def test_category_budget_defaults_null_dates(db_session: Session): def test_category_budget_defaults_null_dates(db_session: Session):
category = CategoryDb(name="Lebenshaltung") category = CategoryDb(name=CategoryFactory.build().name)
db_session.add(category) db_session.add(category)
db_session.flush() db_session.flush()
@@ -50,7 +51,7 @@ def test_category_budget_defaults_null_dates(db_session: Session):
def test_category_budget_date_window(db_session: Session): def test_category_budget_date_window(db_session: Session):
category = CategoryDb(name="Freizeit") category = CategoryDb(name=CategoryFactory.build().name)
db_session.add(category) db_session.add(category)
db_session.flush() db_session.flush()
@@ -69,7 +70,7 @@ def test_category_budget_date_window(db_session: Session):
def test_category_budget_relationship_both_ways(db_session: Session): def test_category_budget_relationship_both_ways(db_session: Session):
category = CategoryDb(name="Beruf") category = CategoryDb(name=CategoryFactory.build().name)
db_session.add(category) db_session.add(category)
db_session.flush() db_session.flush()
@@ -85,7 +86,7 @@ def test_category_budget_relationship_both_ways(db_session: Session):
def test_category_has_multiple_budgets(db_session: Session): def test_category_has_multiple_budgets(db_session: Session):
category = CategoryDb(name="Wohnen") category = CategoryDb(name=CategoryFactory.build().name)
db_session.add(category) db_session.add(category)
db_session.flush() db_session.flush()
+6 -5
View File
@@ -3,6 +3,7 @@ from datetime import date
import pytest import pytest
from src.models.category import Category, CategoryBudget from src.models.category import Category, CategoryBudget
from tests.factories import CategoryFactory
class TestCategoryComputedFields: class TestCategoryComputedFields:
@@ -75,7 +76,7 @@ class TestCategoryBudgetValidation:
def test_valid_budget_no_dates(self): def test_valid_budget_no_dates(self):
"""Budget with None dates is valid.""" """Budget with None dates is valid."""
cat = Category.from_str("test") cat = CategoryFactory.build()
budget = CategoryBudget( budget = CategoryBudget(
category=cat, category=cat,
amount_eur=100.00, amount_eur=100.00,
@@ -86,7 +87,7 @@ class TestCategoryBudgetValidation:
def test_valid_budget_with_dates(self): def test_valid_budget_with_dates(self):
"""Budget with valid date range is accepted.""" """Budget with valid date range is accepted."""
cat = Category.from_str("test") cat = CategoryFactory.build()
budget = CategoryBudget( budget = CategoryBudget(
category=cat, category=cat,
amount_eur=100.00, amount_eur=100.00,
@@ -99,7 +100,7 @@ class TestCategoryBudgetValidation:
def test_invalid_date_range_raises(self): def test_invalid_date_range_raises(self):
"""Budget with valid_from > valid_to raises ValueError.""" """Budget with valid_from > valid_to raises ValueError."""
cat = Category.from_str("test") cat = CategoryFactory.build()
with pytest.raises(ValueError) as exc_info: with pytest.raises(ValueError) as exc_info:
CategoryBudget( CategoryBudget(
@@ -116,7 +117,7 @@ class TestCategoryBudgetValidation:
def test_valid_budget_exact_boundary(self): def test_valid_budget_exact_boundary(self):
"""Budget with exact same from and to is valid (single day).""" """Budget with exact same from and to is valid (single day)."""
cat = Category.from_str("test") cat = CategoryFactory.build()
budget = CategoryBudget( budget = CategoryBudget(
category=cat, category=cat,
amount_eur=100.00, amount_eur=100.00,
@@ -129,7 +130,7 @@ class TestCategoryBudgetValidation:
def test_validation_preserves_original_values(self): def test_validation_preserves_original_values(self):
"""Validation doesn't change the model's original values.""" """Validation doesn't change the model's original values."""
cat = Category.from_str("test") cat = CategoryFactory.build()
budget = CategoryBudget( budget = CategoryBudget(
category=cat, category=cat,
amount_eur=150.75, amount_eur=150.75,
+18 -17
View File
@@ -8,6 +8,7 @@ from sqlalchemy.orm import Session
from src.domain_utils import find_category_db, set_category_budget from src.domain_utils import find_category_db, set_category_budget
from src.models import Category, CategoryBudget, CategoryBudgetDb from src.models import Category, CategoryBudget, CategoryBudgetDb
from src.models.category import upsert_category from src.models.category import upsert_category
from tests.factories import CategoryFactory
# NOTE: MYPY errors suppressed for test fixtures - see AGENTS.md for type checking notes. # NOTE: MYPY errors suppressed for test fixtures - see AGENTS.md for type checking notes.
@@ -17,7 +18,7 @@ class TestSetCategoryBudgetNoOverlap:
def test_no_overlap_add_new(self, db_session: Session): def test_no_overlap_add_new(self, db_session: Session):
"""When new budget has no overlap with existing, add both.""" """When new budget has no overlap with existing, add both."""
cat = Category(name="NoOverlap") cat = CategoryFactory.build()
cat_db = upsert_category(db_session, cat, do_commit=False) cat_db = upsert_category(db_session, cat, do_commit=False)
db_session.commit() db_session.commit()
@@ -46,7 +47,7 @@ class TestSetCategoryBudgetNoOverlap:
def test_completely_before_no_overlap(self, db_session: Session): def test_completely_before_no_overlap(self, db_session: Session):
"""New budget completely before existing - should add both.""" """New budget completely before existing - should add both."""
cat = Category(name="BeforeExisting") cat = CategoryFactory.build()
cat_db = upsert_category(db_session, cat, do_commit=False) cat_db = upsert_category(db_session, cat, do_commit=False)
db_session.commit() db_session.commit()
@@ -79,7 +80,7 @@ class TestSetCategoryBudgetFullOverlap:
def test_full_overlap_replace(self, db_session: Session): def test_full_overlap_replace(self, db_session: Session):
"""When new budget fully overlaps existing, replace entirely.""" """When new budget fully overlaps existing, replace entirely."""
cat = Category(name="FullOverlap") cat = CategoryFactory.build()
cat_db = upsert_category(db_session, cat, do_commit=False) cat_db = upsert_category(db_session, cat, do_commit=False)
db_session.commit() db_session.commit()
@@ -114,7 +115,7 @@ class TestSetCategoryBudgetPartialOverlapStart:
def test_partial_overlap_at_start(self, db_session: Session): def test_partial_overlap_at_start(self, db_session: Session):
"""When new budget overlaps existing from the start, keep end and cut before.""" """When new budget overlaps existing from the start, keep end and cut before."""
cat = Category(name="OverlapStart") cat = CategoryFactory.build()
cat_db = upsert_category(db_session, cat, do_commit=False) cat_db = upsert_category(db_session, cat, do_commit=False)
db_session.commit() db_session.commit()
@@ -150,7 +151,7 @@ class TestSetCategoryBudgetPartialOverlapEnd:
def test_partial_overlap_at_end(self, db_session: Session): def test_partial_overlap_at_end(self, db_session: Session):
"""When new budget overlaps existing at the end, keep start and cut after.""" """When new budget overlaps existing at the end, keep start and cut after."""
cat = Category(name="OverlapEnd") cat = CategoryFactory.build()
cat_db = upsert_category(db_session, cat, do_commit=False) cat_db = upsert_category(db_session, cat, do_commit=False)
db_session.commit() db_session.commit()
@@ -186,7 +187,7 @@ class TestSetCategoryBudgetMiddleOverlap:
def test_complex_overlap_middle(self, db_session: Session): def test_complex_overlap_middle(self, db_session: Session):
"""Handle overlap in the middle that splits existing budget into two parts.""" """Handle overlap in the middle that splits existing budget into two parts."""
cat = Category(name="SplitMiddle") cat = CategoryFactory.build()
cat_db = upsert_category(db_session, cat, do_commit=False) cat_db = upsert_category(db_session, cat, do_commit=False)
db_session.commit() db_session.commit()
@@ -223,7 +224,7 @@ class TestSetCategoryBudgetUnbounded:
def test_unbounded_start(self, db_session: Session): def test_unbounded_start(self, db_session: Session):
"""Budget with None valid_from (unbounded start).""" """Budget with None valid_from (unbounded start)."""
cat = Category(name="UnboundedStart") cat = CategoryFactory.build()
cat_db = upsert_category(db_session, cat, do_commit=False) cat_db = upsert_category(db_session, cat, do_commit=False)
db_session.commit() db_session.commit()
@@ -244,7 +245,7 @@ class TestSetCategoryBudgetUnbounded:
def test_unbounded_end(self, db_session: Session): def test_unbounded_end(self, db_session: Session):
"""Budget with None valid_to (unbounded end).""" """Budget with None valid_to (unbounded end)."""
cat = Category(name="UnboundedEnd") cat = CategoryFactory.build()
cat_db = upsert_category(db_session, cat, do_commit=False) cat_db = upsert_category(db_session, cat, do_commit=False)
db_session.commit() db_session.commit()
@@ -265,7 +266,7 @@ class TestSetCategoryBudgetUnbounded:
def test_unbounded_both(self, db_session: Session): def test_unbounded_both(self, db_session: Session):
"""Budget with both dates unbounded (None).""" """Budget with both dates unbounded (None)."""
cat = Category(name="UnboundedBoth") cat = CategoryFactory.build()
cat_db = upsert_category(db_session, cat, do_commit=False) cat_db = upsert_category(db_session, cat, do_commit=False)
db_session.commit() db_session.commit()
@@ -290,7 +291,7 @@ class TestSetCategoryBudgetWithExistingUnbounded:
def test_add_bounded_after_unbounded(self, db_session: Session): def test_add_bounded_after_unbounded(self, db_session: Session):
"""Add a bounded budget after an unbounded one (no overlap).""" """Add a bounded budget after an unbounded one (no overlap)."""
cat = Category(name="AfterUnbounded") cat = CategoryFactory.build()
cat_db = upsert_category(db_session, cat, do_commit=False) cat_db = upsert_category(db_session, cat, do_commit=False)
db_session.commit() db_session.commit()
@@ -319,7 +320,7 @@ class TestSetCategoryBudgetWithExistingUnbounded:
def test_add_bounded_before_unbounded(self, db_session: Session): def test_add_bounded_before_unbounded(self, db_session: Session):
"""Add a bounded budget before an unbounded one (no overlap).""" """Add a bounded budget before an unbounded one (no overlap)."""
cat = Category(name="BeforeUnbounded") cat = CategoryFactory.build()
cat_db = upsert_category(db_session, cat, do_commit=False) cat_db = upsert_category(db_session, cat, do_commit=False)
db_session.commit() db_session.commit()
@@ -352,7 +353,7 @@ class TestSetCategoryBudgetMultipleWithSameAmount:
def test_same_range_different_amount(self, db_session: Session): def test_same_range_different_amount(self, db_session: Session):
"""When two budgets have same range but different amounts, only one should remain.""" """When two budgets have same range but different amounts, only one should remain."""
cat = Category(name="SameRangeDifferentAmount") cat = CategoryFactory.build()
cat_db = upsert_category(db_session, cat, do_commit=False) cat_db = upsert_category(db_session, cat, do_commit=False)
db_session.commit() db_session.commit()
@@ -386,7 +387,7 @@ class TestSetCategoryBudgetDateEdgeCases:
def test_adjacent_budgets_no_overlap(self, db_session: Session): def test_adjacent_budgets_no_overlap(self, db_session: Session):
"""Two budgets that are adjacent (end of one is start-1 of next) should both exist.""" """Two budgets that are adjacent (end of one is start-1 of next) should both exist."""
cat = Category(name="Adjacent") cat = CategoryFactory.build()
cat_db = upsert_category(db_session, cat, do_commit=False) cat_db = upsert_category(db_session, cat, do_commit=False)
db_session.commit() db_session.commit()
@@ -497,7 +498,7 @@ class TestSetCategoryBudgetValidation:
def test_valid_budget_no_dates(self): def test_valid_budget_no_dates(self):
"""Budget with None dates is valid.""" """Budget with None dates is valid."""
cat = Category.from_str("test") cat = CategoryFactory.build()
budget = CategoryBudget( budget = CategoryBudget(
category=cat, category=cat,
amount_eur=100.00, amount_eur=100.00,
@@ -507,7 +508,7 @@ class TestSetCategoryBudgetValidation:
def test_valid_budget_with_dates(self): def test_valid_budget_with_dates(self):
"""Budget with dates is valid.""" """Budget with dates is valid."""
cat = Category.from_str("test") cat = CategoryFactory.build()
budget = CategoryBudget( budget = CategoryBudget(
category=cat, category=cat,
amount_eur=100.00, amount_eur=100.00,
@@ -519,7 +520,7 @@ class TestSetCategoryBudgetValidation:
def test_invalid_date_range_raises(self): def test_invalid_date_range_raises(self):
"""Budget with valid_from > valid_to should raise validation error.""" """Budget with valid_from > valid_to should raise validation error."""
cat = Category.from_str("test") cat = CategoryFactory.build()
with pytest.raises(ValueError) as exc_info: with pytest.raises(ValueError) as exc_info:
CategoryBudget( CategoryBudget(
@@ -533,7 +534,7 @@ class TestSetCategoryBudgetValidation:
def test_amount_must_be_positive(self): def test_amount_must_be_positive(self):
"""Amount must be greater than zero.""" """Amount must be greater than zero."""
cat = Category.from_str("test") cat = CategoryFactory.build()
with pytest.raises(ValueError) as exc_info: with pytest.raises(ValueError) as exc_info:
CategoryBudget( CategoryBudget(
+11
View File
@@ -1,8 +1,17 @@
from datetime import date from datetime import date
import faker import faker
from sqlalchemy.orm import Session
from src.models.b4w import B4wDb from src.models.b4w import B4wDb
from src.models.category import CategoryDb, upsert_category
from tests.factories import CategoryFactory
def _load_initial_categories(session: Session) -> None:
for category in CategoryFactory.build_all():
upsert_category(session, category, do_commit=False)
session.commit()
def test_b4w_populate(populated_db_b4w): def test_b4w_populate(populated_db_b4w):
@@ -10,10 +19,12 @@ def test_b4w_populate(populated_db_b4w):
session = populated_db_b4w( session = populated_db_b4w(
"b4w_basic", "b4w_basic",
100, 100,
setup=_load_initial_categories,
ValDt=lambda: faker.Faker().date_between(val_dt_min, val_dt_max), ValDt=lambda: faker.Faker().date_between(val_dt_min, val_dt_max),
) )
assert session assert session
assert session.query(B4wDb).count() == 100 assert session.query(B4wDb).count() == 100
assert session.query(CategoryDb).count() >= len(CategoryFactory.build_all())
# ``ValDt`` was given as a per-row callable: it must have been resolved # ``ValDt`` was given as a per-row callable: it must have been resolved
# and persisted for every row. # and persisted for every row.
dates = [d for (d,) in session.query(B4wDb.date_valid).all()] dates = [d for (d,) in session.query(B4wDb.date_valid).all()]
+2 -2
View File
@@ -15,7 +15,7 @@ from src.ingest.transactions import (
from src.models.b4w import B4wDb from src.models.b4w import B4wDb
from src.models.comdirect import ComdirectGiroDb from src.models.comdirect import ComdirectGiroDb
from src.models.transactions import Transaction, TransactionDb from src.models.transactions import Transaction, TransactionDb
from tests.factories import B4wExportFactory from tests.factories import B4wExportFactory, CategoryFactory
B4W_PATH = Path("tests/test_data/b4w_export_dummy_01.csv") B4W_PATH = Path("tests/test_data/b4w_export_dummy_01.csv")
COMDIRECT_PATH = Path("tests/test_data/comdirect_export_dummy_01.txt") COMDIRECT_PATH = Path("tests/test_data/comdirect_export_dummy_01.txt")
@@ -108,7 +108,7 @@ def test_consolidate_b4w_signed_amount_and_category(db_session: Session):
export = B4wExportFactory.build( export = B4wExportFactory.build(
Amt="50.00", Amt="50.00",
RmtInf="Supermarket", RmtInf="Supermarket",
Category="Lebenshaltung:Lebensmittel", Category=CategoryFactory.build().to_str(),
CdtDbtInd="DBIT", CdtDbtInd="DBIT",
) )
staging = upsert_b4w(db_session, export, do_commit=False) staging = upsert_b4w(db_session, export, do_commit=False)