Compare commits

1 Commits
Author SHA1 Message Date
mathias2784 6babc560c7 Category overhaul (#20)
Tests / lint (push) Successful in 16s
Tests / test (push) Successful in 16s
Reviewed-on: #20
Co-authored-by: Mathias <mathias2784@web.de>
2026-10-27 18:08:10 +00:00
10 changed files with 524 additions and 27 deletions
+23 -10
View File
@@ -23,20 +23,29 @@ alembic upgrade head
```python ```python
from src.ingest.b4w import ingest_b4w from src.ingest.b4w import ingest_b4w
from src.db import get_session
from pathlib import Path from pathlib import Path
csv_path = Path("/mnt/anbau/Software/Python/finanz-cockpit/scripts/dummy_data/b4w_export_dummy_01.csv") csv_path = Path("/mnt/anbau/Software/Python/finanz-cockpit/scripts/dummy_data/b4w_export_dummy_01.csv")
ingest_b4w(csv_path) with get_session() as session:
ingest_b4w(csv_path, session)
``` ```
### Import comdirect Export ### Import comdirect Export
```python ```python
from src.ingest.comdirect import ingest_comdirect from src.ingest.comdirect import ingest_comdirect
from src.db import get_session
from pathlib import Path from pathlib import Path
csv_path = Path("/mnt/anbau/Software/Python/finanz-cockpit/scripts/dummy_data/comdirect_giro_01.csv") csv_path = Path("/mnt/anbau/Software/Python/finanz-cockpit/scripts/dummy_data/comdirect_giro_01.csv")
ingest_comdirect(csv_path) with get_session() as session:
ingest_comdirect(
session,
csv_path,
account_iban="DE89370400440532013000",
account_currency="EUR",
)
``` ```
### Set Category Budgets ### Set Category Budgets
@@ -45,6 +54,7 @@ ingest_comdirect(csv_path)
from src.models.category import Category, CategoryBudget from src.models.category import Category, CategoryBudget
from datetime import date from datetime import date
from src.domain_utils import set_category_budget from src.domain_utils import set_category_budget
from src.db import get_session
# Create category hierarchy # Create category hierarchy
cat = Category.from_str("Lebenshaltung:Essen:Supermarkt") cat = Category.from_str("Lebenshaltung:Essen:Supermarkt")
@@ -56,14 +66,17 @@ budget = CategoryBudget(
valid_from=date(2026, 10, 1), valid_from=date(2026, 10, 1),
valid_to=date(2026, 11, 30), valid_to=date(2026, 11, 30),
) )
set_category_budget(db_session, budget) with get_session() as session:
set_category_budget(session, budget)
session.commit()
# Budgets can also be unbounded (no dates specified) # Budgets can also be unbounded (no dates specified)
budget = CategoryBudget( budget = CategoryBudget(
category=cat, category=cat,
amount_eur=200.00, # Monthly average amount_eur=200.00, # Monthly average
) )
set_category_budget(db_session, budget) set_category_budget(session, budget)
session.commit()
``` ```
## Testing ## Testing
@@ -141,7 +154,7 @@ alembic upgrade head
pytest pytest
# Run with coverage report # Run with coverage report
pytest --cov=src --cov-report=html -c .coverage htmlcov pytest --cov=src --cov-report=html:htmlcov
``` ```
### Common Patterns ### Common Patterns
@@ -0,0 +1,23 @@
"""merge category validity and budget migration branches
Revision ID: c18ae67f4b2a
Revises: d9c6533f8a1b, fe23064de1c5
Create Date: 2026-10-27
"""
from collections.abc import Sequence
# revision identifiers, used by Alembic.
revision: str = "c18ae67f4b2a"
down_revision: str | Sequence[str] | None = ("d9c6533f8a1b", "fe23064de1c5")
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -0,0 +1,102 @@
"""add temporal validity to categories
Revision ID: d9c6533f8a1b
Revises: 78ec55b9e1d1
Create Date: 2026-10-25 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "d9c6533f8a1b"
down_revision: str | Sequence[str] | None = "78ec55b9e1d1"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table("category") as batch_op:
batch_op.drop_constraint("uq_category_name_parent", type_="unique")
batch_op.add_column(
sa.Column(
"valid_from",
sa.DateTime(timezone=True),
nullable=True,
info={
"description": "UTC start of validity; NULL means valid since the indefinite past."
},
)
)
batch_op.add_column(
sa.Column(
"valid_to",
sa.DateTime(timezone=True),
nullable=True,
info={
"description": "UTC end of validity; NULL means the version is currently valid."
},
)
)
batch_op.add_column(
sa.Column(
"last_action",
sa.String(),
nullable=False,
server_default="created",
info={
"description": "Most recent lifecycle action recorded for this category version."
},
)
)
# Existing rows predate temporal tracking, so their start is unbounded.
op.execute("UPDATE category SET last_action = 'migrated'")
# Separate root and non-root indexes so NULL parent IDs are unique too.
op.create_index(
"uq_category_active_name_parent",
"category",
["name", "parent_id"],
unique=True,
sqlite_where=sa.text("valid_to IS NULL AND parent_id IS NOT NULL"),
postgresql_where=sa.text("valid_to IS NULL AND parent_id IS NOT NULL"),
)
op.create_index(
"uq_category_active_root_name",
"category",
["name"],
unique=True,
sqlite_where=sa.text("valid_to IS NULL AND parent_id IS NULL"),
postgresql_where=sa.text("valid_to IS NULL AND parent_id IS NULL"),
)
def downgrade() -> None:
bind = op.get_bind()
has_temporal_history = bind.execute(
sa.text(
"SELECT 1 FROM category "
"WHERE valid_from IS NOT NULL OR valid_to IS NOT NULL LIMIT 1"
)
).first()
if has_temporal_history:
raise RuntimeError(
"Cannot downgrade category validity while temporal history exists; "
"the downgrade would discard it."
)
op.drop_index("uq_category_active_root_name", table_name="category")
op.drop_index("uq_category_active_name_parent", table_name="category")
with op.batch_alter_table("category") as batch_op:
batch_op.drop_column("last_action")
batch_op.drop_column("valid_to")
batch_op.drop_column("valid_from")
batch_op.create_unique_constraint(
"uq_category_name_parent", ["name", "parent_id"]
)
+1 -5
View File
@@ -33,11 +33,7 @@ build-backend = "uv_build"
[tool.mypy] [tool.mypy]
explicit_package_bases = true explicit_package_bases = true
mypy_path = "." mypy_path = "."
# Ignore test file type errors (inline class definitions, float literals) exclude = ["^tests/"]
# See AGENTS.md for type checking notes
[[tool.mypy.overrides]]
module = ["tests.test_db_utils_update_path", "tests.test_domain_utils_budget_overlap"]
ignore_errors = true
[tool.ruff] [tool.ruff]
target-version = "py314" target-version = "py314"
+2 -1
View File
@@ -29,7 +29,8 @@ def find_category_db(session: Session, category: Category | None) -> CategoryDb
return ( return (
session.query(CategoryDb) session.query(CategoryDb)
.filter_by(name=node.name, parent_id=parent_id) .filter_by(name=node.name, parent_id=parent_id)
.first() .filter(CategoryDb.valid_to.is_(None))
.one_or_none()
) )
return find_existing(category) return find_existing(category)
+89 -1
View File
@@ -1,3 +1,4 @@
from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -6,7 +7,7 @@ from src.db_utils import upsert
from src.ingest.common import _reader_raw from src.ingest.common import _reader_raw
from src.ingest.transactions import consolidate_transactions, map_b4w_to_transaction from src.ingest.transactions import consolidate_transactions, map_b4w_to_transaction
from src.models.b4w import B4wDb, B4wExport from src.models.b4w import B4wDb, B4wExport
from src.models.category import upsert_category from src.models.category import Category, CategoryDb, upsert_category
def reader_b4w(path: Path) -> list[B4wExport]: def reader_b4w(path: Path) -> list[B4wExport]:
@@ -14,6 +15,93 @@ def reader_b4w(path: Path) -> list[B4wExport]:
return list(map(B4wExport.model_validate, raw)) return list(map(B4wExport.model_validate, raw))
def _utc_datetime(value: datetime | None) -> datetime:
if value is None:
return datetime.now(UTC)
if value.tzinfo is None or value.utcoffset() is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
def _ensure_after_valid_from(category: CategoryDb, effective_at: datetime) -> None:
if category.valid_from is None:
return
valid_from = _utc_datetime(category.valid_from)
if effective_at <= valid_from:
raise ValueError("The action time must be later than valid_from")
def change_category(
session: Session,
category_id: int,
replacement: Category,
effective_at: datetime | None = None,
do_commit: bool = True,
) -> CategoryDb:
"""Close an active category version and start its replacement version."""
current = (
session.query(CategoryDb).filter_by(id=category_id, valid_to=None).one_or_none()
)
if current is None:
raise ValueError(f"No active category found with id {category_id}")
action_time = _utc_datetime(effective_at)
_ensure_after_valid_from(current, action_time)
parent = upsert_category(session, replacement.parent, do_commit=False)
parent_id = parent.id if parent else None
if current.name == replacement.name and current.parent_id == parent_id:
return current
replacement_active = (
session.query(CategoryDb)
.filter_by(name=replacement.name, parent_id=parent_id, valid_to=None)
.one_or_none()
)
current.valid_to = action_time
current.last_action = "changed"
session.flush()
if replacement_active is None:
replacement_active = CategoryDb(
name=replacement.name,
parent=parent,
valid_from=action_time,
valid_to=None,
last_action="changed",
)
session.add(replacement_active)
session.flush()
if do_commit:
session.commit()
return replacement_active
def retire_category(
session: Session,
category_id: int,
effective_at: datetime | None = None,
do_commit: bool = True,
) -> CategoryDb:
"""Close an active category version without creating a replacement."""
category = (
session.query(CategoryDb).filter_by(id=category_id, valid_to=None).one_or_none()
)
if category is None:
raise ValueError(f"No active category found with id {category_id}")
action_time = _utc_datetime(effective_at)
_ensure_after_valid_from(category, action_time)
category.valid_to = action_time
category.last_action = "retired"
session.flush()
if do_commit:
session.commit()
return category
def upsert_b4w( def upsert_b4w(
session: Session, data: B4wExport, do_update=False, do_commit=True session: Session, data: B4wExport, do_update=False, do_commit=True
) -> B4wDb | None: ) -> B4wDb | None:
+9 -2
View File
@@ -2,11 +2,16 @@ from datetime import date
from decimal import Decimal from decimal import Decimal
from pydantic import Field, computed_field, field_validator from pydantic import Field, computed_field, field_validator
from sqlalchemy import Date, ForeignKey, Integer, String from sqlalchemy import (
Date,
ForeignKey,
Integer,
String,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.constants import IBAN_PATTERN, Currency from src.constants import IBAN_PATTERN, Currency
from src.models.category import Category from src.models.category import Category, CategoryDb
from src.models.common import ( from src.models.common import (
ExportBaseModel, ExportBaseModel,
IngestdbBase, IngestdbBase,
@@ -15,6 +20,8 @@ from src.models.common import (
hashify, hashify,
) )
__all__ = ["B4wDb", "B4wExport", "Category", "CategoryDb"]
class B4wExport(ExportBaseModel): class B4wExport(ExportBaseModel):
id: int = Field(alias="Id1", description="internal B4w ID") id: int = Field(alias="Id1", description="internal B4w ID")
+114 -6
View File
@@ -1,15 +1,50 @@
from datetime import date from __future__ import annotations
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal from decimal import Decimal
from typing import Self from typing import Self
from pydantic import BaseModel, Field, computed_field, model_validator from pydantic import BaseModel, Field, computed_field, model_validator
from sqlalchemy import Date, ForeignKey, Numeric, String, UniqueConstraint from sqlalchemy import (
Date,
DateTime,
ForeignKey,
Index,
Numeric,
String,
TypeDecorator,
text,
)
from sqlalchemy.orm import Mapped, Session, mapped_column, relationship from sqlalchemy.orm import Mapped, Session, mapped_column, relationship
from src.db_utils import upsert from src.db_utils import upsert
from src.models.common import BaseModelDb, ExportBaseModel, hashify from src.models.common import BaseModelDb, ExportBaseModel, hashify
class UTCDateTime(TypeDecorator[datetime]):
"""Store UTC timestamps and return timezone-aware UTC values on every dialect."""
impl = DateTime
cache_ok = True
def load_dialect_impl(self, dialect):
return dialect.type_descriptor(DateTime(timezone=True))
def process_bind_param(self, value: datetime | None, dialect):
if value is None:
return None
if value.tzinfo is None or value.utcoffset() is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
def process_result_value(self, value: datetime | None, dialect):
if value is None:
return None
if value.tzinfo is None or value.utcoffset() is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
class Category(ExportBaseModel): class Category(ExportBaseModel):
name: str = Field(pattern=r"[^:\r\n]+") name: str = Field(pattern=r"[^:\r\n]+")
parent: Category | None = None parent: Category | None = None
@@ -44,7 +79,21 @@ class Category(ExportBaseModel):
class CategoryDb(BaseModelDb): class CategoryDb(BaseModelDb):
__tablename__ = "category" __tablename__ = "category"
__table_args__ = ( __table_args__ = (
UniqueConstraint("name", "parent_id", name="uq_category_name_parent"), Index(
"uq_category_active_name_parent",
"name",
"parent_id",
unique=True,
sqlite_where=text("valid_to IS NULL AND parent_id IS NOT NULL"),
postgresql_where=text("valid_to IS NULL AND parent_id IS NOT NULL"),
),
Index(
"uq_category_active_root_name",
"name",
unique=True,
sqlite_where=text("valid_to IS NULL AND parent_id IS NULL"),
postgresql_where=text("valid_to IS NULL AND parent_id IS NULL"),
),
) )
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
@@ -52,6 +101,29 @@ class CategoryDb(BaseModelDb):
parent_id: Mapped[int | None] = mapped_column( parent_id: Mapped[int | None] = mapped_column(
ForeignKey("category.id"), nullable=True ForeignKey("category.id"), nullable=True
) )
valid_from: Mapped[datetime | None] = mapped_column(
UTCDateTime(),
nullable=True,
info={
"description": "UTC start of validity; NULL means valid since the indefinite past."
},
)
valid_to: Mapped[datetime | None] = mapped_column(
UTCDateTime(),
nullable=True,
info={
"description": "UTC end of validity; NULL means the version is currently valid."
},
)
last_action: Mapped[str] = mapped_column(
String,
nullable=False,
default="created",
server_default="created",
info={
"description": "Most recent lifecycle action recorded for this category version."
},
)
# Relationships # Relationships
parent = relationship( parent = relationship(
@@ -69,11 +141,45 @@ def upsert_category(
parent = upsert_category(session, category.parent, do_commit=False) parent = upsert_category(session, category.parent, do_commit=False)
parameters = {"name": category.name, "parent": parent} parameters = {"name": category.name, "parent": parent}
matching_rows = session.query(CategoryDb).filter_by(
name=category.name, parent_id=parent.id if parent else None
)
active = matching_rows.filter(CategoryDb.valid_to.is_(None)).one_or_none()
if active is not None:
return active
historical = (
matching_rows.filter(CategoryDb.valid_to.is_not(None))
.order_by(CategoryDb.valid_to.desc())
.first()
)
valid_from = None
last_action = "created"
if historical is not None:
valid_from = datetime.now(UTC)
if historical.valid_to is not None:
previous_valid_to = historical.valid_to
if (
previous_valid_to.tzinfo is None
or previous_valid_to.utcoffset() is None
):
previous_valid_to = previous_valid_to.replace(tzinfo=UTC)
else:
previous_valid_to = previous_valid_to.astimezone(UTC)
valid_from = max(valid_from, previous_valid_to + timedelta(microseconds=1))
last_action = "changed"
parameters_new = {
**parameters,
"valid_from": valid_from,
"valid_to": None,
"last_action": last_action,
}
return upsert( return upsert(
session, session,
CategoryDb, CategoryDb,
parameters, {**parameters, "valid_to": None},
parameters, parameters_new,
do_update=False, do_update=False,
do_commit=do_commit, do_commit=do_commit,
) )
@@ -113,7 +219,9 @@ class CategoryBudgetDb(BaseModelDb):
__tablename__ = "category_budget" __tablename__ = "category_budget"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
category_id: Mapped[int] = mapped_column(ForeignKey("category.id"), nullable=False) category_id: Mapped[int] = mapped_column(
ForeignKey("category.id"), nullable=False, index=True
)
amount_eur: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False) amount_eur: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
valid_from: Mapped[date | None] = mapped_column(Date, nullable=True, default=None) valid_from: Mapped[date | None] = mapped_column(Date, nullable=True, default=None)
valid_to: Mapped[date | None] = mapped_column(Date, nullable=True, default=None) valid_to: Mapped[date | None] = mapped_column(Date, nullable=True, default=None)
+29 -2
View File
@@ -1,13 +1,13 @@
"""Comprehensive tests for set_category_budget overlap handling.""" """Comprehensive tests for set_category_budget overlap handling."""
from datetime import date from datetime import UTC, date, datetime
import pytest import pytest
from sqlalchemy.orm import Session 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 CategoryDb, upsert_category
from tests.factories import CategoryFactory 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.
@@ -615,6 +615,33 @@ class TestFindCategoryDb:
result = find_category_db(db_session, Category(name="NonExistent")) result = find_category_db(db_session, Category(name="NonExistent"))
assert result is None assert result is None
def test_find_reintroduced_category_returns_active_version(
self, db_session: Session
):
category = Category(name="Reintroduced")
historical = CategoryDb(
name=category.name,
valid_to=datetime(2026, 1, 1, tzinfo=UTC),
last_action="retired",
)
db_session.add(historical)
db_session.commit()
active = upsert_category(db_session, category, do_commit=False)
assert active is not None
db_session.commit()
result = find_category_db(db_session, category)
assert result is not None
assert result.id == active.id
set_category_budget(
db_session,
CategoryBudget(category=category, amount_eur=100.00),
)
budget = db_session.query(CategoryBudgetDb).one()
assert budget.category_id == active.id
class TestSetCategoryBudgetErrorHandling: class TestSetCategoryBudgetErrorHandling:
"""Test error handling in set_category_budget.""" """Test error handling in set_category_budget."""
+132
View File
@@ -0,0 +1,132 @@
from datetime import UTC, datetime
import pytest
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from src.ingest.b4w import change_category, retire_category, upsert_category
from src.models import Category
from src.models.b4w import CategoryDb
def test_upsert_category_returns_orm_object(db_session: Session):
result = upsert_category(
db_session,
Category.from_str("dummy_parent_1:dummy_parent_2:dummy"),
do_commit=False,
)
assert result
assert isinstance(result, CategoryDb)
assert str(result.name) == "dummy"
assert str(result.parent.name) == "dummy_parent_2"
assert str(result.parent.parent.name) == "dummy_parent_1"
assert result.valid_from is None
assert result.valid_to is None
assert result.last_action == "created"
assert all(
CategoryDb.__table__.c[name].info.get("description")
for name in ("valid_from", "valid_to", "last_action")
)
def test_upsert_category_writes_new_to_db(db_session: Session):
upsert_category(
db_session,
Category.from_str("grand_father:father:new_category"),
do_commit=True,
)
result = db_session.execute(
select(CategoryDb).filter_by(name="new_category")
).scalar_one()
assert result
assert str(result.name) == "new_category"
assert str(result.parent.name) == "father"
assert str(result.parent.parent.name) == "grand_father"
def test_upsert_category_skips_existing(db_session: Session):
for _ in range(3):
upsert_category(
db_session,
Category.from_str("grand_father:father:try_again_and_again"),
do_commit=True,
)
result = db_session.execute(
select(CategoryDb).filter_by(name="try_again_and_again")
).all()
assert len(result) == 1
def test_change_category_closes_old_version_and_starts_new_one(
db_session: Session,
):
original = upsert_category(
db_session, Category.from_str("old_category"), do_commit=False
)
assert original is not None
effective_at = datetime(2026, 10, 16, 12, tzinfo=UTC)
replacement = change_category(
db_session,
original.id,
Category.from_str("new_category"),
effective_at=effective_at,
do_commit=False,
)
assert original.valid_from is None
assert original.valid_to == effective_at
assert original.last_action == "changed"
assert replacement.name == "new_category"
assert replacement.valid_from == effective_at
assert replacement.valid_to is None
assert replacement.last_action == "changed"
db_session.expire(original)
db_session.expire(replacement)
assert original.valid_to == effective_at
assert replacement.valid_from == effective_at
# A historical name can be introduced again as a new active version.
reintroduced = upsert_category(
db_session, Category.from_str("old_category"), do_commit=False
)
assert reintroduced is not None
assert reintroduced.id != original.id
assert reintroduced.valid_from is not None
assert reintroduced.valid_from > original.valid_to
assert reintroduced.last_action == "changed"
def test_retire_category_closes_active_version(db_session: Session):
category = upsert_category(
db_session, Category.from_str("category_to_retire"), do_commit=False
)
assert category is not None
effective_at = datetime(2026, 10, 16, 12, tzinfo=UTC)
retired = retire_category(
db_session, category.id, effective_at=effective_at, do_commit=False
)
assert retired.valid_to == effective_at
assert retired.last_action == "retired"
assert (
db_session.query(CategoryDb)
.filter_by(name="category_to_retire", valid_to=None)
.one_or_none()
is None
)
def test_active_root_categories_are_unique(db_session: Session):
db_session.add_all(
[
CategoryDb(name="duplicate_root"),
CategoryDb(name="duplicate_root"),
]
)
with pytest.raises(IntegrityError):
db_session.flush()