_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>
156 lines
5.5 KiB
Python
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()
|