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:
moneyfy
2026-09-09 13:12:08 +02:00
co-authored by Claude Opus 5
commit 0b06775be3
40 changed files with 2236 additions and 0 deletions
+150
View File
@@ -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",
),
)