- Repo-Struktur für Backend, Frontend, Dokumentation und Gitea-Workflows - FastAPI-Skeleton mit pydantic-settings und RFC-7807-artigem Fehlerformat - Vollständiges Datenmodell (15 Tabellen) als SQLAlchemy-2.0-Modelle - Alembic gegen die Async-Engine inklusive Erstmigration - Idempotenter Seed für den deutschen Kategoriebaum und Standardregeln - Endpunkte /api/health und /api/version - pytest-Infrastruktur mit transaktionsisolierten Fixtures Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
151 lines
5.9 KiB
Python
151 lines
5.9 KiB
Python
"""Stammdaten: Konten, Kategorien, Firmen, Logos, Benutzer."""
|
||
|
||
from datetime import date, datetime
|
||
from decimal import Decimal
|
||
|
||
from sqlalchemy import (
|
||
Boolean,
|
||
CheckConstraint,
|
||
Date,
|
||
DateTime,
|
||
ForeignKey,
|
||
Index,
|
||
Integer,
|
||
String,
|
||
Text,
|
||
UniqueConstraint,
|
||
)
|
||
from sqlalchemy.dialects.postgresql import ARRAY
|
||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||
|
||
from app.db.base import Base, CreatedAtMixin, Money, TimestampMixin
|
||
from app.models.enums import (
|
||
AccountType,
|
||
EntryKind,
|
||
LogoSource,
|
||
LogoStatus,
|
||
pg_enum,
|
||
)
|
||
|
||
|
||
class Account(Base, TimestampMixin):
|
||
"""Ein Konto.
|
||
|
||
Der Saldo wird aus Eröffnungssaldo plus allen bestätigten Buchungen fortgeschrieben.
|
||
"""
|
||
|
||
__tablename__ = "account"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||
type: Mapped[AccountType] = mapped_column(
|
||
pg_enum(AccountType, "account_type"), nullable=False, default=AccountType.CHECKING
|
||
)
|
||
iban_last4: Mapped[str | None] = mapped_column(String(4), nullable=True)
|
||
opening_balance: Mapped[Decimal] = mapped_column(Money, nullable=False, default=Decimal("0.00"))
|
||
opening_balance_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3b82f6")
|
||
icon: Mapped[str] = mapped_column(String(64), nullable=False, default="wallet")
|
||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint("name", name="uq_account_name"),
|
||
Index("ix_account_is_active", "is_active"),
|
||
)
|
||
|
||
|
||
class Category(Base):
|
||
"""Zweistufiger Kategoriebaum – Unterkategorien haben genau einen Elternknoten."""
|
||
|
||
__tablename__ = "category"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
parent_id: Mapped[int | None] = mapped_column(
|
||
ForeignKey("category.id", ondelete="RESTRICT"), nullable=True
|
||
)
|
||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||
kind: Mapped[EntryKind] = mapped_column(pg_enum(EntryKind, "entry_kind"), nullable=False)
|
||
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#64748b")
|
||
icon: Mapped[str] = mapped_column(String(64), nullable=False, default="circle")
|
||
is_fixed_cost: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||
|
||
parent: Mapped["Category | None"] = relationship(
|
||
remote_side="Category.id", back_populates="children"
|
||
)
|
||
children: Mapped[list["Category"]] = relationship(
|
||
back_populates="parent", cascade="save-update, merge", order_by="Category.sort_order"
|
||
)
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint("parent_id", "name", name="uq_category_parent_id"),
|
||
Index("ix_category_kind", "kind"),
|
||
)
|
||
|
||
|
||
class LogoAsset(Base):
|
||
"""Im Volume abgelegtes Logo. Auslieferung erfolgt ausschließlich lokal."""
|
||
|
||
__tablename__ = "logo_asset"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
sha256: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||
mime: Mapped[str] = mapped_column(String(64), nullable=False)
|
||
width: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||
height: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||
file_path: Mapped[str] = mapped_column(String(255), nullable=False)
|
||
source_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||
|
||
|
||
class Merchant(Base, CreatedAtMixin):
|
||
"""Firma bzw. Zahlungsempfänger inklusive Markenlogo und -farbe."""
|
||
|
||
__tablename__ = "merchant"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
name: Mapped[str] = mapped_column(String(160), nullable=False)
|
||
normalized_name: Mapped[str] = mapped_column(String(160), nullable=False, unique=True)
|
||
domain: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||
aliases: Mapped[list[str]] = mapped_column(
|
||
ARRAY(Text), nullable=False, default=list, server_default="{}"
|
||
)
|
||
logo_asset_id: Mapped[int | None] = mapped_column(
|
||
ForeignKey("logo_asset.id", ondelete="SET NULL"), nullable=True
|
||
)
|
||
brand_color: Mapped[str | None] = mapped_column(String(9), nullable=True)
|
||
brand_color_dark: Mapped[str | None] = mapped_column(String(9), nullable=True)
|
||
logo_source: Mapped[LogoSource | None] = mapped_column(
|
||
pg_enum(LogoSource, "logo_source"), nullable=True
|
||
)
|
||
logo_status: Mapped[LogoStatus] = mapped_column(
|
||
pg_enum(LogoStatus, "logo_status"), nullable=False, default=LogoStatus.PENDING
|
||
)
|
||
|
||
logo_asset: Mapped[LogoAsset | None] = relationship(lazy="joined")
|
||
|
||
__table_args__ = (Index("ix_merchant_normalized_name", "normalized_name"),)
|
||
|
||
|
||
class AppUser(Base, CreatedAtMixin):
|
||
"""Single-User-Betrieb; `external_subject` ist für eine spätere OIDC-Anbindung vorgesehen."""
|
||
|
||
__tablename__ = "app_user"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
username: Mapped[str] = mapped_column(String(120), nullable=False, unique=True)
|
||
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||
password_hash: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||
must_change_password: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||
external_subject: Mapped[str | None] = mapped_column(String(255), nullable=True, unique=True)
|
||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||
|
||
__table_args__ = (
|
||
CheckConstraint(
|
||
"password_hash IS NOT NULL OR external_subject IS NOT NULL",
|
||
name="local_or_external_login",
|
||
),
|
||
)
|