Files
certain/backend/app/schemas.py
T
GuidoandClaude Opus 4.8 8ed4bf5254 feat: Phase 2 – Budget-Engine + Direktzugriff (http://IP:Port)
Budget:
- Modelle + Migration 0003: budget_template_lines, planned_income_lines,
  budget_periods, budget_period_lines.
- services/budget: Perioden-Grenzen (einstellbarer Starttag, Label nach Endmonat),
  Standard-Vorlage vs. Monats-Override (Snapshot-on-Customize), Soll/Ist inkl.
  budgetierter Sparen-Transfers, geplante Einnahmen (Soll/Ist).
- routers/budgets: Vorlage, geplante Einnahmen (CRUD), Monats-Soll/Ist,
  Monats-Override, Zurücksetzen. pytest: Perioden, Soll/Ist, Override/Reset, Einnahmen.
- Frontend: Budget-Seite (Monat + Standard-Budget), Fortschrittsbalken grün/rot,
  Monats-Navigation, editierbare Soll-Beträge; Sparen-Option im Transfer-Formular.

Deployment-Fix (Nutzer-Anforderung: http://IP:Port muss funktionieren):
- Dienst bindet an APP_BIND_HOST=0.0.0.0 (direkt im LAN erreichbar).
- APP_COOKIE_SECURE=false als Standard (sonst kein Login über reines HTTP);
  für reinen HTTPS-Betrieb umstellbar. Doku + install.sh entsprechend angepasst.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 19:16:27 +02:00

286 lines
7.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,
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
# --- 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