import uuid from datetime import datetime from decimal import Decimal from sqlalchemy import ( BigInteger, Boolean, DateTime, ForeignKey, Index, Integer, Numeric, String, UniqueConstraint, func, ) from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db import Base def new_uuid() -> str: return str(uuid.uuid4()) class User(Base): __tablename__ = "user" id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) # Normalisiert (lowercase, getrimmt) - eindeutig ueber diesen Wert. email: Mapped[str] = mapped_column(String(255), unique=True) display_name: Mapped[str | None] = mapped_column(String(80), default=None) password_hash: Mapped[str] = mapped_column(String(255)) verified_at: Mapped[datetime | None] = mapped_column(DateTime, default=None) is_admin: Mapped[bool] = mapped_column(Boolean, default=False) is_active: Mapped[bool] = mapped_column(Boolean, default=True) # Sperrt alles ausser "Profil lesen" und "Passwort aendern". # Wird beim initial angelegten Admin gesetzt. must_change_password: Mapped[bool] = mapped_column(Boolean, default=False) # Grundlage der automatischen Deaktivierung. Wird bei der Anmeldung # gesetzt und danach hoechstens stuendlich nachgefuehrt - jeder # Request zu schreiben waere unnoetige Last. last_seen_at: Mapped[datetime | None] = mapped_column(DateTime, default=None) # Seit wann ist das Konto deaktiviert. Grundlage der automatischen # Loeschung; is_active allein sagt nichts ueber die Dauer. deactivated_at: Mapped[datetime | None] = mapped_column(DateTime, default=None) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) sessions: Mapped[list["UserSession"]] = relationship( back_populates="user", cascade="all, delete-orphan" ) class UserSession(Base): """Serverseitige Session. Der Client haelt nur das Klartext-Token im HttpOnly-Cookie; hier liegt ausschliesslich dessen SHA-256-Hash.""" __tablename__ = "user_session" id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) token_hash: Mapped[str] = mapped_column(String(64), unique=True) user_id: Mapped[str] = mapped_column( String(36), ForeignKey("user.id", ondelete="CASCADE") ) # Double-Submit-CSRF: dieser Wert steht zusaetzlich in einem lesbaren # Cookie und muss bei schreibenden Requests im Header wiederkommen. csrf_token: Mapped[str] = mapped_column(String(64)) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) last_seen_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) expires_at: Mapped[datetime] = mapped_column(DateTime) user: Mapped[User] = relationship(back_populates="sessions") __table_args__ = (Index("ix_user_session_expires", "expires_at"),) class EmailToken(Base): """Einmal-Token fuer Mailverifikation und Passwort-Reset. Auch hier liegt nur der Hash in der Datenbank.""" __tablename__ = "email_token" id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) token_hash: Mapped[str] = mapped_column(String(64), unique=True) user_id: Mapped[str] = mapped_column( String(36), ForeignKey("user.id", ondelete="CASCADE") ) purpose: Mapped[str] = mapped_column(String(32)) # "verify" | "reset" expires_at: Mapped[datetime] = mapped_column(DateTime) used_at: Mapped[datetime | None] = mapped_column(DateTime, default=None) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) __table_args__ = (Index("ix_email_token_user_purpose", "user_id", "purpose"),) class Setting(Base): """Zur Laufzeit aenderbare Konfiguration (Admin-Oberflaeche).""" __tablename__ = "setting" key: Mapped[str] = mapped_column(String(64), primary_key=True) value: Mapped[str] = mapped_column(String(255)) updated_at: Mapped[datetime] = mapped_column( DateTime, server_default=func.now(), onupdate=func.now() ) class RateLimit(Base): """Zaehlerbasiertes Rate Limiting ohne Redis. Ein Datensatz pro (Schluessel, Zeitfenster).""" __tablename__ = "rate_limit" bucket: Mapped[str] = mapped_column(String(160), primary_key=True) window_start: Mapped[datetime] = mapped_column(DateTime, primary_key=True) count: Mapped[int] = mapped_column(Integer, default=0) # ========================================================================== # Phase 2: Listen, Katalog, Eintraege # ========================================================================== # # Zwei Konventionen ziehen sich durch alle folgenden Tabellen: # # rev / row_rev Pro Liste laeuft ein monoton steigender Zaehler # (shopping_list.rev). Jede Aenderung erhoeht ihn und # schreibt den neuen Wert in row_rev der geaenderten # Zeile. Ein Client fragt "gib mir alles mit # row_rev > N" und bekommt genau das Delta. # # deleted_at Soft Delete. Geloeschte Zeilen bleiben stehen, damit # auch ein Client, der tagelang offline war, vom # Loeschen erfaehrt. Aufraeumen per Cron nach 30 Tagen. class ShoppingList(Base): __tablename__ = "shopping_list" id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) name: Mapped[str] = mapped_column(String(120)) owner_id: Mapped[str] = mapped_column( String(36), ForeignKey("user.id", ondelete="RESTRICT") ) rev: Mapped[int] = mapped_column(BigInteger, default=0) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) updated_at: Mapped[datetime] = mapped_column( DateTime, server_default=func.now(), onupdate=func.now() ) deleted_at: Mapped[datetime | None] = mapped_column(DateTime, default=None) members: Mapped[list["ListMember"]] = relationship( back_populates="shopping_list", cascade="all, delete-orphan" ) class ListMember(Base): __tablename__ = "list_member" list_id: Mapped[str] = mapped_column( String(36), ForeignKey("shopping_list.id", ondelete="CASCADE"), primary_key=True ) user_id: Mapped[str] = mapped_column( String(36), ForeignKey("user.id", ondelete="CASCADE"), primary_key=True ) # "owner" | "editor" | "viewer" role: Mapped[str] = mapped_column(String(16), default="editor") # Zusatzrecht, unabhaengig von der Rolle: oeffentliche Links erzeugen # und widerrufen. Der Eigentuemer hat es immer, alle anderen nur, # wenn er es ausdruecklich erteilt. may_share_public: Mapped[bool] = mapped_column(Boolean, default=False) joined_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) shopping_list: Mapped[ShoppingList] = relationship(back_populates="members") user: Mapped[User] = relationship() class Market(Base): """Ein Markt, z.B. 'Edeka Neustadt'. Gehoert zu genau einer Liste - so entsteht kein listenuebergreifender Datenbestand ueber Nutzer.""" __tablename__ = "market" id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) list_id: Mapped[str] = mapped_column( String(36), ForeignKey("shopping_list.id", ondelete="CASCADE") ) name: Mapped[str] = mapped_column(String(120)) sort_order: Mapped[int] = mapped_column(Integer, default=0) row_rev: Mapped[int] = mapped_column(BigInteger, default=0) deleted_at: Mapped[datetime | None] = mapped_column(DateTime, default=None) __table_args__ = ( Index("ix_market_list_rev", "list_id", "row_rev"), UniqueConstraint("list_id", "name", name="uq_market_list_name"), ) class Category(Base): """Warengruppe, z.B. 'Molkerei'. Bestimmt die Gliederung innerhalb eines Marktes - in der App wie im Ausdruck.""" __tablename__ = "category" id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) list_id: Mapped[str] = mapped_column( String(36), ForeignKey("shopping_list.id", ondelete="CASCADE") ) name: Mapped[str] = mapped_column(String(120)) sort_order: Mapped[int] = mapped_column(Integer, default=0) row_rev: Mapped[int] = mapped_column(BigInteger, default=0) deleted_at: Mapped[datetime | None] = mapped_column(DateTime, default=None) __table_args__ = ( Index("ix_category_list_rev", "list_id", "row_rev"), UniqueConstraint("list_id", "name", name="uq_category_list_name"), ) class Article(Base): """Stammdaten eines Artikels. Der Listeneintrag (list_item) verweist darauf; derselbe Artikel kann also mehrfach auf der Liste landen, ohne dass Name und Attribute doppelt gepflegt werden.""" __tablename__ = "article" id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) list_id: Mapped[str] = mapped_column( String(36), ForeignKey("shopping_list.id", ondelete="CASCADE") ) name: Mapped[str] = mapped_column(String(200)) barcode: Mapped[str | None] = mapped_column(String(64), default=None) note: Mapped[str | None] = mapped_column(String(500), default=None) default_market_id: Mapped[str | None] = mapped_column( String(36), ForeignKey("market.id", ondelete="SET NULL"), default=None ) default_category_id: Mapped[str | None] = mapped_column( String(36), ForeignKey("category.id", ondelete="SET NULL"), default=None ) row_rev: Mapped[int] = mapped_column(BigInteger, default=0) deleted_at: Mapped[datetime | None] = mapped_column(DateTime, default=None) attributes: Mapped[list["ArticleAttribute"]] = relationship( back_populates="article", cascade="all, delete-orphan", lazy="selectin" ) availability: Mapped[list["ArticleMarket"]] = relationship( cascade="all, delete-orphan", lazy="selectin" ) __table_args__ = ( Index("ix_article_list_rev", "list_id", "row_rev"), Index("ix_article_barcode", "list_id", "barcode"), UniqueConstraint("list_id", "name", name="uq_article_list_name"), ) class ArticleAttribute(Base): """Frei definierbare Eigenschaft: Verpackungseinheit, Farbe, Groesse. Spalte heisst attr_name, weil "key" in MariaDB reserviert ist.""" __tablename__ = "article_attribute" id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) article_id: Mapped[str] = mapped_column( String(36), ForeignKey("article.id", ondelete="CASCADE") ) attr_name: Mapped[str] = mapped_column(String(80)) attr_value: Mapped[str] = mapped_column(String(300)) article: Mapped[Article] = relationship(back_populates="attributes") __table_args__ = ( UniqueConstraint("article_id", "attr_name", name="uq_article_attr"), ) class ArticleMarket(Base): """In welchen Maerkten ist der Artikel erhaeltlich.""" __tablename__ = "article_market" article_id: Mapped[str] = mapped_column( String(36), ForeignKey("article.id", ondelete="CASCADE"), primary_key=True ) market_id: Mapped[str] = mapped_column( String(36), ForeignKey("market.id", ondelete="CASCADE"), primary_key=True ) class ListItem(Base): """Ein konkreter Eintrag auf der Einkaufsliste.""" __tablename__ = "list_item" id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) list_id: Mapped[str] = mapped_column( String(36), ForeignKey("shopping_list.id", ondelete="CASCADE") ) article_id: Mapped[str] = mapped_column( String(36), ForeignKey("article.id", ondelete="CASCADE") ) # Ueberschreibt die Vorgabe aus dem Artikel, falls gesetzt. market_id: Mapped[str | None] = mapped_column( String(36), ForeignKey("market.id", ondelete="SET NULL"), default=None ) category_id: Mapped[str | None] = mapped_column( String(36), ForeignKey("category.id", ondelete="SET NULL"), default=None ) # Wer den Eintrag angelegt hat. SET NULL beim Löschen des Kontos: # der Eintrag bleibt bestehen, der Personenbezug verschwindet. created_by: Mapped[str | None] = mapped_column( String(36), ForeignKey("user.id", ondelete="SET NULL"), default=None ) # Stueckzahl: wie viele Packungen sollen es sein. Der Preis am # Eintrag gilt fuer EINE davon - die Summe ist Preis mal Anzahl. count: Mapped[int] = mapped_column(Integer, default=1) # Gebinde: wie gross ist eine Packung. Getrennt von der Stueckzahl, # weil beides sonst verwechselt wird: Bei "500 ml" zu 2,99 EUR # ergaebe eine gemeinsame Zahl eine Summe von 1495 EUR statt 2,99. pack_size: Mapped[Decimal | None] = mapped_column(Numeric(10, 3), default=None) pack_unit: Mapped[str | None] = mapped_column(String(32), default=None) # Naehere Bestimmung des gewuenschten Artikels, z.B. bei Pfeffer # "bunt, ganz" oder "schwarz, gemahlen". Bewusst getrennt von `note`: # die Eigenschaft gehoert zum Produkt und steht deshalb neben dem # Namen, waehrend die Notiz eine Bemerkung fuer den Einkaufenden ist # ("beim Metzger fragen"). variant: Mapped[str | None] = mapped_column(String(200), default=None) note: Mapped[str | None] = mapped_column(String(500), default=None) # "open" | "bought" | "deferred" status: Mapped[str] = mapped_column(String(16), default="open") # Preis in Cent - niemals als Float rechnen. price_cents: Mapped[int | None] = mapped_column(Integer, default=None) row_rev: Mapped[int] = mapped_column(BigInteger, default=0) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) updated_at: Mapped[datetime] = mapped_column( DateTime, server_default=func.now(), onupdate=func.now() ) deleted_at: Mapped[datetime | None] = mapped_column(DateTime, default=None) article: Mapped[Article] = relationship(lazy="joined") creator: Mapped["User | None"] = relationship( foreign_keys=[created_by], lazy="joined" ) __table_args__ = ( Index("ix_list_item_list_rev", "list_id", "row_rev"), Index("ix_list_item_list_status", "list_id", "status"), ) class ListInvite(Base): """Einladung zu einer Liste, adressiert an eine E-Mail-Adresse. Bewusst unabhaengig davon, ob unter der Adresse schon ein Konto existiert: So verraet das Anlegen einer Einladung nicht, wer bei uns registriert ist, und derselbe Ablauf gilt fuer neue wie fuer bestehende Nutzer. In der Datenbank steht nur der Hash des Tokens. Ein erneuter Versand erzeugt deshalb ein neues Token und entwertet das alte - der Klartext laesst sich nicht rekonstruieren. """ __tablename__ = "list_invite" id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) list_id: Mapped[str] = mapped_column( String(36), ForeignKey("shopping_list.id", ondelete="CASCADE") ) email: Mapped[str] = mapped_column(String(255)) role: Mapped[str] = mapped_column(String(16), default="editor") token_hash: Mapped[str] = mapped_column(String(64), unique=True) invited_by: Mapped[str | None] = mapped_column( String(36), ForeignKey("user.id", ondelete="SET NULL"), default=None ) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) last_sent_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) send_count: Mapped[int] = mapped_column(Integer, default=1) expires_at: Mapped[datetime] = mapped_column(DateTime) accepted_at: Mapped[datetime | None] = mapped_column(DateTime, default=None) accepted_by: Mapped[str | None] = mapped_column( String(36), ForeignKey("user.id", ondelete="SET NULL"), default=None ) revoked_at: Mapped[datetime | None] = mapped_column(DateTime, default=None) inviter: Mapped["User | None"] = relationship( foreign_keys=[invited_by], lazy="joined" ) __table_args__ = ( Index("ix_list_invite_list", "list_id"), Index("ix_list_invite_email", "email"), ) class PublicShare(Base): """Oeffentlicher Ansichtslink auf eine Liste. Wer den Link hat, darf sehen und abhaken - mehr nicht. Kein Konto noetig. Ablaufdatum ist Pflicht: ein unbefristeter Link waere ein dauerhaft offenes Fenster in fremde Daten. In der Datenbank steht nur der Hash des Tokens. Der Klartext wird einmal bei der Erzeugung ausgeliefert und ist danach nicht mehr rekonstruierbar - ein Datenbankleck gibt also keinen Zugriff. """ __tablename__ = "public_share" id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) list_id: Mapped[str] = mapped_column( String(36), ForeignKey("shopping_list.id", ondelete="CASCADE") ) token_hash: Mapped[str] = mapped_column(String(64), unique=True) # Freie Bezeichnung, damit mehrere Links unterscheidbar bleiben # ("fuer Oma", "Vereinsfest"). label: Mapped[str | None] = mapped_column(String(120), default=None) # Darf ueber diesen Link abgehakt werden? allow_check: Mapped[bool] = mapped_column(Boolean, default=True) created_by: Mapped[str | None] = mapped_column( String(36), ForeignKey("user.id", ondelete="SET NULL"), default=None ) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) expires_at: Mapped[datetime] = mapped_column(DateTime) revoked_at: Mapped[datetime | None] = mapped_column(DateTime, default=None) # Grobe Nutzungsanzeige fuer den Eigentuemer. Bewusst ohne IP-Adressen # und ohne Zeitreihe - es soll erkennbar sein, DASS ein Link benutzt # wird, nicht von wem. last_access_at: Mapped[datetime | None] = mapped_column(DateTime, default=None) access_count: Mapped[int] = mapped_column(Integer, default=0) creator: Mapped["User | None"] = relationship( foreign_keys=[created_by], lazy="joined" ) __table_args__ = (Index("ix_public_share_list", "list_id"),) class AppliedOp(Base): """Quittung fuer eine bereits verarbeitete Operation aus einer Outbox-Warteschlange. Der Client vergibt die `op_id` selbst, bevor er sendet. Kommt dieselbe Operation ein zweites Mal an - weil die Antwort unterwegs verloren ging, das Telefon neu gestartet wurde oder der Hintergrundversand sie erneut zugestellt hat -, wird sie hier erkannt und nicht noch einmal ausgefuehrt. Ohne diese Tabelle wuerde aus einem verlorenen "Butter hinzufuegen" beim naechsten Versuch zweimal Butter. """ __tablename__ = "applied_op" op_id: Mapped[str] = mapped_column(String(64), primary_key=True) list_id: Mapped[str] = mapped_column( String(36), ForeignKey("shopping_list.id", ondelete="CASCADE") ) user_id: Mapped[str | None] = mapped_column( String(36), ForeignKey("user.id", ondelete="SET NULL"), default=None ) kind: Mapped[str] = mapped_column(String(32)) # Bei item.create: die vergebene ID, damit der Client seinen # vorlaeufigen Eintrag zuordnen kann. result_id: Mapped[str | None] = mapped_column(String(36), default=None) rev: Mapped[int] = mapped_column(BigInteger, default=0) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) __table_args__ = (Index("ix_applied_op_list", "list_id", "created_at"),) class ProductCache(Base): """Zwischenspeicher fuer Abfragen bei Open Food Facts. Zweck ist nicht nur Geschwindigkeit: Ohne Zwischenspeicher ginge bei jedem Scan eine Anfrage nach draussen. So wird jeder Code hoechstens einmal je Gueltigkeitszeitraum abgefragt - und zwar von diesem Server, nicht vom Geraet des Nutzers. Dessen IP-Adresse erfaehrt der Dritte damit gar nicht erst. Fehlschlaege werden ebenfalls vermerkt, sonst laeuft jeder Scan eines unbekannten Codes erneut ins Leere. """ __tablename__ = "product_cache" barcode: Mapped[str] = mapped_column(String(64), primary_key=True) found: Mapped[bool] = mapped_column(Boolean, default=False) name: Mapped[str | None] = mapped_column(String(300), default=None) brand: Mapped[str | None] = mapped_column(String(200), default=None) # Rohtext wie "500 g", "1,5 l" oder "6 x 33 cl" package: Mapped[str | None] = mapped_column(String(120), default=None) # Aus dem Rohtext zerlegt: "6 x 33 cl" -> count 6, Gebinde 33 cl count: Mapped[int | None] = mapped_column(Integer, default=None) pack_size: Mapped[Decimal | None] = mapped_column(Numeric(10, 3), default=None) pack_unit: Mapped[str | None] = mapped_column(String(32), default=None) source: Mapped[str] = mapped_column(String(32), default="openfoodfacts") fetched_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) class PricePoint(Base): """Ein beobachteter Preis: Artikel, Markt, Betrag, Zeitpunkt. Bewusst OHNE Nutzerbezug. Aus "wer hat wann wo was zu welchem Preis gekauft" liesse sich ein Bewegungs- und Konsumprofil bilden - genau das soll die Preisdatenbank nicht ermoeglichen. Fuer den Zweck (Preise vergleichen) genuegt Markt, Artikel und Zeitpunkt. Menge und Einheit werden mitgefuehrt, weil ein Preis ohne Bezugsgroesse nicht vergleichbar ist: 1,29 EUR fuer einen Liter ist etwas anderes als 1,29 EUR fuer 200 Milliliter. """ __tablename__ = "price_point" id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) list_id: Mapped[str] = mapped_column( String(36), ForeignKey("shopping_list.id", ondelete="CASCADE") ) article_id: Mapped[str] = mapped_column( String(36), ForeignKey("article.id", ondelete="CASCADE") ) market_id: Mapped[str] = mapped_column( String(36), ForeignKey("market.id", ondelete="CASCADE") ) # Preis fuer EIN Gebinde. price_cents: Mapped[int] = mapped_column(Integer) pack_size: Mapped[Decimal | None] = mapped_column(Numeric(10, 3), default=None) pack_unit: Mapped[str | None] = mapped_column(String(32), default=None) recorded_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) __table_args__ = ( Index("ix_price_article_market", "article_id", "market_id", "recorded_at"), Index("ix_price_list", "list_id", "recorded_at"), ) class PushSubscription(Base): """Anmeldung eines Geraets fuer Push-Benachrichtigungen. Der Endpunkt ist eine vom Browserhersteller vergebene URL. Sie ist geraetebezogen, aber nicht personenbezogen im engeren Sinn - wir speichern sie, weil ohne sie keine Zustellung moeglich ist, und loeschen sie, sobald der Push-Dienst sie als ungueltig meldet. """ __tablename__ = "push_subscription" id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) user_id: Mapped[str] = mapped_column( String(36), ForeignKey("user.id", ondelete="CASCADE") ) endpoint: Mapped[str] = mapped_column(String(500), unique=True) # Schluesselmaterial fuer die Ende-zu-Ende-Verschluesselung der # Nachricht. Auch der Push-Dienst kann den Inhalt nicht lesen. p256dh: Mapped[str] = mapped_column(String(200)) auth: Mapped[str] = mapped_column(String(100)) # Grobe Geraetekennung, damit der Nutzer seine Anmeldungen # auseinanderhalten kann. Bewusst gekuerzt. label: Mapped[str | None] = mapped_column(String(80), default=None) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) last_success_at: Mapped[datetime | None] = mapped_column(DateTime, default=None) failure_count: Mapped[int] = mapped_column(Integer, default=0) __table_args__ = (Index("ix_push_subscription_user", "user_id"),) class NotifyState(Base): """Wann wurde diese Person zuletzt ueber diese Liste benachrichtigt. Grundlage der Drosselung: Wer gerade eine Liste abarbeitet, erzeugt Dutzende Aenderungen. Ohne diesen Vermerk bekaemen die anderen Mitglieder ebenso viele Benachrichtigungen. """ __tablename__ = "notify_state" list_id: Mapped[str] = mapped_column( String(36), ForeignKey("shopping_list.id", ondelete="CASCADE"), primary_key=True ) user_id: Mapped[str] = mapped_column( String(36), ForeignKey("user.id", ondelete="CASCADE"), primary_key=True ) last_notified_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) class EmailChange(Base): """Laufende Aenderung einer E-Mail-Adresse. Bei gewoehnlichen Konten muss nur die NEUE Adresse bestaetigen; die alte bekommt eine Benachrichtigung, damit eine untergeschobene Aenderung auffaellt. Bei Administratorkonten muessen BEIDE bestaetigen. Der Grund: Wer Zugriff auf ein Administratorkonto erlangt, koennte sonst die Adresse auf eine eigene umstellen und sich damit dauerhaft einnisten - der rechtmaessige Inhaber verloere den Weg zurueck ueber "Passwort vergessen". Die Bestaetigung von der alten Adresse macht das unmoeglich, solange der Angreifer nicht auch das Postfach hat. """ __tablename__ = "email_change" id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) user_id: Mapped[str] = mapped_column( String(36), ForeignKey("user.id", ondelete="CASCADE") ) old_email: Mapped[str] = mapped_column(String(255)) new_email: Mapped[str] = mapped_column(String(255)) # Nur Hashes, wie bei allen Token in dieser Anwendung token_new_hash: Mapped[str] = mapped_column(String(64), unique=True) token_old_hash: Mapped[str | None] = mapped_column( String(64), unique=True, default=None ) # True bei Administratorkonten requires_old: Mapped[bool] = mapped_column(Boolean, default=False) confirmed_new_at: Mapped[datetime | None] = mapped_column(DateTime, default=None) confirmed_old_at: Mapped[datetime | None] = mapped_column(DateTime, default=None) applied_at: Mapped[datetime | None] = mapped_column(DateTime, default=None) cancelled_at: Mapped[datetime | None] = mapped_column(DateTime, default=None) requested_by: Mapped[str | None] = mapped_column( String(36), ForeignKey("user.id", ondelete="SET NULL"), default=None ) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) expires_at: Mapped[datetime] = mapped_column(DateTime) __table_args__ = (Index("ix_email_change_user", "user_id"),)