Files
certain/backend/app/routers/accounts.py
T
GuidoandClaude Opus 4.8 1f03483e5d fix: Konto umbenennen scheitert nicht mehr an Identifikator-UNIQUE
_set_identifiers glich die Liste per clear()+neu-Anlegen ab; SQLAlchemy fügte
dabei die (unveränderten) Identifikatoren vor dem Löschen ein und verletzte so
UNIQUE(account_id, kind, value_norm) -> Internal Server Error beim Speichern.
Jetzt als Diff: unveränderte behalten, nur fehlende hinzufügen / überzählige
entfernen. Betrifft alle Identifikator-Arten (IBAN/Nummer/Text).

Regressionstest: Umbenennen mit unverändertem Identifikator + Entfernen (76 grün).

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

156 lines
5.5 KiB
Python

"""Konten inkl. Erkennungs-Identifikatoren. Alles owner-gescoped."""
from datetime import date
from fastapi import APIRouter, Depends, status
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, get_owned
from ..models import Account, AccountIdentifier, IdentifierKind, User
from ..schemas import AccountCreate, AccountIdentifierOut, AccountOut, AccountUpdate
from ..services import balances
from ..services.csv_import import normalize_text
from .settings import BASE_CURRENCY_KEY, get_user_setting
router = APIRouter(prefix="/accounts", tags=["accounts"])
def normalize_identifier(value: str, kind: IdentifierKind) -> str:
"""Vereinheitlicht einen Identifikator für den Abgleich. IBAN/Nummer:
Großbuchstaben, nur alphanumerisch. Freier Text: klein, getrimmt (für die
Zielkonto-Erkennung im Buchungstext)."""
if kind == IdentifierKind.text:
return normalize_text(value)
return "".join(ch for ch in value.upper() if ch.isalnum())
def base_currency(db: Session, user: User) -> str:
return get_user_setting(db, user, BASE_CURRENCY_KEY) or "EUR"
def to_out(db: Session, acc: Account, base: str, on: date) -> AccountOut:
return AccountOut(
id=acc.id,
name=acc.name,
type=acc.type,
currency=acc.currency,
icon=acc.icon,
color=acc.color,
opening_balance_cents=acc.opening_balance_cents,
opening_date=acc.opening_date,
is_archived=acc.is_archived,
position=acc.position,
identifiers=[
AccountIdentifierOut(id=i.id, kind=i.kind, value=i.value) for i in acc.identifiers
],
balance_cents=balances.account_balance_cents(db, acc),
balance_base_cents=balances.account_balance_base_cents(db, acc, base, on),
)
def _set_identifiers(acc: Account, items) -> None:
"""Gleicht die Identifikator-Liste auf den Ziel-Zustand ab (dedupliziert nach
Art + Normalform). Bewusst als Diff (unveränderte behalten, nur fehlende
hinzufügen / überzählige entfernen): ein einfaches clear()+neu-Anlegen würde
beim Speichern zuerst einfügen und dann löschen und so die UNIQUE-Bedingung
(account_id, kind, value_norm) verletzen (Fehler beim Umbenennen)."""
desired: dict[tuple[str, str], tuple] = {}
for it in items:
norm = normalize_identifier(it.value, it.kind)
if not norm:
continue
key = (it.kind.value, norm)
desired.setdefault(key, (it.kind, it.value.strip(), norm))
keep: set[tuple[str, str]] = set()
for existing in list(acc.identifiers):
key = (existing.kind.value, existing.value_norm)
if key in desired:
keep.add(key)
existing.value = desired[key][1] # Original-Schreibweise ggf. angleichen
else:
acc.identifiers.remove(existing)
for key, (kind, value, norm) in desired.items():
if key not in keep:
acc.identifiers.append(AccountIdentifier(kind=kind, value=value, value_norm=norm))
@router.get("", response_model=list[AccountOut])
def list_accounts(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
base = base_currency(db, user)
today = date.today()
accounts = (
db.query(Account)
.filter(Account.owner_id == user.id)
.order_by(Account.position, Account.name)
.all()
)
return [to_out(db, a, base, today) for a in accounts]
@router.post("", response_model=AccountOut, status_code=status.HTTP_201_CREATED)
def create_account(
payload: AccountCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)
):
max_pos = (
db.query(Account.position)
.filter(Account.owner_id == user.id)
.order_by(Account.position.desc())
.first()
)
acc = Account(
owner_id=user.id,
name=payload.name.strip(),
type=payload.type,
currency=payload.currency,
icon=payload.icon,
color=payload.color,
opening_balance_cents=payload.opening_balance_cents,
opening_date=payload.opening_date,
position=(max_pos[0] + 1) if max_pos else 0,
)
_set_identifiers(acc, payload.identifiers)
db.add(acc)
db.commit()
db.refresh(acc)
return to_out(db, acc, base_currency(db, user), date.today())
@router.get("/{account_id}", response_model=AccountOut)
def get_account(
account_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)
):
acc = get_owned(db, Account, account_id, user)
return to_out(db, acc, base_currency(db, user), date.today())
@router.patch("/{account_id}", response_model=AccountOut)
def update_account(
account_id: int,
payload: AccountUpdate,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
acc = get_owned(db, Account, account_id, user)
data = payload.model_dump(exclude_unset=True)
identifiers = data.pop("identifiers", None)
for key, value in data.items():
if value is not None or key in ("opening_date",):
setattr(acc, key, value)
if identifiers is not None:
_set_identifiers(acc, payload.identifiers)
db.commit()
db.refresh(acc)
return to_out(db, acc, base_currency(db, user), date.today())
@router.delete("/{account_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_account(
account_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)
):
acc = get_owned(db, Account, account_id, user)
db.delete(acc)
db.commit()