Erste Produktivversion
This commit is contained in:
632
backend/app/models.py
Normal file
632
backend/app/models.py
Normal file
@@ -0,0 +1,632 @@
|
||||
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"),)
|
||||
Reference in New Issue
Block a user