633 lines
26 KiB
Python
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"),)
|