Files
certain/backend/app/schemas.py
T
GuidoandClaude Opus 5 fc6ee2f345 feat: Postfaecher einrichten und Zahlungs-Mails abholen (Etappe B, Teil 2)
- Verschluesselung fuer Zugangsdaten: Ein IMAP-Passwort laesst sich nicht
  hashen, Certain muss es dem Mailserver vorlegen. Es liegt deshalb mit
  Fernet verschluesselt in der Datenbank, der Schluessel wird per HKDF aus
  session_secret abgeleitet und nirgends gespeichert. Wird das Geheimnis
  getauscht, meldet Certain das verstaendlich statt mit einem Stacktrace.
- IMAP-Abruf ueber imaplib/email (Standardbibliothek). Ordnerliste,
  Verbindungstest mit klarer Fehlermeldung, Absender-Zuordnung auf Konten,
  danach als gelesen markieren oder verschieben.
- Wiedererkennung ueber die Message-ID statt der UID – die vergibt der
  Server beim Verschieben neu.
- Nichts wird stillschweigend gebucht: Eine Mail wird zum Eingangs-Eintrag.
  Fehlt das Zielkonto oder ist ein Feld unsicher, gilt sie als unklar.
- Hintergrund-Thread beim Start, nach dem Muster des EZB-Kursabrufs: ein
  langsames Postfach darf den Dienst nie blockieren.

Getestet gegen einen IMAP-Doppelgaenger, damit die Suite ohne Netz laeuft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 19:58:06 +02:00

595 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Pydantic-Schemas für Anfragen/Antworten (Phase 0: Auth, Benutzer, Einstellungen)."""
from datetime import date as date_t, datetime, time as time_t
from decimal import Decimal
from typing import Literal
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 AccountOrder(BaseModel):
"""Konto-IDs in der gewünschten Reihenfolge."""
ids: list[int]
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
excluded_from_stats: bool = False
# 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"
excluded_from_stats: bool = False
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
excluded_from_stats: 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)
filter_column: int | None = None
filter_value: str | None = None
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
filter_column: int | None = None
filter_value: str | 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]
filter_column: int | None = None
filter_value: str | None = None
class ImportBatchOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
account_id: int
template_id: int | None
preset: str | 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
preset: str | 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)
target_account_id: int | None = None
class TransferKeywordOut(BaseModel):
id: int
word: str
amount_cents: int | None = None
target_account_id: int | None = None
class FlaggedKeywordOut(BaseModel):
id: int
category_id: int | None = None
category_name: str
word: str
applied_count: int
overridden_count: int
# --- Gerätemarken und Eingang ------------------------------------------------
class ApiTokenCreate(BaseModel):
name: str = Field(min_length=1, max_length=80)
default_account_id: int | None = None
class ApiTokenOut(BaseModel):
id: int
name: str
default_account_id: int | None
last_used_at: datetime | None
created_at: datetime | None
model_config = ConfigDict(from_attributes=True)
class ApiTokenCreated(ApiTokenOut):
"""Antwort beim Anlegen – der Klartext ist hier das einzige Mal zu sehen."""
token: str
class QuickBookingIn(BaseModel):
"""Was ein Kurzbefehl schickt. Bewusst genügsam: Betrag reicht."""
amount: Decimal
payee: str | None = Field(default=None, max_length=255)
date: date_t | None = None
account_id: int | None = None
note: str | None = None
# Kennung des Kurzbefehls, damit ein doppelt ausgelöster Aufruf nicht
# zweimal bucht (schlechtes Netz, versehentlicher Doppeltipp).
external_id: str | None = Field(default=None, max_length=255)
kind: Literal["expense", "income"] = "expense"
class InboxItemOut(BaseModel):
id: int
source: str
status: str
account_id: int | None
date: date_t
amount_cents: int
currency: str
payee: str | None
note: str | None
category_id: int | None
transaction_id: int | None
created_at: datetime | None
model_config = ConfigDict(from_attributes=True)
class QuickBookingOut(BaseModel):
"""Kurze Rückmeldung – der Kurzbefehl zeigt sie als Mitteilung an."""
ok: bool = True
message: str
item: InboxItemOut
# --- Postfächer für den Mail-Abruf -------------------------------------------
class MailSourceIn(BaseModel):
name: str = Field(default="", max_length=80)
host: str = Field(min_length=1, max_length=255)
port: int = 993
use_ssl: bool = True
username: str = Field(min_length=1, max_length=255)
# Beim Ändern weglassen, um das gespeicherte Passwort zu behalten.
password: str | None = None
folder: str = "INBOX"
done_folder: str | None = None
mark_seen: bool = True
is_active: bool = True
poll_minutes: int = Field(default=15, ge=5, le=1440)
class MailSourceOut(BaseModel):
id: int
name: str
host: str
port: int
use_ssl: bool
username: str
folder: str
done_folder: str | None
mark_seen: bool
is_active: bool
poll_minutes: int
last_poll_at: datetime | None
last_error: str | None
# Das Passwort verlässt den Server nie – nur die Tatsache, dass eines da ist.
password_set: bool = True
model_config = ConfigDict(from_attributes=True)
class MailSenderIn(BaseModel):
name: str = Field(default="", max_length=80)
from_contains: str | None = Field(default=None, max_length=255)
subject_contains: str | None = Field(default=None, max_length=255)
account_id: int | None = None
class MailSenderOut(MailSenderIn):
id: int
source_id: int
model_config = ConfigDict(from_attributes=True)