Backend: - Modelle + Migration 0002: accounts, account_identifiers, category_groups, categories, transactions, exchange_rates (alle owner-gescoped). - EZB-Kursdienst (90-Tage/Historie) + Backfill-Skript; Umrechnung EUR<->CHF zum Buchungsdatum mit lokalem Cache und manueller Übersteuerung; EUR-Pivot (amount_eur_cents) eingefroren. - Kontostände + Netto-Vermögen (Umrechnung in Hauptwährung). - Router: accounts (inkl. IBAN-Identifikatoren), categories (Gruppen+Kategorien), transactions (Ausgabe/Einnahme/Transfer, cross-currency), rates. - Gruppierte Standard-Kategorien je Nutzer beim Anlegen. - pytest-Suite: Währung, Salden, Cross-Currency-Transfer, Nutzer-Isolation. Frontend: - Seiten Konten, Kategorien, Buchungen; Icon-Chip, Geldbetrag mit Umrechnung, Icon-/Farbwähler, Modal; erweitertes SVG-Icon-Set (Konto-/Kategorie-Icons). - Währungs-/Datumsformatierung (EUR de-DE, CHF de-CH), i18n DE/EN erweitert. - Live-Umrechnungs-Vorschau im Transfer-Formular. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
258 lines
6.6 KiB
Python
258 lines
6.6 KiB
Python
"""Pydantic-Schemas für Anfragen/Antworten (Phase 0: Auth, Benutzer, Einstellungen)."""
|
|
|
|
from datetime import date as date_t, datetime, time as time_t
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
|
|
from .models import (
|
|
AccountType,
|
|
BudgetTreatment,
|
|
CategoryKind,
|
|
IdentifierKind,
|
|
Role,
|
|
TxnType,
|
|
)
|
|
|
|
ALLOWED_CURRENCIES = {"EUR", "CHF"}
|
|
|
|
|
|
def _check_currency(v: str) -> str:
|
|
v = v.upper()
|
|
if v not in ALLOWED_CURRENCIES:
|
|
raise ValueError("Währung muss EUR oder CHF sein")
|
|
return v
|
|
|
|
|
|
class UserOut(BaseModel):
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
id: int
|
|
username: str
|
|
role: Role
|
|
display_name: str | None = None
|
|
is_active: bool
|
|
must_change_password: bool
|
|
created_at: datetime
|
|
|
|
|
|
class UserCreate(BaseModel):
|
|
username: str = Field(min_length=1, max_length=64)
|
|
password: str = Field(min_length=1, max_length=1024)
|
|
role: Role = Role.user
|
|
display_name: str | None = Field(default=None, max_length=120)
|
|
|
|
|
|
class UserUpdate(BaseModel):
|
|
password: str | None = Field(default=None, min_length=1, max_length=1024)
|
|
role: Role | None = None
|
|
display_name: str | None = Field(default=None, max_length=120)
|
|
is_active: bool | None = None
|
|
|
|
|
|
class PasswordChange(BaseModel):
|
|
current_password: str = Field(min_length=1, max_length=1024)
|
|
new_password: str = Field(min_length=1, max_length=1024)
|
|
|
|
|
|
class SettingOut(BaseModel):
|
|
key: str
|
|
value: str | None = None
|
|
|
|
|
|
# --- Konten ----------------------------------------------------------------
|
|
|
|
|
|
class AccountIdentifierIn(BaseModel):
|
|
kind: IdentifierKind = IdentifierKind.iban
|
|
value: str = Field(min_length=1, max_length=64)
|
|
|
|
|
|
class AccountIdentifierOut(BaseModel):
|
|
id: int
|
|
kind: IdentifierKind
|
|
value: str
|
|
|
|
|
|
class AccountCreate(BaseModel):
|
|
name: str = Field(min_length=1, max_length=120)
|
|
type: AccountType = AccountType.girokonto
|
|
currency: str = "EUR"
|
|
icon: str = "wallet"
|
|
color: str = "#3f51b5"
|
|
opening_balance_cents: int = 0
|
|
opening_date: date_t | None = None
|
|
identifiers: list[AccountIdentifierIn] = Field(default_factory=list)
|
|
|
|
@field_validator("currency")
|
|
@classmethod
|
|
def _cur(cls, v: str) -> str:
|
|
return _check_currency(v)
|
|
|
|
|
|
class AccountUpdate(BaseModel):
|
|
name: str | None = Field(default=None, min_length=1, max_length=120)
|
|
type: AccountType | None = None
|
|
currency: str | None = None
|
|
icon: str | None = None
|
|
color: str | None = None
|
|
opening_balance_cents: int | None = None
|
|
opening_date: date_t | None = None
|
|
is_archived: bool | None = None
|
|
position: int | None = None
|
|
# Wenn gesetzt, ersetzt die komplette Identifikator-Liste.
|
|
identifiers: list[AccountIdentifierIn] | None = None
|
|
|
|
@field_validator("currency")
|
|
@classmethod
|
|
def _cur(cls, v: str | None) -> str | None:
|
|
return _check_currency(v) if v is not None else None
|
|
|
|
|
|
class AccountOut(BaseModel):
|
|
id: int
|
|
name: str
|
|
type: AccountType
|
|
currency: str
|
|
icon: str
|
|
color: str
|
|
opening_balance_cents: int
|
|
opening_date: date_t | None
|
|
is_archived: bool
|
|
position: int
|
|
identifiers: list[AccountIdentifierOut]
|
|
balance_cents: int
|
|
balance_base_cents: int
|
|
|
|
|
|
# --- Kategorien ------------------------------------------------------------
|
|
|
|
|
|
class CategoryOut(BaseModel):
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
id: int
|
|
group_id: int
|
|
name: str
|
|
type: CategoryKind
|
|
icon: str
|
|
color: str
|
|
position: int
|
|
is_builtin: bool
|
|
is_archived: bool
|
|
|
|
|
|
class CategoryGroupOut(BaseModel):
|
|
id: int
|
|
name: str
|
|
kind: CategoryKind
|
|
icon: str
|
|
color: str
|
|
position: int
|
|
is_builtin: bool
|
|
categories: list[CategoryOut]
|
|
|
|
|
|
class CategoryGroupCreate(BaseModel):
|
|
name: str = Field(min_length=1, max_length=120)
|
|
kind: CategoryKind = CategoryKind.expense
|
|
icon: str = "list"
|
|
color: str = "#6b7480"
|
|
|
|
|
|
class CategoryGroupUpdate(BaseModel):
|
|
name: str | None = Field(default=None, min_length=1, max_length=120)
|
|
icon: str | None = None
|
|
color: str | None = None
|
|
position: int | None = None
|
|
|
|
|
|
class CategoryCreate(BaseModel):
|
|
group_id: int
|
|
name: str = Field(min_length=1, max_length=120)
|
|
icon: str = "more-horizontal"
|
|
color: str = "#6b7480"
|
|
|
|
|
|
class CategoryUpdate(BaseModel):
|
|
name: str | None = Field(default=None, min_length=1, max_length=120)
|
|
group_id: int | None = None
|
|
icon: str | None = None
|
|
color: str | None = None
|
|
position: int | None = None
|
|
is_archived: bool | None = None
|
|
|
|
|
|
# --- Transaktionen ---------------------------------------------------------
|
|
|
|
|
|
class TransactionCreate(BaseModel):
|
|
type: TxnType
|
|
date: date_t
|
|
time: time_t | None = None
|
|
account_id: int
|
|
to_account_id: int | None = None
|
|
amount_cents: int = Field(gt=0)
|
|
to_amount_cents: int | None = Field(default=None, gt=0)
|
|
fx_rate: float | None = Field(default=None, gt=0)
|
|
category_id: int | None = None
|
|
budget_treatment: BudgetTreatment | None = None
|
|
budget_category_id: int | None = None
|
|
payee: str | None = Field(default=None, max_length=255)
|
|
description: str | None = Field(default=None, max_length=255)
|
|
notes: str | None = None
|
|
|
|
|
|
class TransactionUpdate(BaseModel):
|
|
type: TxnType | None = None
|
|
date: date_t | None = None
|
|
time: time_t | None = None
|
|
account_id: int | None = None
|
|
to_account_id: int | None = None
|
|
amount_cents: int | None = Field(default=None, gt=0)
|
|
to_amount_cents: int | None = Field(default=None, gt=0)
|
|
fx_rate: float | None = Field(default=None, gt=0)
|
|
category_id: int | None = None
|
|
budget_treatment: BudgetTreatment | None = None
|
|
budget_category_id: int | None = None
|
|
payee: str | None = Field(default=None, max_length=255)
|
|
description: str | None = Field(default=None, max_length=255)
|
|
notes: str | None = None
|
|
|
|
|
|
class TransactionOut(BaseModel):
|
|
id: int
|
|
type: TxnType
|
|
date: date_t
|
|
time: time_t | None
|
|
account_id: int
|
|
to_account_id: int | None
|
|
account_currency: str
|
|
amount_cents: int
|
|
to_amount_cents: int | None
|
|
fx_rate: float | None
|
|
amount_eur_cents: int
|
|
amount_base_cents: int
|
|
category_id: int | None
|
|
budget_treatment: BudgetTreatment | None
|
|
budget_category_id: int | None
|
|
payee: str | None
|
|
description: str | None
|
|
notes: str | None
|
|
|
|
|
|
class RateStatus(BaseModel):
|
|
currency: str
|
|
count: int
|
|
min_date: date_t | None
|
|
max_date: date_t | None
|
|
latest_rate: float | None
|
|
|
|
|
|
class ConversionOut(BaseModel):
|
|
amount_cents: int
|
|
from_currency: str
|
|
to_currency: str
|
|
date: date_t
|
|
converted_cents: int | None
|
|
rate: float | None
|