Der Schlagwort-Knopf sah bei jeder Kategorie gleich aus - man musste jede einzeln oeffnen, um zu sehen, wo ueberhaupt etwas hinterlegt ist. Belegte Kategorien tragen den Knopf jetzt in Akzentfarbe und mit der Anzahl daneben, der Tooltip nennt sie ausgeschrieben. Die Zahl kommt als keyword_count an der Kategorie mit, ermittelt in einer Aggregat-Abfrage fuer die ganze Liste. Ein Abruf je Kategorie waere dafuer unverhaeltnismaessig gewesen. Gefuellt wird das Feld nur beim Auflisten; beim Anlegen und Aendern einer Gruppe bleibt es bei 0, wo es ohnehin nichts aussagt. Das Popup laedt beim Schliessen die Liste neu, damit die Zahl nach dem Bearbeiten stimmt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
459 lines
12 KiB
Python
459 lines
12 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,
|
||
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 <img src=...> 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
|
||
# Anzahl hinterlegter Schlagwörter. Nur in der Liste gefüllt, damit die
|
||
# Übersicht ohne einen Abruf je Kategorie auskommt.
|
||
keyword_count: int = 0
|
||
|
||
|
||
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
|