feat: Phase 0 – Gerüst (FastAPI+SQLite, Multi-User-Login, React-App-Shell, Deploy)
Grundgerüst für Certain Finance: - Backend: FastAPI + SQLAlchemy + SQLite, Alembic-Migrationen (Batch-Mode), argon2-Passwort-Hashing, server-seitige Sitzungen im Cookie, Erst-Admin, Benutzerverwaltung mit Temporär-Passwort, Einstellungen je Benutzer. - Ein Prozess, ein Port: uvicorn liefert API (/api) und gebautes SPA aus, inkl. SPA-Deeplink-Fallback. - Frontend: React 18 + Vite + JSX, Design-Tokens (Light/Dark), i18n DE/EN, Inline-SVG-Icons (keine Emojis), Login, Passwort-ändern, Dashboard-Platzhalter, Benutzer- und Einstellungsseiten. - Deploy: nativer LXC-Installer + Update-Skript (systemd), env-Vorlage, Doku. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user