first sketch; incomplete; WiP
Tests / test (push) Successful in 11s
Tests / lint (push) Failing after 8s

This commit is contained in:
2026-10-27 20:35:49 +01:00
parent 6babc560c7
commit 6b2821ba96
4 changed files with 72 additions and 7 deletions
+27
View File
@@ -0,0 +1,27 @@
{
"month": "2026-10",
"categories": [
{
"category": "super-category-1",
"total_amount": 100,
"total_budget": 120,
"sub_categories": [
{
"category": "category-1",
"total_amount": 60,
"total_budget": 50,
"sub_categories": [],
"transactions": []
},
{
"category": "category-2",
"total_amount": 40,
"total_budget": 60,
"sub_categories": [],
"transactions": []
}
],
"transactions": []
}
]
}
+34 -2
View File
@@ -18,7 +18,8 @@ from sqlalchemy import (
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 Amount, AmountPos, BaseModelDb, ExportBaseModel, hashify
from src.models.transactions import Transaction
class UTCDateTime(TypeDecorator[datetime]): class UTCDateTime(TypeDecorator[datetime]):
@@ -75,6 +76,9 @@ class Category(ExportBaseModel):
def __repr__(self): def __repr__(self):
return f'Category.from_str("{self.__str__()}")' return f'Category.from_str("{self.__str__()}")'
def get_budget(self, date_valid: date | None) -> float:
...
class CategoryDb(BaseModelDb): class CategoryDb(BaseModelDb):
__tablename__ = "category" __tablename__ = "category"
@@ -187,7 +191,7 @@ def upsert_category(
class CategoryBudget(BaseModel): class CategoryBudget(BaseModel):
category: Category category: Category
amount_eur: Decimal = Field(gt=0, decimal_places=2) amount_eur: AmountPos
valid_from: date | None = Field( valid_from: date | None = Field(
default=None, description="Start of validity. None means unbounded" default=None, description="Start of validity. None means unbounded"
) )
@@ -228,3 +232,31 @@ class CategoryBudgetDb(BaseModelDb):
# relationship # relationship
category = relationship("CategoryDb", back_populates="budgets") category = relationship("CategoryDb", back_populates="budgets")
class CategoryTree(BaseModel):
category: Category
total_amount: float
total_budget: float
sub_categories: list[ CategoryTree ] | None
transactions: list[Transaction] | None
def get_total_amount(self) -> float:
result = 0.0
if self.transactions:
sum(transaction.amount for transaction in self.transactions )
if self.sub_categories:
sum(sub_category.get_total_amount() for sub_category in self.sub_categories)
return result
def get_total_budget(self, date_valid: date) -> float:
result = 0.0
result += self.category.get_budget(date_valid)
if self.sub_categories:
result += sum(category.category.get_budget(date_valid) for category in self.sub_categories)
return result
def build_category_tree(transactions: list[Transaction]) -> list[CategoryTree]:
result = []
for transaction in transactions:
pass
+8 -3
View File
@@ -2,8 +2,9 @@ import hashlib
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
from enum import Enum from enum import Enum
from typing import Annotated
from pydantic import BaseModel from pydantic import BaseModel, Field
from sqlalchemy import Date, Numeric, String from sqlalchemy import Date, Numeric, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@@ -39,12 +40,15 @@ def date2str(value: date | None, default="--", date_format="%Y-%m-%d") -> str:
return value.strftime(date_format) return value.strftime(date_format)
return default return default
type AmountPos = Annotated[Decimal, Field(decimal_places=2, gt=0)]
type Amount = Annotated[Decimal, Field(decimal_places=2)]
def _parse_decimal_german(decimal_str: str) -> Decimal:
def _parse_decimal_german(decimal_str: str) -> Amount:
return Decimal(decimal_str.replace(".", "").replace(",", ".")) return Decimal(decimal_str.replace(".", "").replace(",", "."))
def decimal2str(value: Decimal) -> str: def decimal2str(value: Amount) -> str:
value = Decimal(f"{value:.2f}") value = Decimal(f"{value:.2f}")
int_part, _, frac = str(value).partition(".") int_part, _, frac = str(value).partition(".")
groups: list[str] = [] groups: list[str] = []
@@ -60,6 +64,7 @@ type Csv2Dict = list[Csv2DictRow]
type CsV2DictMulti = dict[str, Csv2Dict] type CsV2DictMulti = dict[str, Csv2Dict]
class ExportBaseModel(BaseModel): class ExportBaseModel(BaseModel):
@classmethod @classmethod
+3 -2
View File
@@ -5,7 +5,7 @@ from pydantic import Field, computed_field
from sqlalchemy import Date, ForeignKey, Index, Integer, String, UniqueConstraint from sqlalchemy import Date, ForeignKey, Index, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.common import ExportBaseModel, IngestdbBase, date2str, hashify from src.models.common import Amount, ExportBaseModel, IngestdbBase, date2str, hashify
class Transaction(ExportBaseModel): class Transaction(ExportBaseModel):
@@ -22,12 +22,13 @@ class Transaction(ExportBaseModel):
account_iban: str account_iban: str
account_currency: str account_currency: str
date_valid: date date_valid: date
amount: Decimal = Field(description="Signed amount; negative = money out") amount: Amount
amount_currency: str amount_currency: str
date_book: date | None = None date_book: date | None = None
text: str | None = None text: str | None = None
other_name: str | None = None other_name: str | None = None
other_iban: str | None = None other_iban: str | None = None
# TODO: The Pydantic model should hold a category object, NOT the category id which is owned by the DB
category_id: int | None = None category_id: int | None = None
@computed_field # type: ignore[prop-decorator] @computed_field # type: ignore[prop-decorator]