Files
einkaufsapp/backend/app/models.py
2026-08-08 20:31:58 +02:00

633 lines
26 KiB
Python

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"),)