"""SQLAlchemy-Modelle. Konventionen (wie in der Vorlage): ``Mapped[...] = mapped_column(...)``, Geld als Ganzzahl in Cent/Rappen, Enums als kurze VARCHAR-Strings (``native_enum=False``, ohne DB-CHECK) – das hält Alembic auf SQLite einfach. Phase 0 umfasst nur Benutzer, Sitzungen und Benutzer-Einstellungen. Fachliche Tabellen (Konten, Kategorien, Transaktionen, Budget …) kommen in späteren Phasen hinzu und werden dann alle über ``owner_id`` je Benutzer getrennt. """ from __future__ import annotations import enum from datetime import datetime, timezone from sqlalchemy import ( Boolean, DateTime, Enum as SAEnum, ForeignKey, Integer, String, ) from sqlalchemy.orm import Mapped, mapped_column, relationship from .database import Base def _now() -> datetime: return datetime.now(timezone.utc) def _enum(enum_cls: type[enum.Enum]): """Enum als VARCHAR ohne DB-CHECK – Alembic-freundlich auf SQLite.""" return SAEnum(enum_cls, native_enum=False, create_constraint=False, length=32) class Role(str, enum.Enum): admin = "admin" user = "user" class User(Base): __tablename__ = "users" id: Mapped[int] = mapped_column(Integer, primary_key=True) username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) password_hash: Mapped[str] = mapped_column(String(255), nullable=False) role: Mapped[Role] = mapped_column(_enum(Role), nullable=False, default=Role.user) display_name: Mapped[str | None] = mapped_column(String(120), nullable=True) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) # Vom Admin mit Temporär-Passwort angelegt: beim ersten Login ändern lassen. must_change_password: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) sessions: Mapped[list[Session]] = relationship( back_populates="user", cascade="all, delete-orphan" ) settings: Mapped[list[UserSetting]] = relationship( back_populates="user", cascade="all, delete-orphan" ) class Session(Base): """Server-seitige Sitzung. Im Cookie liegt nur das Klartext-Token, hier der Hash.""" __tablename__ = "sessions" id: Mapped[int] = mapped_column(Integer, primary_key=True) user_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True ) token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) user_agent: Mapped[str | None] = mapped_column(String(255), nullable=True) user: Mapped[User] = relationship(back_populates="sessions") class UserSetting(Base): """Freie Schlüssel-Wert-Einstellungen je Benutzer. Bekannte Schlüssel: ``base_currency`` (EUR|CHF), ``budget_start_day`` (int), ``locale`` (de|en), ``theme`` (system|light|dark), ``date_format``. """ __tablename__ = "user_settings" user_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), primary_key=True ) key: Mapped[str] = mapped_column(String(64), primary_key=True) value: Mapped[str | None] = mapped_column(String(255), nullable=True) user: Mapped[User] = relationship(back_populates="settings")