"""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, AmountMode, BudgetTreatment, CategoryKind, IdentifierKind, ImportStatus, 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 must_change_password_reason: str | None = None totp_enabled: bool = False created_at: datetime # Kein Datenbankfeld: „Richtlinie verlangt MFA und dieser Benutzer hat # keins". Wird beim Ausliefern gesetzt (siehe deps.user_out), damit das # Frontend ohne zweite Abfrage weiss, wohin es umleiten muss. mfa_setup_required: bool = False 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 # --- Passwortrichtlinie ----------------------------------------------------- class PolicyOut(BaseModel): min_length: int require_upper: bool require_lower: bool require_digit: bool require_special: bool mfa_required: bool class PolicyIn(BaseModel): min_length: int = Field(ge=8, le=128) require_upper: bool = False require_lower: bool = False require_digit: bool = False require_special: bool = False mfa_required: bool = False # --- Anmeldung / Zwei-Faktor ------------------------------------------------ class LoginOut(BaseModel): """Antwort auf ``POST /auth/login``. ``status`` ist ``"ok"`` (angemeldet, ``user`` gesetzt) oder ``"2fa_required"`` (Passwort stimmt, es fehlt noch der Code – ``user`` bleibt leer).""" status: str = "ok" user: UserOut | None = None class TwoFactorVerify(BaseModel): code: str = Field(min_length=1, max_length=64) recovery: bool = False class TwoFactorSetupOut(BaseModel): secret: str otpauth_uri: str qr_png: str # data:-URI, direkt in verwendbar class TwoFactorActivate(BaseModel): code: str = Field(min_length=1, max_length=16) class TwoFactorPassword(BaseModel): password: str = Field(min_length=1, max_length=1024) class RecoveryCodesOut(BaseModel): codes: list[str] class TwoFactorStatusOut(BaseModel): enabled: bool recovery_codes_left: int # --- 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 # --- Budget: geplante Einnahmen -------------------------------------------- class PlannedIncomeCreate(BaseModel): label: str = Field(min_length=1, max_length=120) category_id: int | None = None target_account_id: int | None = None planned_cents: int = Field(ge=0) class PlannedIncomeUpdate(BaseModel): label: str | None = Field(default=None, min_length=1, max_length=120) category_id: int | None = None target_account_id: int | None = None planned_cents: int | None = Field(default=None, ge=0) class PlannedIncomeOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: int label: str category_id: int | None target_account_id: int | None planned_cents: int position: int # --- CSV-Import ------------------------------------------------------------ class ImportTemplateCreate(BaseModel): name: str = Field(min_length=1, max_length=120) delimiter: str = ";" decimal_sep: str = "," date_format: str = "%d.%m.%Y" encoding: str = "utf-8" has_header: bool = True skip_rows: int = 0 amount_mode: AmountMode = AmountMode.signed column_map: dict[str, int | None] = Field(default_factory=dict) class ImportTemplateUpdate(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=120) delimiter: str | None = None decimal_sep: str | None = None date_format: str | None = None encoding: str | None = None has_header: bool | None = None skip_rows: int | None = None amount_mode: AmountMode | None = None column_map: dict[str, int | None] | None = None class ImportTemplateOut(BaseModel): id: int name: str delimiter: str decimal_sep: str date_format: str encoding: str has_header: bool skip_rows: int amount_mode: AmountMode column_map: dict[str, int | None] class ImportBatchOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: int account_id: int template_id: int | None filename: str row_count: int imported_count: int status: ImportStatus created_at: datetime class ImportCommitIn(BaseModel): account_id: int template_id: int | None = None filename: str = "" rows: list[dict] = Field(default_factory=list) # --- Schlagwörter (manuelle Kategorisierungs-Regeln) ----------------------- class KeywordCreate(BaseModel): word: str = Field(min_length=1, max_length=255) amount_cents: int | None = Field(default=None, gt=0) class KeywordUpdate(BaseModel): word: str = Field(min_length=1, max_length=255) amount_cents: int | None = Field(default=None, gt=0) class KeywordOut(BaseModel): id: int category_id: int word: str amount_cents: int | None = None class TransferKeywordCreate(BaseModel): word: str = Field(min_length=1, max_length=255) amount_cents: int | None = Field(default=None, gt=0) class TransferKeywordOut(BaseModel): id: int word: str amount_cents: int | None = None class FlaggedKeywordOut(BaseModel): id: int category_id: int | None = None category_name: str word: str applied_count: int overridden_count: int