- Manuelle Schlagwörter je Kategorie (Popup mit Chips, Doppelklick zum Bearbeiten) - Treffer über Empfänger ODER Verwendungszweck; längstes Schlagwort gewinnt; Kind-Filter (Ausgabe/Einnahme); wirken nur bei künftigen Importen - Lernende Korrektur-Erkennung: ab 5 Anwendungen und >= 1/3 Korrekturen erscheint eine Prüf-Meldung in den Einstellungen (Behalten/Entfernen) - Neue CategorySelect-Komponente (Baum + Suche, per Portal) ersetzt die nativen optgroup-Selects in Buchungen und Import-Prüfansicht - Migration 0006 (source/applied_count/overridden_count/confirmed, match_field any) - Endpunkte, i18n DE/EN, Tests (test_keywords: 7 neu, gesamt 27 grün) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
374 lines
9.3 KiB
Python
374 lines
9.3 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
|
|
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
|
|
|
|
|
|
# --- 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)
|
|
|
|
|
|
class KeywordUpdate(BaseModel):
|
|
word: str = Field(min_length=1, max_length=255)
|
|
|
|
|
|
class KeywordOut(BaseModel):
|
|
id: int
|
|
category_id: int
|
|
word: str
|
|
|
|
|
|
class FlaggedKeywordOut(BaseModel):
|
|
id: int
|
|
category_id: int
|
|
category_name: str
|
|
word: str
|
|
applied_count: int
|
|
overridden_count: int
|