Compare commits

...
2 Commits
Author SHA1 Message Date
mathias2784 696823406e Extract Category model into src/models/category.py
Tests / lint (push) Successful in 11s
Tests / test (push) Successful in 10s
2026-10-16 12:29:11 +02:00
mathias2784 c7e4b68dfb Fix comdirect CSV reader for neu group header rows 2026-10-16 12:28:57 +02:00
7 changed files with 78 additions and 63 deletions
+2 -1
View File
@@ -4,7 +4,8 @@ from sqlalchemy.orm import Session
from src.ingest._common import _reader_raw, _upsert from src.ingest._common import _reader_raw, _upsert
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, Category, CategoryDb from src.models.b4w import B4wDb, B4wExport, Category
from src.models.category import CategoryDb
def reader_b4w(path: Path) -> list[B4wExport]: def reader_b4w(path: Path) -> list[B4wExport]:
+15 -6
View File
@@ -73,6 +73,10 @@ def _reader_raw_comdirect(path: Path) -> CsV2DictMulti:
account_name: str | None = None account_name: str | None = None
header: list[str] = [] header: list[str] = []
result: CsV2DictMulti = {} result: CsV2DictMulti = {}
# Comdirect groups "new" transactions (marked with "neu") under a header
# line that carries only their Buchungstag; remember it until the next
# transaction row for the same account.
pending_book_date: dict[str, str] = {}
for line in lines: for line in lines:
if expect_next == EXPECT_LINE.ACCOUNT_NAME: if expect_next == EXPECT_LINE.ACCOUNT_NAME:
matches = re.findall(PATTERN_ACCOUNT_NAME, line) matches = re.findall(PATTERN_ACCOUNT_NAME, line)
@@ -91,13 +95,18 @@ def _reader_raw_comdirect(path: Path) -> CsV2DictMulti:
expect_next = EXPECT_LINE.DATA expect_next = EXPECT_LINE.DATA
elif expect_next == EXPECT_LINE.DATA: elif expect_next == EXPECT_LINE.DATA:
data = re.findall(PATTERN_DATA, line) data = re.findall(PATTERN_DATA, line)
if data: if not data:
assert account_name is not None
result[account_name].append(
{column: value for column, value in zip(header, data)}
)
else:
expect_next = EXPECT_LINE.ACCOUNT_NAME expect_next = EXPECT_LINE.ACCOUNT_NAME
elif len(data) == 1:
# Group header line (single Buchungstag for the "neu" rows below).
assert account_name is not None
pending_book_date[account_name] = data[0]
else:
assert account_name is not None
row = {column: value for column, value in zip(header, data)}
if data[0] == "neu" and account_name in pending_book_date:
row["Buchungstag"] = pending_book_date.pop(account_name)
result[account_name].append(row)
else: else:
expect_next = EXPECT_LINE.ACCOUNT_NAME expect_next = EXPECT_LINE.ACCOUNT_NAME
return result return result
+1 -1
View File
@@ -1,5 +1,5 @@
from src.models.b4w import B4wExport as B4wExport from src.models.b4w import B4wExport as B4wExport
from src.models.b4w import Category as Category from src.models.category import Category as Category
from src.models.category_budget import CategoryBudgetDb as CategoryBudgetDb from src.models.category_budget import CategoryBudgetDb as CategoryBudgetDb
from src.models.comdirect import ComdirectCreditCard as ComdirectCreditCard from src.models.comdirect import ComdirectCreditCard as ComdirectCreditCard
from src.models.comdirect import ComdirectDepot as ComdirectDepot from src.models.comdirect import ComdirectDepot as ComdirectDepot
+2 -53
View File
@@ -2,18 +2,18 @@ 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, UniqueConstraint 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._common import ( from src.models._common import (
BaseModelDb,
ExportBaseModel, ExportBaseModel,
IngestdbBase, IngestdbBase,
Sign, Sign,
_hash, _hash,
date2str, date2str,
) )
from src.models.category import Category
class B4wExport(ExportBaseModel): class B4wExport(ExportBaseModel):
@@ -87,57 +87,6 @@ class B4wExport(ExportBaseModel):
return category return category
class Category(ExportBaseModel):
name: str = Field(pattern=r"[^:\r\n]+")
parent: Category | None = None
@computed_field # type: ignore[prop-decorator]
@property
def hash_id(self) -> str:
return _hash(self.name, self.parent)
@classmethod
def from_str(cls, s: str) -> Category | None:
if not s:
return None
components = s.split(":")
name = components[-1]
rest = ":".join(components[:-1])
return cls(name=name, parent=cls.from_str(rest))
def to_str(self) -> str | None:
if not self.parent:
return self.name
else:
return f"{self.parent.to_str()}:{self.name}"
def __str__(self):
return self.to_str()
def __repr__(self):
return f'Category.from_str("{self.__str__()}")'
class CategoryDb(BaseModelDb):
__tablename__ = "category"
__table_args__ = (
UniqueConstraint("name", "parent_id", name="uq_category_name_parent"),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String, nullable=False)
parent_id: Mapped[int | None] = mapped_column(
ForeignKey("category.id"), nullable=True
)
# Relationships
parent = relationship(
"CategoryDb", remote_side="CategoryDb.id", back_populates="children"
)
children = relationship("CategoryDb", back_populates="parent")
budgets = relationship("CategoryBudgetDb", back_populates="category")
class B4wDb(IngestdbBase): class B4wDb(IngestdbBase):
__tablename__ = "import_b4w" __tablename__ = "import_b4w"
+56
View File
@@ -0,0 +1,56 @@
from pydantic import Field, computed_field
from sqlalchemy import ForeignKey, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models._common import BaseModelDb, ExportBaseModel, _hash
class Category(ExportBaseModel):
name: str = Field(pattern=r"[^:\r\n]+")
parent: Category | None = None
@computed_field # type: ignore[prop-decorator]
@property
def hash_id(self) -> str:
return _hash(self.name, self.parent)
@classmethod
def from_str(cls, s: str) -> Category | None:
if not s:
return None
components = s.split(":")
name = components[-1]
rest = ":".join(components[:-1])
return cls(name=name, parent=cls.from_str(rest))
def to_str(self) -> str | None:
if not self.parent:
return self.name
else:
return f"{self.parent.to_str()}:{self.name}"
def __str__(self):
return self.to_str()
def __repr__(self):
return f'Category.from_str("{self.__str__()}")'
class CategoryDb(BaseModelDb):
__tablename__ = "category"
__table_args__ = (
UniqueConstraint("name", "parent_id", name="uq_category_name_parent"),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String, nullable=False)
parent_id: Mapped[int | None] = mapped_column(
ForeignKey("category.id"), nullable=True
)
# Relationships
parent = relationship(
"CategoryDb", remote_side="CategoryDb.id", back_populates="children"
)
children = relationship("CategoryDb", back_populates="parent")
budgets = relationship("CategoryBudgetDb", back_populates="category")
+1 -1
View File
@@ -4,7 +4,7 @@ from decimal import Decimal
from sqlalchemy import inspect from sqlalchemy import inspect
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from src.models.b4w import CategoryDb from src.models.category import CategoryDb
from src.models.category_budget import CategoryBudgetDb from src.models.category_budget import CategoryBudgetDb
+1 -1
View File
@@ -3,7 +3,7 @@ from sqlalchemy.orm import Session
from src.ingest.b4w import upsert_category from src.ingest.b4w import upsert_category
from src.models import Category from src.models import Category
from src.models.b4w import CategoryDb from src.models.category import CategoryDb
def test_upsert_category_returns_orm_object(db_session: Session): def test_upsert_category_returns_orm_object(db_session: Session):