feat: Projektfundament mit Datenmodell, Migrationen und Seed
- 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
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
"""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",
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user