Bisher oeffnete der Doppelklick ein Eingabefeld im Chip selbst. Dort war nur
Platz fuer das Wort - der Betrag liess sich gar nicht aendern, weil die
Inline-Bearbeitung bewusst nur { word } geschickt hat.
Jetzt laedt der Doppelklick Wort und Betrag in dieselbe Zeile, in der auch
neue Schlagwoerter entstehen. Der Knopf wechselt auf ein Haekchen, daneben
steht Abbrechen (auch per Escape), und der bearbeitete Chip ist markiert.
Gespeichert wird per PATCH mit ausdruecklichem amount_cents, damit ein
geleertes Betragsfeld den Betrag auch wirklich loescht; ein geparstes 0 wird
zu null, weil das Schema gt=0 verlangt. Ueber POST ginge es nicht: das
antwortet bei vorhandenem Wort mit geaendertem Betrag mit 409.
Tests decken jetzt ab, dass Betrag mitgeaendert wird, beim Weglassen stehen
bleibt und bei ausdruecklichem null verschwindet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
364 lines
13 KiB
Python
364 lines
13 KiB
Python
"""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.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) -> CategoryGroupOut:
|
||
cats = sorted(g.categories, key=lambda c: (c.position, c.name.lower()))
|
||
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=[CategoryOut.model_validate(c) for c in cats],
|
||
)
|
||
|
||
|
||
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()
|
||
)
|
||
return [group_out(g) 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, exclude_id: int | None = None):
|
||
q = db.query(CategorizationRule).filter(
|
||
CategorizationRule.owner_id == user.id,
|
||
CategorizationRule.match_field == MatchField.any,
|
||
CategorizationRule.pattern == pattern,
|
||
)
|
||
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, 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")
|
||
existing = _find_any_rule(db, user, pattern)
|
||
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,
|
||
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)
|
||
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,
|
||
source=RuleSource.manual, priority=10,
|
||
)
|
||
db.add(rule)
|
||
db.commit()
|
||
db.refresh(rule)
|
||
return _transfer_keyword_out(rule)
|