"""Kategorie-Gruppen und Kategorien (zweistufig). Alles owner-gescoped. Eine Kategorie erbt ihre Art (Einnahme/Ausgabe) von ihrer Gruppe. """ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import func from sqlalchemy.orm import Session from ..database import get_db from ..deps import get_current_user, get_owned from ..models import Account, CategorizationRule, Category, CategoryGroup, MatchField, RuleSource, User from ..schemas import ( CategoryCreate, CategoryGroupCreate, CategoryGroupOut, CategoryGroupUpdate, CategoryOut, CategoryUpdate, FlaggedKeywordOut, KeywordCreate, KeywordOut, KeywordUpdate, TransferKeywordCreate, TransferKeywordOut, ) from ..services.csv_import import normalize_text router = APIRouter(prefix="/categories", tags=["categories"]) # Ab wann ein manuelles Schlagwort als „stimmt vielleicht nicht" markiert wird: # mindestens so viele Anwendungen UND mindestens dieser Anteil wegkorrigiert. FLAG_MIN_APPLIED = 5 FLAG_RATIO_DEN = 3 # >= 1/3 (jede dritte) def group_out(g: CategoryGroup, keyword_counts: dict[int, int] | None = None) -> CategoryGroupOut: cats = sorted(g.categories, key=lambda c: (c.position, c.name.lower())) out = [] for c in cats: eintrag = CategoryOut.model_validate(c) if keyword_counts is not None: eintrag.keyword_count = keyword_counts.get(c.id, 0) out.append(eintrag) return CategoryGroupOut( id=g.id, name=g.name, kind=g.kind, icon=g.icon, color=g.color, position=g.position, is_builtin=g.is_builtin, categories=out, ) def _keyword_counts(db: Session, user: User) -> dict[int, int]: """Schlagwörter je Kategorie – eine Abfrage für die ganze Liste. Die Übersicht markiert damit die Kategorien, für die schon etwas hinterlegt ist; ein Abruf je Kategorie waere dafuer unverhaeltnismaessig.""" zeilen = ( db.query(CategorizationRule.category_id, func.count(CategorizationRule.id)) .filter( CategorizationRule.owner_id == user.id, CategorizationRule.source == RuleSource.manual, CategorizationRule.is_transfer.is_(False), CategorizationRule.category_id.isnot(None), ) .group_by(CategorizationRule.category_id) .all() ) return {cat_id: anzahl for cat_id, anzahl in zeilen} def _next_position(db: Session, model, **filters) -> int: q = db.query(model.position) for k, v in filters.items(): q = q.filter(getattr(model, k) == v) row = q.order_by(model.position.desc()).first() return (row[0] + 1) if row else 0 @router.get("", response_model=list[CategoryGroupOut]) def list_categories(db: Session = Depends(get_db), user: User = Depends(get_current_user)): groups = ( db.query(CategoryGroup) .filter(CategoryGroup.owner_id == user.id) .order_by(CategoryGroup.position, CategoryGroup.name) .all() ) counts = _keyword_counts(db, user) return [group_out(g, counts) for g in groups] # --- Gruppen --------------------------------------------------------------- @router.post("/groups", response_model=CategoryGroupOut, status_code=status.HTTP_201_CREATED) def create_group( payload: CategoryGroupCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user) ): g = CategoryGroup( owner_id=user.id, name=payload.name.strip(), kind=payload.kind, icon=payload.icon, color=payload.color, position=_next_position(db, CategoryGroup, owner_id=user.id), ) db.add(g) db.commit() db.refresh(g) return group_out(g) @router.patch("/groups/{group_id}", response_model=CategoryGroupOut) def update_group( group_id: int, payload: CategoryGroupUpdate, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): g = get_owned(db, CategoryGroup, group_id, user) for key, value in payload.model_dump(exclude_unset=True).items(): if value is not None: setattr(g, key, value) db.commit() db.refresh(g) return group_out(g) @router.delete("/groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_group( group_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user) ): g = get_owned(db, CategoryGroup, group_id, user) db.delete(g) db.commit() # --- Kategorien ------------------------------------------------------------ @router.post("", response_model=CategoryOut, status_code=status.HTTP_201_CREATED) def create_category( payload: CategoryCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user) ): group = get_owned(db, CategoryGroup, payload.group_id, user) cat = Category( owner_id=user.id, group_id=group.id, name=payload.name.strip(), type=group.kind, # erbt die Art der Gruppe icon=payload.icon, color=payload.color, position=_next_position(db, Category, owner_id=user.id, group_id=group.id), ) db.add(cat) db.commit() db.refresh(cat) return CategoryOut.model_validate(cat) @router.patch("/{category_id}", response_model=CategoryOut) def update_category( category_id: int, payload: CategoryUpdate, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): cat = get_owned(db, Category, category_id, user) data = payload.model_dump(exclude_unset=True) new_group_id = data.pop("group_id", None) if new_group_id is not None: group = get_owned(db, CategoryGroup, new_group_id, user) cat.group_id = group.id cat.type = group.kind # Art an die neue Gruppe angleichen for key, value in data.items(): if value is not None or key == "is_archived": setattr(cat, key, value) db.commit() db.refresh(cat) return CategoryOut.model_validate(cat) @router.delete("/{category_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_category( category_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user) ): cat = get_owned(db, Category, category_id, user) db.delete(cat) db.commit() # --- Schlagwörter je Kategorie (manuelle Regeln, Feld = Empfänger ODER Zweck) --- def _keyword_out(rule: CategorizationRule) -> KeywordOut: return KeywordOut(id=rule.id, category_id=rule.category_id, word=rule.pattern, amount_cents=rule.amount_cents) def _owned_manual_rule(db: Session, rule_id: int, user: User) -> CategorizationRule: rule = db.get(CategorizationRule, rule_id) if rule is None or rule.owner_id != user.id or rule.source != RuleSource.manual: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Nicht gefunden") return rule def _conflict(db: Session, clash: CategorizationRule) -> HTTPException: """Klare Meldung, wenn ein Schlagwort schon woanders (Kategorie/Transfer) hängt.""" if clash.is_transfer: wo = "als Transfer-Regel" else: cat = db.get(Category, clash.category_id) wo = f"der Kategorie „{cat.name if cat else '?'}“" return HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"Schlagwort ist bereits {wo} zugeordnet.", ) def _find_any_rule( db: Session, user: User, pattern: str, kind: str, amount_cents: int | None = None, exclude_id: int | None = None, ): """Sucht ein manuelles Schlagwort gleicher Art (kind) UND gleichen Betrags. Dasselbe Wort darf je einmal für Ausgabe, Einnahme und Transfer existieren – und innerhalb einer Art mehrfach mit verschiedenen Beträgen: Im Personal- restaurant zahlt man mal 5.00, mal 7.50. Ohne Betrag ist es der Auffang für alle übrigen Beträge.""" q = db.query(CategorizationRule).filter( CategorizationRule.owner_id == user.id, CategorizationRule.match_field == MatchField.any, CategorizationRule.pattern == pattern, CategorizationRule.kind == kind, ) q = q.filter( CategorizationRule.amount_cents.is_(None) if amount_cents is None else CategorizationRule.amount_cents == amount_cents ) if exclude_id is not None: q = q.filter(CategorizationRule.id != exclude_id) return q.first() @router.get("/keywords/flagged", response_model=list[FlaggedKeywordOut]) def list_flagged_keywords(db: Session = Depends(get_db), user: User = Depends(get_current_user)): """Manuelle Schlagwörter, die auffällig oft wegkorrigiert werden.""" rules = ( db.query(CategorizationRule) .filter( CategorizationRule.owner_id == user.id, CategorizationRule.source == RuleSource.manual, CategorizationRule.confirmed.is_(False), CategorizationRule.applied_count >= FLAG_MIN_APPLIED, CategorizationRule.overridden_count * FLAG_RATIO_DEN >= CategorizationRule.applied_count, ) .order_by(CategorizationRule.overridden_count.desc()) .all() ) names = {c.id: c.name for c in db.query(Category).filter(Category.owner_id == user.id)} return [ FlaggedKeywordOut( id=r.id, category_id=r.category_id, category_name="→ Transfer" if r.is_transfer else names.get(r.category_id, "?"), word=r.pattern, applied_count=r.applied_count, overridden_count=r.overridden_count, ) for r in rules ] @router.post("/keywords/{rule_id}/confirm", status_code=status.HTTP_204_NO_CONTENT) def confirm_keyword(rule_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): """„Behalten" – nicht mehr nachfragen, obwohl korrigiert wird.""" rule = _owned_manual_rule(db, rule_id, user) rule.confirmed = True db.commit() @router.patch("/keywords/{rule_id}", response_model=KeywordOut) def update_keyword( rule_id: int, payload: KeywordUpdate, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): """Schlagwort ändern – Wort und optional Betrag. Ändert sich das Muster, werden die Korrektur-Zähler zurückgesetzt.""" rule = _owned_manual_rule(db, rule_id, user) pattern = normalize_text(payload.word)[:255] if not pattern: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Leeres Schlagwort") neuer_betrag = ( payload.amount_cents if "amount_cents" in payload.model_fields_set else rule.amount_cents ) if pattern != rule.pattern or neuer_betrag != rule.amount_cents: clash = _find_any_rule(db, user, pattern, rule.kind, neuer_betrag, exclude_id=rule.id) if clash is not None: raise _conflict(db, clash) if pattern != rule.pattern: rule.pattern = pattern rule.applied_count = 0 rule.overridden_count = 0 rule.confirmed = False # Betrag nur anfassen, wenn er mitgeschickt wurde. Ausdrücklich null löscht # ihn (das leere Betragsfeld im Popup), ein Weglassen lässt ihn stehen. if "amount_cents" in payload.model_fields_set: rule.amount_cents = payload.amount_cents db.commit() db.refresh(rule) return _keyword_out(rule) @router.delete("/keywords/{rule_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_keyword(rule_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): rule = _owned_manual_rule(db, rule_id, user) db.delete(rule) db.commit() @router.get("/{category_id}/keywords", response_model=list[KeywordOut]) def list_keywords(category_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): cat = get_owned(db, Category, category_id, user) rules = ( db.query(CategorizationRule) .filter( CategorizationRule.owner_id == user.id, CategorizationRule.source == RuleSource.manual, CategorizationRule.category_id == cat.id, ) .order_by(CategorizationRule.pattern) .all() ) return [_keyword_out(r) for r in rules] @router.post("/{category_id}/keywords", response_model=KeywordOut, status_code=status.HTTP_201_CREATED) def add_keyword( category_id: int, payload: KeywordCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): cat = get_owned(db, Category, category_id, user) pattern = normalize_text(payload.word)[:255] if not pattern: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Leeres Schlagwort") kind = cat.type.value # "expense" | "income" – Konflikt nur innerhalb derselben Art # Konflikt nur bei gleichem Wort UND gleichem Betrag: „sv = 5.00" und # „sv = 7.50" sind zwei gültige Regeln derselben Kategorie. existing = _find_any_rule(db, user, pattern, kind, payload.amount_cents) if existing is not None: if not existing.is_transfer and existing.category_id == cat.id: return _keyword_out(existing) # schon identisch vorhanden – unverändert zurück raise _conflict(db, existing) rule = CategorizationRule( owner_id=user.id, match_field=MatchField.any, pattern=pattern, category_id=cat.id, amount_cents=payload.amount_cents, kind=kind, source=RuleSource.manual, priority=10, ) db.add(rule) db.commit() db.refresh(rule) return _keyword_out(rule) # --- Transfer-Schlagwörter (Text -> Transfer, Zielkonto aus Konto-Texten) ------ def _transfer_keyword_out(rule: CategorizationRule) -> TransferKeywordOut: return TransferKeywordOut( id=rule.id, word=rule.pattern, amount_cents=rule.amount_cents, target_account_id=rule.target_account_id, ) @router.get("/transfer-keywords", response_model=list[TransferKeywordOut]) def list_transfer_keywords(db: Session = Depends(get_db), user: User = Depends(get_current_user)): rules = ( db.query(CategorizationRule) .filter( CategorizationRule.owner_id == user.id, CategorizationRule.source == RuleSource.manual, CategorizationRule.is_transfer.is_(True), ) .order_by(CategorizationRule.pattern) .all() ) return [_transfer_keyword_out(r) for r in rules] @router.post("/transfer-keywords", response_model=TransferKeywordOut, status_code=status.HTTP_201_CREATED) def add_transfer_keyword( payload: TransferKeywordCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user) ): pattern = normalize_text(payload.word)[:255] if not pattern: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Leeres Schlagwort") target_id = None if payload.target_account_id is not None: target_id = get_owned(db, Account, payload.target_account_id, user).id # Gleiches Wort existiert schon als Transfer-Regel -> aktualisieren (Betrag/Zielkonto). existing = _find_any_rule(db, user, pattern, "transfer", payload.amount_cents) if existing is not None: existing.amount_cents = payload.amount_cents existing.target_account_id = target_id db.commit() db.refresh(existing) return _transfer_keyword_out(existing) rule = CategorizationRule( owner_id=user.id, match_field=MatchField.any, pattern=pattern, category_id=None, amount_cents=payload.amount_cents, is_transfer=True, target_account_id=target_id, kind="transfer", source=RuleSource.manual, priority=10, ) db.add(rule) db.commit() db.refresh(rule) return _transfer_keyword_out(rule)