Files
certain/backend/app/routers/categories.py
T
GuidoandClaude Opus 4.8 d1cab19264 feat: gleiches Schlagwort für Einnahme UND Ausgabe erlaubt (Art je Regel)
- CategorizationRule bekommt `kind` (expense|income|transfer); Unique jetzt
  (owner, match_field, pattern, kind) statt ohne kind
- Konfliktprüfung/Anlage (add/update Keyword, Transfer-Keyword, gelernte Regeln)
  arbeiten kind-bewusst: dasselbe Wort je einmal pro Art möglich
- Migration 0009 mit Backfill (kind aus Kategorie-Art bzw. is_transfer)
- Tests angepasst: quer über Arten erlaubt, gleiche Art weiterhin Konflikt (71 grün)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-20 16:37:35 +02:00

395 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 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, exclude_id: int | None = None):
"""Sucht ein manuelles Schlagwort gleicher Art (kind). Dasselbe Wort darf je
einmal für Ausgabe, Einnahme und Transfer existieren – Konflikt nur je Art."""
q = db.query(CategorizationRule).filter(
CategorizationRule.owner_id == user.id,
CategorizationRule.match_field == MatchField.any,
CategorizationRule.pattern == pattern,
CategorizationRule.kind == kind,
)
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")
if pattern != rule.pattern:
clash = _find_any_rule(db, user, pattern, rule.kind, exclude_id=rule.id)
if clash is not None:
raise _conflict(db, clash)
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
existing = _find_any_rule(db, user, pattern, kind)
if existing is not None:
if not existing.is_transfer and existing.category_id == cat.id and existing.amount_cents == payload.amount_cents:
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)
@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")
existing = _find_any_rule(db, user, pattern, "transfer")
if existing is not None:
if existing.is_transfer and existing.amount_cents == payload.amount_cents:
return _transfer_keyword_out(existing)
raise _conflict(db, existing)
rule = CategorizationRule(
owner_id=user.id, match_field=MatchField.any, pattern=pattern,
category_id=None, amount_cents=payload.amount_cents, is_transfer=True,
kind="transfer", source=RuleSource.manual, priority=10,
)
db.add(rule)
db.commit()
db.refresh(rule)
return _transfer_keyword_out(rule)