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>
286 lines
7.3 KiB
Python
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
|