feat: Schlagwörter je Kategorie + auf-/zuklappbares Kategorie-Dropdown
- Manuelle Schlagwörter je Kategorie (Popup mit Chips, Doppelklick zum Bearbeiten) - Treffer über Empfänger ODER Verwendungszweck; längstes Schlagwort gewinnt; Kind-Filter (Ausgabe/Einnahme); wirken nur bei künftigen Importen - Lernende Korrektur-Erkennung: ab 5 Anwendungen und >= 1/3 Korrekturen erscheint eine Prüf-Meldung in den Einstellungen (Behalten/Entfernen) - Neue CategorySelect-Komponente (Baum + Suche, per Portal) ersetzt die nativen optgroup-Selects in Buchungen und Import-Prüfansicht - Migration 0006 (source/applied_count/overridden_count/confirmed, match_field any) - Endpunkte, i18n DE/EN, Tests (test_keywords: 7 neu, gesamt 27 grün) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,12 +3,12 @@
|
||||
Eine Kategorie erbt ihre Art (Einnahme/Ausgabe) von ihrer Gruppe.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
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 Category, CategoryGroup, User
|
||||
from ..models import CategorizationRule, Category, CategoryGroup, MatchField, RuleSource, User
|
||||
from ..schemas import (
|
||||
CategoryCreate,
|
||||
CategoryGroupCreate,
|
||||
@@ -16,10 +16,20 @@ from ..schemas import (
|
||||
CategoryGroupUpdate,
|
||||
CategoryOut,
|
||||
CategoryUpdate,
|
||||
FlaggedKeywordOut,
|
||||
KeywordCreate,
|
||||
KeywordOut,
|
||||
KeywordUpdate,
|
||||
)
|
||||
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()))
|
||||
@@ -152,3 +162,152 @@ def delete_category(
|
||||
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)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@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=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),
|
||||
):
|
||||
"""Wort eines Schlagworts ändern (Inline-Bearbeitung im Popup). Da sich das
|
||||
Muster ändert, 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 = (
|
||||
db.query(CategorizationRule)
|
||||
.filter(
|
||||
CategorizationRule.owner_id == user.id,
|
||||
CategorizationRule.match_field == MatchField.any,
|
||||
CategorizationRule.pattern == pattern,
|
||||
CategorizationRule.id != rule.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if clash is not None:
|
||||
other = db.get(Category, clash.category_id)
|
||||
other_name = other.name if other else "?"
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Schlagwort ist bereits der Kategorie „{other_name}“ zugeordnet.",
|
||||
)
|
||||
rule.pattern = pattern
|
||||
rule.applied_count = 0
|
||||
rule.overridden_count = 0
|
||||
rule.confirmed = False
|
||||
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 = (
|
||||
db.query(CategorizationRule)
|
||||
.filter(
|
||||
CategorizationRule.owner_id == user.id,
|
||||
CategorizationRule.match_field == MatchField.any,
|
||||
CategorizationRule.pattern == pattern,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing is not None:
|
||||
if existing.category_id == cat.id:
|
||||
return _keyword_out(existing) # schon vorhanden – unverändert zurück
|
||||
other = db.get(Category, existing.category_id)
|
||||
other_name = other.name if other else "?"
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Schlagwort ist bereits der Kategorie „{other_name}“ zugeordnet.",
|
||||
)
|
||||
rule = CategorizationRule(
|
||||
owner_id=user.id, match_field=MatchField.any, pattern=pattern,
|
||||
category_id=cat.id, source=RuleSource.manual, priority=10,
|
||||
)
|
||||
db.add(rule)
|
||||
db.commit()
|
||||
db.refresh(rule)
|
||||
return _keyword_out(rule)
|
||||
|
||||
Reference in New Issue
Block a user