Erste Produktivversion
This commit is contained in:
181
.env.example
Normal file
181
.env.example
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
# ==========================================================================
|
||||||
|
# Datenbank
|
||||||
|
# ==========================================================================
|
||||||
|
DB_NAME=einkaufsapp
|
||||||
|
DB_USER=einkaufsapp
|
||||||
|
DB_PASSWORD=BITTE-AENDERN-langes-zufaelliges-passwort
|
||||||
|
DB_ROOT_PASSWORD=BITTE-AENDERN-anderes-langes-passwort
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Anwendung
|
||||||
|
# ==========================================================================
|
||||||
|
# ---- Anwendungsname ----
|
||||||
|
# Erscheint in der Oberflaeche, im Browsertitel, auf dem Startbildschirm
|
||||||
|
# und in allen Mails. Nach einer Aenderung genuegt:
|
||||||
|
# docker compose up -d api
|
||||||
|
# Der web-Container muss dafuer NICHT neu gebaut werden.
|
||||||
|
APP_NAME=Einkaufsliste
|
||||||
|
# Kuerzere Fassung fuer das Symbol auf dem Startbildschirm. Leer = APP_NAME.
|
||||||
|
APP_SHORT_NAME=Einkauf
|
||||||
|
|
||||||
|
# Einziger nach aussen veroeffentlichter Port. Dahinter liegt nginx,
|
||||||
|
# das sowohl die Oberflaeche als auch /api/ ausliefert. Der api-Container
|
||||||
|
# hat bewusst KEINEN eigenen Port nach aussen.
|
||||||
|
HTTP_PORT=46600
|
||||||
|
|
||||||
|
# Mailpit-Weboberflaeche, nur mit "--profile dev". An 127.0.0.1 gebunden.
|
||||||
|
MAILPIT_PORT=8025
|
||||||
|
|
||||||
|
# Basis-URL, wie Nutzer die App im Browser erreichen - inklusive Port,
|
||||||
|
# falls kein vorgelagerter Reverse Proxy auf 80/443 steht.
|
||||||
|
# Landet in Verifikations- und Einladungsmails, muss also stimmen.
|
||||||
|
PUBLIC_BASE_URL=http://einkauf.example.de:46600
|
||||||
|
|
||||||
|
# Welchen Absendern von X-Forwarded-For die API glauben darf.
|
||||||
|
# "*" ist in Ordnung, solange api keinen oeffentlichen Port hat.
|
||||||
|
FORWARDED_ALLOW_IPS=*
|
||||||
|
|
||||||
|
SESSION_DAYS=30
|
||||||
|
|
||||||
|
# Cookie nur über HTTPS ausliefern.
|
||||||
|
# false bei direktem HTTP-Zugriff auf Port 46600
|
||||||
|
# true sobald ein Reverse Proxy mit TLS davorsteht (dann muss
|
||||||
|
# PUBLIC_BASE_URL ebenfalls auf https:// zeigen)
|
||||||
|
# Ein Secure-Cookie über reines HTTP wird vom Browser verworfen - die
|
||||||
|
# Anmeldung scheitert dann kommentarlos.
|
||||||
|
COOKIE_SECURE=false
|
||||||
|
|
||||||
|
# Dieses Konto wird als Administrator markiert, sobald es sich registriert.
|
||||||
|
ADMIN_EMAIL=admin@example.de
|
||||||
|
|
||||||
|
# Optional: Startpasswort für das erste Administratorkonto.
|
||||||
|
#
|
||||||
|
# - Wird NUR beim allerersten Start verwendet, und auch dann nur, wenn
|
||||||
|
# die Datenbank noch gar keinen Nutzer enthält.
|
||||||
|
# - Das angelegte Konto muss das Passwort bei der ersten Anmeldung
|
||||||
|
# ändern. Bis dahin sind alle Funktionen außer "Profil lesen" und
|
||||||
|
# "Passwort ändern" gesperrt.
|
||||||
|
# - Danach diesen Wert wieder entfernen. Er steht im Klartext auf der
|
||||||
|
# Platte, in jedem Backup und ist über "docker inspect" für jeden
|
||||||
|
# lesbar, der auf den Host kommt.
|
||||||
|
#
|
||||||
|
# Leer lassen, um stattdessen den regulären Registrierungsweg zu nutzen.
|
||||||
|
ADMIN_INITIAL_PASSWORD=
|
||||||
|
|
||||||
|
# Alternative für Docker Secrets - hat Vorrang vor der Variablen oben:
|
||||||
|
# ADMIN_INITIAL_PASSWORD_FILE=/run/secrets/admin_password
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Selbstregistrierung
|
||||||
|
# ==========================================================================
|
||||||
|
# true Immer erlaubt. Admin-API kann es NICHT abschalten.
|
||||||
|
# false Immer gesperrt. Zugang nur noch per Einladung (ab Phase 5).
|
||||||
|
# Admin-API kann es NICHT einschalten.
|
||||||
|
# admin Die Datenbankeinstellung entscheidet, der Administrator darf
|
||||||
|
# sie zur Laufzeit umschalten. (Voreinstellung)
|
||||||
|
ALLOW_SELF_REGISTRATION=admin
|
||||||
|
|
||||||
|
# Nur relevant bei ALLOW_SELF_REGISTRATION=admin: Startwert beim
|
||||||
|
# allerersten Start. Danach wird dieser Wert ignoriert.
|
||||||
|
SELF_REGISTRATION_DEFAULT=true
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Produktdatenbank
|
||||||
|
# ==========================================================================
|
||||||
|
# Nachschlagen gescannter Strichcodes bei Open Food Facts.
|
||||||
|
# openfoodfacts eingeschaltet (Voreinstellung)
|
||||||
|
# off aus - nur der eigene Artikelstamm
|
||||||
|
#
|
||||||
|
# Die Abfrage laeuft ueber den api-Container, nicht aus dem Browser:
|
||||||
|
# So erfaehrt der Dienst die IP-Adressen deiner Nutzer nicht, die
|
||||||
|
# Content-Security-Policy bleibt bei connect-src 'self', und jeder Code
|
||||||
|
# wird nur einmal abgefragt.
|
||||||
|
#
|
||||||
|
# Voraussetzung: Der api-Container muss world.openfoodfacts.org
|
||||||
|
# erreichen koennen.
|
||||||
|
PRODUCT_LOOKUP=openfoodfacts
|
||||||
|
|
||||||
|
# Gueltigkeit der zwischengespeicherten Antworten.
|
||||||
|
PRODUCT_CACHE_DAYS=180
|
||||||
|
# Fehlschlaege kuerzer merken - das Produkt kann spaeter eingepflegt sein.
|
||||||
|
PRODUCT_MISS_DAYS=14
|
||||||
|
PRODUCT_LOOKUP_TIMEOUT=6
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Aufraeumen
|
||||||
|
# ==========================================================================
|
||||||
|
# Wie lange weich geloeschte Daten aufbewahrt werden. Solange kann ein
|
||||||
|
# Geraet offline bleiben und beim naechsten Abgleich noch erfahren, dass
|
||||||
|
# etwas verschwunden ist. Kuerzer einzustellen fuehrt dazu, dass
|
||||||
|
# geloeschte Listen auf selten benutzten Geraeten stehenbleiben.
|
||||||
|
CLEANUP_DELETED_DAYS=30
|
||||||
|
# Quittungen der Outbox - verhindern doppeltes Ausfuehren.
|
||||||
|
CLEANUP_OPS_DAYS=7
|
||||||
|
# Abstand zwischen zwei Aufraeumdurchlaeufen.
|
||||||
|
CLEANUP_INTERVAL_HOURS=24
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Push-Benachrichtigungen
|
||||||
|
# ==========================================================================
|
||||||
|
# Schluesselpaar erzeugen mit:
|
||||||
|
# docker compose exec api python3 /app/../tools/vapid-keys.py
|
||||||
|
# oder lokal:
|
||||||
|
# python3 tools/vapid-keys.py
|
||||||
|
#
|
||||||
|
# Leer lassen = Push abgeschaltet. Ein Wechsel des privaten Schluessels
|
||||||
|
# entwertet alle bestehenden Geraeteanmeldungen.
|
||||||
|
# ACHTUNG: Jede Variable darf nur EINMAL in dieser Datei stehen. Bei
|
||||||
|
# doppelten Eintraegen nimmt Docker Compose den letzten - eine
|
||||||
|
# vergessene leere Vorlagenzeile unterhalb des eingefuegten Werts
|
||||||
|
# schaltet die Funktion damit still wieder ab.
|
||||||
|
VAPID_PRIVATE_KEY=
|
||||||
|
VAPID_PUBLIC_KEY=
|
||||||
|
# Kontaktadresse fuer die Push-Dienste der Browserhersteller.
|
||||||
|
# Leer = SMTP_ENVELOPE_FROM wird verwendet.
|
||||||
|
VAPID_SUBJECT=mailto:admin@example.de
|
||||||
|
|
||||||
|
# Hoechstens eine Benachrichtigung je Liste und Person in diesem Zeitraum.
|
||||||
|
PUSH_THROTTLE_HOURS=2
|
||||||
|
PUSH_TIMEOUT=10
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# SMTP-Relay
|
||||||
|
# ==========================================================================
|
||||||
|
# --- Beispiel: eigener Postfix/mailcow-Relay ---
|
||||||
|
SMTP_HOST=mail.example.de
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_SECURITY=starttls
|
||||||
|
SMTP_USER=einkaufsapp@example.de
|
||||||
|
SMTP_PASSWORD=BITTE-AENDERN
|
||||||
|
|
||||||
|
# --- Beispiel: Relay mit implizitem TLS ---
|
||||||
|
# SMTP_PORT=465
|
||||||
|
# SMTP_SECURITY=ssl
|
||||||
|
|
||||||
|
# --- Beispiel: lokaler Mailpit-Container zum Testen ---
|
||||||
|
# SMTP_HOST=mailpit
|
||||||
|
# SMTP_PORT=1025
|
||||||
|
# SMTP_SECURITY=none
|
||||||
|
# SMTP_USER=
|
||||||
|
# SMTP_PASSWORD=
|
||||||
|
|
||||||
|
# Hostname im EHLO-Kommando. Sollte ein FQDN sein, der zur sendenden
|
||||||
|
# Domain passt - sonst meldet sich der Container mit seiner ID, was bei
|
||||||
|
# strengen Empfängern Spampunkte kostet.
|
||||||
|
SMTP_HELO_HOSTNAME=einkauf.example.de
|
||||||
|
|
||||||
|
# Absender, wie der Empfänger ihn sieht.
|
||||||
|
SMTP_FROM=einkaufsliste@example.de
|
||||||
|
# Leer lassen, um APP_NAME zu verwenden.
|
||||||
|
SMTP_FROM_NAME=
|
||||||
|
|
||||||
|
# Envelope-Absender (Return-Path). SPF wird gegen DIESE Adresse geprüft,
|
||||||
|
# nicht gegen SMTP_FROM. Leer lassen = identisch mit SMTP_FROM.
|
||||||
|
# Eine eigene Bounce-Adresse ist sinnvoll, wenn du Rückläufer auswerten willst.
|
||||||
|
SMTP_ENVELOPE_FROM=bounces@example.de
|
||||||
|
|
||||||
|
# Optional: Antworten sollen an ein Postfach gehen, das jemand liest.
|
||||||
|
SMTP_REPLY_TO=
|
||||||
|
|
||||||
|
SMTP_TIMEOUT=20
|
||||||
|
SMTP_MAX_RETRIES=3
|
||||||
447
.gitignore
vendored
447
.gitignore
vendored
@@ -1,416 +1,43 @@
|
|||||||
# ---> VisualStudioCode
|
# ==========================================================================
|
||||||
.vscode/*
|
# Geheimnisse - NIEMALS ins Repository
|
||||||
!.vscode/settings.json
|
# ==========================================================================
|
||||||
!.vscode/tasks.json
|
# Enthält Datenbankpasswort, SMTP-Zugangsdaten und den privaten
|
||||||
!.vscode/launch.json
|
# VAPID-Schlüssel. Gehört in den Passwortmanager oder in eine
|
||||||
!.vscode/extensions.json
|
# verschlüsselte Sicherung.
|
||||||
!.vscode/*.code-snippets
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# Local History for Visual Studio Code
|
# Docker-Secrets-Dateien
|
||||||
.history/
|
secrets/
|
||||||
|
*.key
|
||||||
|
*.pem
|
||||||
|
|
||||||
# Built Visual Studio Code Extensions
|
# ==========================================================================
|
||||||
*.vsix
|
# Laufzeitdaten
|
||||||
|
# ==========================================================================
|
||||||
|
data/
|
||||||
|
*.sql.gz
|
||||||
|
*.sql.bz2
|
||||||
|
backups/
|
||||||
|
|
||||||
# ---> VisualStudio
|
# ==========================================================================
|
||||||
## Ignore Visual Studio temporary files, build results, and
|
# Werkzeuge
|
||||||
## files generated by popular Visual Studio add-ons.
|
# ==========================================================================
|
||||||
##
|
|
||||||
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
|
|
||||||
|
|
||||||
# User-specific files
|
|
||||||
*.rsuser
|
|
||||||
*.suo
|
|
||||||
*.user
|
|
||||||
*.userosscache
|
|
||||||
*.sln.docstates
|
|
||||||
|
|
||||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
|
||||||
*.userprefs
|
|
||||||
|
|
||||||
# Mono auto generated files
|
|
||||||
mono_crash.*
|
|
||||||
|
|
||||||
# Build results
|
|
||||||
[Dd]ebug/
|
|
||||||
[Dd]ebugPublic/
|
|
||||||
[Rr]elease/
|
|
||||||
[Rr]eleases/
|
|
||||||
x64/
|
|
||||||
x86/
|
|
||||||
[Ww][Ii][Nn]32/
|
|
||||||
[Aa][Rr][Mm]/
|
|
||||||
[Aa][Rr][Mm]64/
|
|
||||||
bld/
|
|
||||||
[Bb]in/
|
|
||||||
[Oo]bj/
|
|
||||||
[Ll]og/
|
|
||||||
[Ll]ogs/
|
|
||||||
|
|
||||||
# Visual Studio 2015/2017 cache/options directory
|
|
||||||
.vs/
|
|
||||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
|
||||||
#wwwroot/
|
|
||||||
|
|
||||||
# Visual Studio 2017 auto generated files
|
|
||||||
Generated\ Files/
|
|
||||||
|
|
||||||
# MSTest test Results
|
|
||||||
[Tt]est[Rr]esult*/
|
|
||||||
[Bb]uild[Ll]og.*
|
|
||||||
|
|
||||||
# NUnit
|
|
||||||
*.VisualState.xml
|
|
||||||
TestResult.xml
|
|
||||||
nunit-*.xml
|
|
||||||
|
|
||||||
# Build Results of an ATL Project
|
|
||||||
[Dd]ebugPS/
|
|
||||||
[Rr]eleasePS/
|
|
||||||
dlldata.c
|
|
||||||
|
|
||||||
# Benchmark Results
|
|
||||||
BenchmarkDotNet.Artifacts/
|
|
||||||
|
|
||||||
# .NET Core
|
|
||||||
project.lock.json
|
|
||||||
project.fragment.lock.json
|
|
||||||
artifacts/
|
|
||||||
|
|
||||||
# ASP.NET Scaffolding
|
|
||||||
ScaffoldingReadMe.txt
|
|
||||||
|
|
||||||
# StyleCop
|
|
||||||
StyleCopReport.xml
|
|
||||||
|
|
||||||
# Files built by Visual Studio
|
|
||||||
*_i.c
|
|
||||||
*_p.c
|
|
||||||
*_h.h
|
|
||||||
*.ilk
|
|
||||||
*.meta
|
|
||||||
*.obj
|
|
||||||
*.iobj
|
|
||||||
*.pch
|
|
||||||
*.pdb
|
|
||||||
*.ipdb
|
|
||||||
*.pgc
|
|
||||||
*.pgd
|
|
||||||
*.rsp
|
|
||||||
# but not Directory.Build.rsp, as it configures directory-level build defaults
|
|
||||||
!Directory.Build.rsp
|
|
||||||
*.sbr
|
|
||||||
*.tlb
|
|
||||||
*.tli
|
|
||||||
*.tlh
|
|
||||||
*.tmp
|
|
||||||
*.tmp_proj
|
|
||||||
*_wpftmp.csproj
|
|
||||||
*.log
|
|
||||||
*.tlog
|
|
||||||
*.vspscc
|
|
||||||
*.vssscc
|
|
||||||
.builds
|
|
||||||
*.pidb
|
|
||||||
*.svclog
|
|
||||||
*.scc
|
|
||||||
|
|
||||||
# Chutzpah Test files
|
|
||||||
_Chutzpah*
|
|
||||||
|
|
||||||
# Visual C++ cache files
|
|
||||||
ipch/
|
|
||||||
*.aps
|
|
||||||
*.ncb
|
|
||||||
*.opendb
|
|
||||||
*.opensdf
|
|
||||||
*.sdf
|
|
||||||
*.cachefile
|
|
||||||
*.VC.db
|
|
||||||
*.VC.VC.opendb
|
|
||||||
|
|
||||||
# Visual Studio profiler
|
|
||||||
*.psess
|
|
||||||
*.vsp
|
|
||||||
*.vspx
|
|
||||||
*.sap
|
|
||||||
|
|
||||||
# Visual Studio Trace Files
|
|
||||||
*.e2e
|
|
||||||
|
|
||||||
# TFS 2012 Local Workspace
|
|
||||||
$tf/
|
|
||||||
|
|
||||||
# Guidance Automation Toolkit
|
|
||||||
*.gpState
|
|
||||||
|
|
||||||
# ReSharper is a .NET coding add-in
|
|
||||||
_ReSharper*/
|
|
||||||
*.[Rr]e[Ss]harper
|
|
||||||
*.DotSettings.user
|
|
||||||
|
|
||||||
# TeamCity is a build add-in
|
|
||||||
_TeamCity*
|
|
||||||
|
|
||||||
# DotCover is a Code Coverage Tool
|
|
||||||
*.dotCover
|
|
||||||
|
|
||||||
# AxoCover is a Code Coverage Tool
|
|
||||||
.axoCover/*
|
|
||||||
!.axoCover/settings.json
|
|
||||||
|
|
||||||
# Coverlet is a free, cross platform Code Coverage Tool
|
|
||||||
coverage*.json
|
|
||||||
coverage*.xml
|
|
||||||
coverage*.info
|
|
||||||
|
|
||||||
# Visual Studio code coverage results
|
|
||||||
*.coverage
|
|
||||||
*.coveragexml
|
|
||||||
|
|
||||||
# NCrunch
|
|
||||||
_NCrunch_*
|
|
||||||
.*crunch*.local.xml
|
|
||||||
nCrunchTemp_*
|
|
||||||
|
|
||||||
# MightyMoose
|
|
||||||
*.mm.*
|
|
||||||
AutoTest.Net/
|
|
||||||
|
|
||||||
# Web workbench (sass)
|
|
||||||
.sass-cache/
|
|
||||||
|
|
||||||
# Installshield output folder
|
|
||||||
[Ee]xpress/
|
|
||||||
|
|
||||||
# DocProject is a documentation generator add-in
|
|
||||||
DocProject/buildhelp/
|
|
||||||
DocProject/Help/*.HxT
|
|
||||||
DocProject/Help/*.HxC
|
|
||||||
DocProject/Help/*.hhc
|
|
||||||
DocProject/Help/*.hhk
|
|
||||||
DocProject/Help/*.hhp
|
|
||||||
DocProject/Help/Html2
|
|
||||||
DocProject/Help/html
|
|
||||||
|
|
||||||
# Click-Once directory
|
|
||||||
publish/
|
|
||||||
|
|
||||||
# Publish Web Output
|
|
||||||
*.[Pp]ublish.xml
|
|
||||||
*.azurePubxml
|
|
||||||
# Note: Comment the next line if you want to checkin your web deploy settings,
|
|
||||||
# but database connection strings (with potential passwords) will be unencrypted
|
|
||||||
*.pubxml
|
|
||||||
*.publishproj
|
|
||||||
|
|
||||||
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
|
||||||
# checkin your Azure Web App publish settings, but sensitive information contained
|
|
||||||
# in these scripts will be unencrypted
|
|
||||||
PublishScripts/
|
|
||||||
|
|
||||||
# NuGet Packages
|
|
||||||
*.nupkg
|
|
||||||
# NuGet Symbol Packages
|
|
||||||
*.snupkg
|
|
||||||
# The packages folder can be ignored because of Package Restore
|
|
||||||
**/[Pp]ackages/*
|
|
||||||
# except build/, which is used as an MSBuild target.
|
|
||||||
!**/[Pp]ackages/build/
|
|
||||||
# Uncomment if necessary however generally it will be regenerated when needed
|
|
||||||
#!**/[Pp]ackages/repositories.config
|
|
||||||
# NuGet v3's project.json files produces more ignorable files
|
|
||||||
*.nuget.props
|
|
||||||
*.nuget.targets
|
|
||||||
|
|
||||||
# Microsoft Azure Build Output
|
|
||||||
csx/
|
|
||||||
*.build.csdef
|
|
||||||
|
|
||||||
# Microsoft Azure Emulator
|
|
||||||
ecf/
|
|
||||||
rcf/
|
|
||||||
|
|
||||||
# Windows Store app package directories and files
|
|
||||||
AppPackages/
|
|
||||||
BundleArtifacts/
|
|
||||||
Package.StoreAssociation.xml
|
|
||||||
_pkginfo.txt
|
|
||||||
*.appx
|
|
||||||
*.appxbundle
|
|
||||||
*.appxupload
|
|
||||||
|
|
||||||
# Visual Studio cache files
|
|
||||||
# files ending in .cache can be ignored
|
|
||||||
*.[Cc]ache
|
|
||||||
# but keep track of directories ending in .cache
|
|
||||||
!?*.[Cc]ache/
|
|
||||||
|
|
||||||
# Others
|
|
||||||
ClientBin/
|
|
||||||
~$*
|
|
||||||
*~
|
|
||||||
*.dbmdl
|
|
||||||
*.dbproj.schemaview
|
|
||||||
*.jfm
|
|
||||||
*.pfx
|
|
||||||
*.publishsettings
|
|
||||||
orleans.codegen.cs
|
|
||||||
|
|
||||||
# Including strong name files can present a security risk
|
|
||||||
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
|
||||||
#*.snk
|
|
||||||
|
|
||||||
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
|
||||||
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
|
||||||
#bower_components/
|
|
||||||
|
|
||||||
# RIA/Silverlight projects
|
|
||||||
Generated_Code/
|
|
||||||
|
|
||||||
# Backup & report files from converting an old project file
|
|
||||||
# to a newer Visual Studio version. Backup files are not needed,
|
|
||||||
# because we have git ;-)
|
|
||||||
_UpgradeReport_Files/
|
|
||||||
Backup*/
|
|
||||||
UpgradeLog*.XML
|
|
||||||
UpgradeLog*.htm
|
|
||||||
ServiceFabricBackup/
|
|
||||||
*.rptproj.bak
|
|
||||||
|
|
||||||
# SQL Server files
|
|
||||||
*.mdf
|
|
||||||
*.ldf
|
|
||||||
*.ndf
|
|
||||||
|
|
||||||
# Business Intelligence projects
|
|
||||||
*.rdl.data
|
|
||||||
*.bim.layout
|
|
||||||
*.bim_*.settings
|
|
||||||
*.rptproj.rsuser
|
|
||||||
*- [Bb]ackup.rdl
|
|
||||||
*- [Bb]ackup ([0-9]).rdl
|
|
||||||
*- [Bb]ackup ([0-9][0-9]).rdl
|
|
||||||
|
|
||||||
# Microsoft Fakes
|
|
||||||
FakesAssemblies/
|
|
||||||
|
|
||||||
# GhostDoc plugin setting file
|
|
||||||
*.GhostDoc.xml
|
|
||||||
|
|
||||||
# Node.js Tools for Visual Studio
|
|
||||||
.ntvs_analysis.dat
|
|
||||||
node_modules/
|
|
||||||
|
|
||||||
# Visual Studio 6 build log
|
|
||||||
*.plg
|
|
||||||
|
|
||||||
# Visual Studio 6 workspace options file
|
|
||||||
*.opt
|
|
||||||
|
|
||||||
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
|
||||||
*.vbw
|
|
||||||
|
|
||||||
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
|
|
||||||
*.vbp
|
|
||||||
|
|
||||||
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
|
|
||||||
*.dsw
|
|
||||||
*.dsp
|
|
||||||
|
|
||||||
# Visual Studio 6 technical files
|
|
||||||
*.ncb
|
|
||||||
*.aps
|
|
||||||
|
|
||||||
# Visual Studio LightSwitch build output
|
|
||||||
**/*.HTMLClient/GeneratedArtifacts
|
|
||||||
**/*.DesktopClient/GeneratedArtifacts
|
|
||||||
**/*.DesktopClient/ModelManifest.xml
|
|
||||||
**/*.Server/GeneratedArtifacts
|
|
||||||
**/*.Server/ModelManifest.xml
|
|
||||||
_Pvt_Extensions
|
|
||||||
|
|
||||||
# Paket dependency manager
|
|
||||||
.paket/paket.exe
|
|
||||||
paket-files/
|
|
||||||
|
|
||||||
# FAKE - F# Make
|
|
||||||
.fake/
|
|
||||||
|
|
||||||
# CodeRush personal settings
|
|
||||||
.cr/personal
|
|
||||||
|
|
||||||
# Python Tools for Visual Studio (PTVS)
|
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
# Cake - Uncomment if you are using it
|
node_modules/
|
||||||
# tools/**
|
dist/
|
||||||
# !tools/packages.config
|
|
||||||
|
|
||||||
# Tabs Studio
|
|
||||||
*.tss
|
|
||||||
|
|
||||||
# Telerik's JustMock configuration file
|
|
||||||
*.jmconfig
|
|
||||||
|
|
||||||
# BizTalk build output
|
|
||||||
*.btp.cs
|
|
||||||
*.btm.cs
|
|
||||||
*.odx.cs
|
|
||||||
*.xsd.cs
|
|
||||||
|
|
||||||
# OpenCover UI analysis results
|
|
||||||
OpenCover/
|
|
||||||
|
|
||||||
# Azure Stream Analytics local run output
|
|
||||||
ASALocalRun/
|
|
||||||
|
|
||||||
# MSBuild Binary and Structured Log
|
|
||||||
*.binlog
|
|
||||||
|
|
||||||
# NVidia Nsight GPU debugger configuration file
|
|
||||||
*.nvuser
|
|
||||||
|
|
||||||
# MFractors (Xamarin productivity tool) working folder
|
|
||||||
.mfractor/
|
|
||||||
|
|
||||||
# Local History for Visual Studio
|
|
||||||
.localhistory/
|
|
||||||
|
|
||||||
# Visual Studio History (VSHistory) files
|
|
||||||
.vshistory/
|
|
||||||
|
|
||||||
# BeatPulse healthcheck temp database
|
|
||||||
healthchecksdb
|
|
||||||
|
|
||||||
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
|
||||||
MigrationBackup/
|
|
||||||
|
|
||||||
# Ionide (cross platform F# VS Code tools) working folder
|
|
||||||
.ionide/
|
|
||||||
|
|
||||||
# Fody - auto-generated XML schema
|
|
||||||
FodyWeavers.xsd
|
|
||||||
|
|
||||||
# VS Code files for those working on multiple tools
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/settings.json
|
|
||||||
!.vscode/tasks.json
|
|
||||||
!.vscode/launch.json
|
|
||||||
!.vscode/extensions.json
|
|
||||||
*.code-workspace
|
|
||||||
|
|
||||||
# Local History for Visual Studio Code
|
|
||||||
.history/
|
|
||||||
|
|
||||||
# Windows Installer files from build outputs
|
|
||||||
*.cab
|
|
||||||
*.msi
|
|
||||||
*.msix
|
|
||||||
*.msm
|
|
||||||
*.msp
|
|
||||||
|
|
||||||
# JetBrains Rider
|
|
||||||
*.sln.iml
|
|
||||||
|
|
||||||
|
# Editor und Betriebssystem
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
*.zip
|
||||||
|
|||||||
232
LICENSE
232
LICENSE
@@ -1,232 +0,0 @@
|
|||||||
GNU GENERAL PUBLIC LICENSE
|
|
||||||
Version 3, 29 June 2007
|
|
||||||
|
|
||||||
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
|
||||||
|
|
||||||
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
|
|
||||||
|
|
||||||
Preamble
|
|
||||||
|
|
||||||
The GNU General Public License is a free, copyleft license for software and other kinds of works.
|
|
||||||
|
|
||||||
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
|
|
||||||
|
|
||||||
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
|
|
||||||
|
|
||||||
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
|
|
||||||
|
|
||||||
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
|
|
||||||
|
|
||||||
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
|
|
||||||
|
|
||||||
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
|
|
||||||
|
|
||||||
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
|
|
||||||
|
|
||||||
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
|
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and modification follow.
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
0. Definitions.
|
|
||||||
|
|
||||||
“This License” refers to version 3 of the GNU General Public License.
|
|
||||||
|
|
||||||
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
|
|
||||||
|
|
||||||
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
|
|
||||||
|
|
||||||
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
|
|
||||||
|
|
||||||
A “covered work” means either the unmodified Program or a work based on the Program.
|
|
||||||
|
|
||||||
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
|
|
||||||
|
|
||||||
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
|
|
||||||
|
|
||||||
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
|
|
||||||
|
|
||||||
1. Source Code.
|
|
||||||
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
|
|
||||||
|
|
||||||
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
|
|
||||||
|
|
||||||
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
|
|
||||||
|
|
||||||
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
|
|
||||||
|
|
||||||
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
|
|
||||||
|
|
||||||
The Corresponding Source for a work in source code form is that same work.
|
|
||||||
|
|
||||||
2. Basic Permissions.
|
|
||||||
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
|
|
||||||
|
|
||||||
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
|
|
||||||
|
|
||||||
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
|
|
||||||
|
|
||||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
|
||||||
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
|
|
||||||
|
|
||||||
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
|
|
||||||
|
|
||||||
4. Conveying Verbatim Copies.
|
|
||||||
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
|
|
||||||
|
|
||||||
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
|
|
||||||
|
|
||||||
5. Conveying Modified Source Versions.
|
|
||||||
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
|
|
||||||
|
|
||||||
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
|
|
||||||
|
|
||||||
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
|
|
||||||
|
|
||||||
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
|
|
||||||
|
|
||||||
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
|
|
||||||
|
|
||||||
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
|
|
||||||
|
|
||||||
6. Conveying Non-Source Forms.
|
|
||||||
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
|
|
||||||
|
|
||||||
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
|
|
||||||
|
|
||||||
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
|
|
||||||
|
|
||||||
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
|
|
||||||
|
|
||||||
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
|
|
||||||
|
|
||||||
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
|
|
||||||
|
|
||||||
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
|
|
||||||
|
|
||||||
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
|
|
||||||
|
|
||||||
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
|
|
||||||
|
|
||||||
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
|
|
||||||
|
|
||||||
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
|
|
||||||
|
|
||||||
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
|
|
||||||
|
|
||||||
7. Additional Terms.
|
|
||||||
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
|
|
||||||
|
|
||||||
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
|
|
||||||
|
|
||||||
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
|
|
||||||
|
|
||||||
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
|
|
||||||
|
|
||||||
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
|
|
||||||
|
|
||||||
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
|
|
||||||
|
|
||||||
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
|
|
||||||
|
|
||||||
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
|
|
||||||
|
|
||||||
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
|
|
||||||
|
|
||||||
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
|
|
||||||
|
|
||||||
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
|
|
||||||
|
|
||||||
8. Termination.
|
|
||||||
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
|
|
||||||
|
|
||||||
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
|
|
||||||
|
|
||||||
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
|
|
||||||
|
|
||||||
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
|
|
||||||
|
|
||||||
9. Acceptance Not Required for Having Copies.
|
|
||||||
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
|
|
||||||
|
|
||||||
10. Automatic Licensing of Downstream Recipients.
|
|
||||||
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
|
|
||||||
|
|
||||||
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
|
|
||||||
|
|
||||||
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
|
|
||||||
|
|
||||||
11. Patents.
|
|
||||||
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
|
|
||||||
|
|
||||||
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
|
|
||||||
|
|
||||||
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
|
|
||||||
|
|
||||||
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
|
|
||||||
|
|
||||||
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
|
|
||||||
|
|
||||||
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
|
|
||||||
|
|
||||||
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
|
|
||||||
|
|
||||||
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
|
|
||||||
|
|
||||||
12. No Surrender of Others' Freedom.
|
|
||||||
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
|
|
||||||
|
|
||||||
13. Use with the GNU Affero General Public License.
|
|
||||||
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
|
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
|
||||||
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
|
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
|
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
|
|
||||||
|
|
||||||
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
|
|
||||||
|
|
||||||
15. Disclaimer of Warranty.
|
|
||||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
|
||||||
|
|
||||||
16. Limitation of Liability.
|
|
||||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
|
||||||
|
|
||||||
17. Interpretation of Sections 15 and 16.
|
|
||||||
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
How to Apply These Terms to Your New Programs
|
|
||||||
|
|
||||||
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
|
|
||||||
|
|
||||||
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
|
|
||||||
|
|
||||||
einkaufsapp
|
|
||||||
Copyright (C) 2026 marco.morath
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
|
||||||
|
|
||||||
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
|
|
||||||
|
|
||||||
einkaufsapp Copyright (C) 2026 marco.morath
|
|
||||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
|
||||||
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
|
|
||||||
|
|
||||||
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <https://www.gnu.org/philosophy/why-not-lgpl.html>.
|
|
||||||
22
backend/Dockerfile
Normal file
22
backend/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Nicht als root laufen lassen.
|
||||||
|
RUN useradd --create-home --uid 10001 appuser \
|
||||||
|
&& chown -R appuser:appuser /app
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Ueber "sh" aufrufen: das Bind-Mount ./backend:/app ueberschreibt /app
|
||||||
|
# samt Dateirechten, ein x-Bit aus dem Image waere dann wirkungslos.
|
||||||
|
CMD ["sh", "/app/entrypoint.sh"]
|
||||||
40
backend/alembic.ini
Normal file
40
backend/alembic.ini
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
|
||||||
|
# Kein sqlalchemy.url hier: die Verbindungsdaten kommen aus der
|
||||||
|
# Umgebung und werden in alembic/env.py direkt an create_engine()
|
||||||
|
# uebergeben. configparser wuerde sonst an "%" im Passwort scheitern.
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARNING
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARNING
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
52
backend/alembic/env.py
Normal file
52
backend/alembic/env.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import create_engine, pool
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.db import Base
|
||||||
|
import app.models # noqa: F401 - registriert die Tabellen bei Base
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# WICHTIG: die Datenbank-URL NICHT ueber config.set_main_option() setzen.
|
||||||
|
# alembic.ini wird von configparser mit BasicInterpolation gelesen, und
|
||||||
|
# der deutet jedes "%" als Variablenreferenz. Ein URL-kodiertes Passwort
|
||||||
|
# (z.B. "%21" fuer "!") laesst den Aufruf mit
|
||||||
|
# ValueError: invalid interpolation syntax
|
||||||
|
# scheitern. Die Engine wird deshalb hier direkt gebaut.
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
context.configure(
|
||||||
|
url=settings.database_url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
connectable = create_engine(settings.database_url, poolclass=pool.NullPool)
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
compare_type=True,
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
connectable.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
24
backend/alembic/script.py.mako
Normal file
24
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: str | None = ${repr(down_revision)}
|
||||||
|
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
|
||||||
|
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
102
backend/alembic/versions/0001_initial.py
Normal file
102
backend/alembic/versions/0001_initial.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"""Phase 1: Benutzer, Sessions, Mail-Token, Einstellungen, Rate Limiting
|
||||||
|
|
||||||
|
Revision ID: 0001
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-08-07
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0001"
|
||||||
|
down_revision: str | None = None
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"user",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("email", sa.String(255), nullable=False),
|
||||||
|
sa.Column("display_name", sa.String(80), nullable=True),
|
||||||
|
sa.Column("password_hash", sa.String(255), nullable=False),
|
||||||
|
sa.Column("verified_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("is_admin", sa.Boolean(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("1")),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
sa.UniqueConstraint("email", name="uq_user_email"),
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_unicode_ci",
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"user_session",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("token_hash", sa.String(64), nullable=False),
|
||||||
|
sa.Column("user_id", sa.String(36), nullable=False),
|
||||||
|
sa.Column("csrf_token", sa.String(64), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
sa.Column("last_seen_at", sa.DateTime(), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
sa.Column("expires_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
|
||||||
|
sa.UniqueConstraint("token_hash", name="uq_user_session_token"),
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_unicode_ci",
|
||||||
|
)
|
||||||
|
op.create_index("ix_user_session_expires", "user_session", ["expires_at"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"email_token",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("token_hash", sa.String(64), nullable=False),
|
||||||
|
sa.Column("user_id", sa.String(36), nullable=False),
|
||||||
|
sa.Column("purpose", sa.String(32), nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("used_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
|
||||||
|
sa.UniqueConstraint("token_hash", name="uq_email_token_token"),
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_unicode_ci",
|
||||||
|
)
|
||||||
|
op.create_index("ix_email_token_user_purpose", "email_token", ["user_id", "purpose"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"setting",
|
||||||
|
sa.Column("key", sa.String(64), primary_key=True),
|
||||||
|
sa.Column("value", sa.String(255), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_unicode_ci",
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"rate_limit",
|
||||||
|
sa.Column("bucket", sa.String(160), primary_key=True),
|
||||||
|
sa.Column("window_start", sa.DateTime(), primary_key=True),
|
||||||
|
sa.Column("count", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_unicode_ci",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("rate_limit")
|
||||||
|
op.drop_table("setting")
|
||||||
|
op.drop_index("ix_email_token_user_purpose", table_name="email_token")
|
||||||
|
op.drop_table("email_token")
|
||||||
|
op.drop_index("ix_user_session_expires", table_name="user_session")
|
||||||
|
op.drop_table("user_session")
|
||||||
|
op.drop_table("user")
|
||||||
153
backend/alembic/versions/0002_lists.py
Normal file
153
backend/alembic/versions/0002_lists.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"""Phase 2: Listen, Mitgliedschaften, Märkte, Warengruppen, Artikel, Einträge
|
||||||
|
|
||||||
|
Revision ID: 0002
|
||||||
|
Revises: 0001
|
||||||
|
Create Date: 2026-08-07
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0002"
|
||||||
|
down_revision: str | None = "0001"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
MYSQL = {
|
||||||
|
"mysql_engine": "InnoDB",
|
||||||
|
"mysql_charset": "utf8mb4",
|
||||||
|
"mysql_collate": "utf8mb4_unicode_ci",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"shopping_list",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(120), nullable=False),
|
||||||
|
sa.Column("owner_id", sa.String(36), nullable=False),
|
||||||
|
sa.Column("rev", sa.BigInteger(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("deleted_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["owner_id"], ["user.id"], ondelete="RESTRICT"),
|
||||||
|
**MYSQL,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"list_member",
|
||||||
|
sa.Column("list_id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("user_id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("role", sa.String(16), nullable=False, server_default="editor"),
|
||||||
|
sa.Column("joined_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
|
||||||
|
**MYSQL,
|
||||||
|
)
|
||||||
|
op.create_index("ix_list_member_user", "list_member", ["user_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"market",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("list_id", sa.String(36), nullable=False),
|
||||||
|
sa.Column("name", sa.String(120), nullable=False),
|
||||||
|
sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("row_rev", sa.BigInteger(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("deleted_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
|
||||||
|
sa.UniqueConstraint("list_id", "name", name="uq_market_list_name"),
|
||||||
|
**MYSQL,
|
||||||
|
)
|
||||||
|
op.create_index("ix_market_list_rev", "market", ["list_id", "row_rev"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"category",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("list_id", sa.String(36), nullable=False),
|
||||||
|
sa.Column("name", sa.String(120), nullable=False),
|
||||||
|
sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("row_rev", sa.BigInteger(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("deleted_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
|
||||||
|
sa.UniqueConstraint("list_id", "name", name="uq_category_list_name"),
|
||||||
|
**MYSQL,
|
||||||
|
)
|
||||||
|
op.create_index("ix_category_list_rev", "category", ["list_id", "row_rev"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"article",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("list_id", sa.String(36), nullable=False),
|
||||||
|
sa.Column("name", sa.String(200), nullable=False),
|
||||||
|
sa.Column("barcode", sa.String(64), nullable=True),
|
||||||
|
sa.Column("note", sa.String(500), nullable=True),
|
||||||
|
sa.Column("default_market_id", sa.String(36), nullable=True),
|
||||||
|
sa.Column("default_category_id", sa.String(36), nullable=True),
|
||||||
|
sa.Column("row_rev", sa.BigInteger(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("deleted_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["default_market_id"], ["market.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["default_category_id"], ["category.id"], ondelete="SET NULL"),
|
||||||
|
sa.UniqueConstraint("list_id", "name", name="uq_article_list_name"),
|
||||||
|
**MYSQL,
|
||||||
|
)
|
||||||
|
op.create_index("ix_article_list_rev", "article", ["list_id", "row_rev"])
|
||||||
|
op.create_index("ix_article_barcode", "article", ["list_id", "barcode"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"article_attribute",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("article_id", sa.String(36), nullable=False),
|
||||||
|
sa.Column("attr_name", sa.String(80), nullable=False),
|
||||||
|
sa.Column("attr_value", sa.String(300), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["article_id"], ["article.id"], ondelete="CASCADE"),
|
||||||
|
sa.UniqueConstraint("article_id", "attr_name", name="uq_article_attr"),
|
||||||
|
**MYSQL,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"article_market",
|
||||||
|
sa.Column("article_id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("market_id", sa.String(36), primary_key=True),
|
||||||
|
sa.ForeignKeyConstraint(["article_id"], ["article.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["market_id"], ["market.id"], ondelete="CASCADE"),
|
||||||
|
**MYSQL,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"list_item",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("list_id", sa.String(36), nullable=False),
|
||||||
|
sa.Column("article_id", sa.String(36), nullable=False),
|
||||||
|
sa.Column("market_id", sa.String(36), nullable=True),
|
||||||
|
sa.Column("category_id", sa.String(36), nullable=True),
|
||||||
|
sa.Column("quantity", sa.Numeric(10, 3), nullable=True),
|
||||||
|
sa.Column("unit", sa.String(32), nullable=True),
|
||||||
|
sa.Column("note", sa.String(500), nullable=True),
|
||||||
|
sa.Column("status", sa.String(16), nullable=False, server_default="open"),
|
||||||
|
sa.Column("price_cents", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("row_rev", sa.BigInteger(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("deleted_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["article_id"], ["article.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["market_id"], ["market.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["category_id"], ["category.id"], ondelete="SET NULL"),
|
||||||
|
**MYSQL,
|
||||||
|
)
|
||||||
|
op.create_index("ix_list_item_list_rev", "list_item", ["list_id", "row_rev"])
|
||||||
|
op.create_index("ix_list_item_list_status", "list_item", ["list_id", "status"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("list_item")
|
||||||
|
op.drop_table("article_market")
|
||||||
|
op.drop_table("article_attribute")
|
||||||
|
op.drop_table("article")
|
||||||
|
op.drop_table("category")
|
||||||
|
op.drop_table("market")
|
||||||
|
op.drop_index("ix_list_member_user", table_name="list_member")
|
||||||
|
op.drop_table("list_member")
|
||||||
|
op.drop_table("shopping_list")
|
||||||
31
backend/alembic/versions/0003_must_change_password.py
Normal file
31
backend/alembic/versions/0003_must_change_password.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""Phase 2b: Zwang zum Passwortwechsel beim initial angelegten Admin
|
||||||
|
|
||||||
|
Revision ID: 0003
|
||||||
|
Revises: 0002
|
||||||
|
Create Date: 2026-08-07
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0003"
|
||||||
|
down_revision: str | None = "0002"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"user",
|
||||||
|
sa.Column(
|
||||||
|
"must_change_password",
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("0"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("user", "must_change_password")
|
||||||
60
backend/alembic/versions/0004_sharing.py
Normal file
60
backend/alembic/versions/0004_sharing.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""Phase 5: Einladungen, Eigentümerwechsel, Ersteller je Eintrag
|
||||||
|
|
||||||
|
Revision ID: 0004
|
||||||
|
Revises: 0003
|
||||||
|
Create Date: 2026-08-08
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0004"
|
||||||
|
down_revision: str | None = "0003"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"list_item",
|
||||||
|
sa.Column("created_by", sa.String(36), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_list_item_created_by", "list_item", "user",
|
||||||
|
["created_by"], ["id"], ondelete="SET NULL",
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"list_invite",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("list_id", sa.String(36), nullable=False),
|
||||||
|
sa.Column("email", sa.String(255), nullable=False),
|
||||||
|
sa.Column("role", sa.String(16), nullable=False, server_default="editor"),
|
||||||
|
sa.Column("token_hash", sa.String(64), nullable=False),
|
||||||
|
sa.Column("invited_by", sa.String(36), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("last_sent_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("send_count", sa.Integer(), nullable=False, server_default=sa.text("1")),
|
||||||
|
sa.Column("expires_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("accepted_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("accepted_by", sa.String(36), nullable=True),
|
||||||
|
sa.Column("revoked_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["invited_by"], ["user.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["accepted_by"], ["user.id"], ondelete="SET NULL"),
|
||||||
|
sa.UniqueConstraint("token_hash", name="uq_list_invite_token"),
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_unicode_ci",
|
||||||
|
)
|
||||||
|
op.create_index("ix_list_invite_list", "list_invite", ["list_id"])
|
||||||
|
op.create_index("ix_list_invite_email", "list_invite", ["email"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_list_invite_email", table_name="list_invite")
|
||||||
|
op.drop_index("ix_list_invite_list", table_name="list_invite")
|
||||||
|
op.drop_table("list_invite")
|
||||||
|
op.drop_constraint("fk_list_item_created_by", "list_item", type_="foreignkey")
|
||||||
|
op.drop_column("list_item", "created_by")
|
||||||
56
backend/alembic/versions/0005_public_links.py
Normal file
56
backend/alembic/versions/0005_public_links.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""Phase 5b: Öffentliche Ansichtslinks und Zusatzrecht zum Teilen
|
||||||
|
|
||||||
|
Revision ID: 0005
|
||||||
|
Revises: 0004
|
||||||
|
Create Date: 2026-08-08
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0005"
|
||||||
|
down_revision: str | None = "0004"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"list_member",
|
||||||
|
sa.Column(
|
||||||
|
"may_share_public", sa.Boolean(), nullable=False,
|
||||||
|
server_default=sa.text("0"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# Eigentuemer haben das Recht immer - der Vollstaendigkeit halber
|
||||||
|
# auch in der Spalte, damit Abfragen einheitlich bleiben.
|
||||||
|
op.execute("UPDATE list_member SET may_share_public = 1 WHERE role = 'owner'")
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"public_share",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("list_id", sa.String(36), nullable=False),
|
||||||
|
sa.Column("token_hash", sa.String(64), nullable=False),
|
||||||
|
sa.Column("label", sa.String(120), nullable=True),
|
||||||
|
sa.Column("allow_check", sa.Boolean(), nullable=False, server_default=sa.text("1")),
|
||||||
|
sa.Column("created_by", sa.String(36), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("expires_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("revoked_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("last_access_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("access_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["created_by"], ["user.id"], ondelete="SET NULL"),
|
||||||
|
sa.UniqueConstraint("token_hash", name="uq_public_share_token"),
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_unicode_ci",
|
||||||
|
)
|
||||||
|
op.create_index("ix_public_share_list", "public_share", ["list_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_public_share_list", table_name="public_share")
|
||||||
|
op.drop_table("public_share")
|
||||||
|
op.drop_column("list_member", "may_share_public")
|
||||||
39
backend/alembic/versions/0006_sync.py
Normal file
39
backend/alembic/versions/0006_sync.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""Phase 4: Idempotenz-Quittungen für die Outbox
|
||||||
|
|
||||||
|
Revision ID: 0006
|
||||||
|
Revises: 0005
|
||||||
|
Create Date: 2026-08-08
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0006"
|
||||||
|
down_revision: str | None = "0005"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"applied_op",
|
||||||
|
sa.Column("op_id", sa.String(64), primary_key=True),
|
||||||
|
sa.Column("list_id", sa.String(36), nullable=False),
|
||||||
|
sa.Column("user_id", sa.String(36), nullable=True),
|
||||||
|
sa.Column("kind", sa.String(32), nullable=False),
|
||||||
|
sa.Column("result_id", sa.String(36), nullable=True),
|
||||||
|
sa.Column("rev", sa.BigInteger(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="SET NULL"),
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_unicode_ci",
|
||||||
|
)
|
||||||
|
op.create_index("ix_applied_op_list", "applied_op", ["list_id", "created_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_applied_op_list", table_name="applied_op")
|
||||||
|
op.drop_table("applied_op")
|
||||||
23
backend/alembic/versions/0007_item_variant.py
Normal file
23
backend/alembic/versions/0007_item_variant.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"""Phase 6b: Eigenschaft je Listeneintrag
|
||||||
|
|
||||||
|
Revision ID: 0007
|
||||||
|
Revises: 0006
|
||||||
|
Create Date: 2026-08-08
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0007"
|
||||||
|
down_revision: str | None = "0006"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("list_item", sa.Column("variant", sa.String(200), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("list_item", "variant")
|
||||||
37
backend/alembic/versions/0008_product_cache.py
Normal file
37
backend/alembic/versions/0008_product_cache.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""Phase 6c: Zwischenspeicher für Produktabfragen
|
||||||
|
|
||||||
|
Revision ID: 0008
|
||||||
|
Revises: 0007
|
||||||
|
Create Date: 2026-08-08
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0008"
|
||||||
|
down_revision: str | None = "0007"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"product_cache",
|
||||||
|
sa.Column("barcode", sa.String(64), primary_key=True),
|
||||||
|
sa.Column("found", sa.Boolean(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("name", sa.String(300), nullable=True),
|
||||||
|
sa.Column("brand", sa.String(200), nullable=True),
|
||||||
|
sa.Column("package", sa.String(120), nullable=True),
|
||||||
|
sa.Column("quantity", sa.Numeric(10, 3), nullable=True),
|
||||||
|
sa.Column("unit", sa.String(32), nullable=True),
|
||||||
|
sa.Column("source", sa.String(32), nullable=False, server_default="openfoodfacts"),
|
||||||
|
sa.Column("fetched_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_unicode_ci",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("product_cache")
|
||||||
45
backend/alembic/versions/0009_prices.py
Normal file
45
backend/alembic/versions/0009_prices.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"""Phase 7: Preisdatenbank
|
||||||
|
|
||||||
|
Revision ID: 0009
|
||||||
|
Revises: 0008
|
||||||
|
Create Date: 2026-08-08
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0009"
|
||||||
|
down_revision: str | None = "0008"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"price_point",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("list_id", sa.String(36), nullable=False),
|
||||||
|
sa.Column("article_id", sa.String(36), nullable=False),
|
||||||
|
sa.Column("market_id", sa.String(36), nullable=False),
|
||||||
|
sa.Column("price_cents", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("quantity", sa.Numeric(10, 3), nullable=True),
|
||||||
|
sa.Column("unit", sa.String(32), nullable=True),
|
||||||
|
sa.Column("recorded_at", sa.DateTime(), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["article_id"], ["article.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["market_id"], ["market.id"], ondelete="CASCADE"),
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_unicode_ci",
|
||||||
|
)
|
||||||
|
op.create_index("ix_price_article_market", "price_point",
|
||||||
|
["article_id", "market_id", "recorded_at"])
|
||||||
|
op.create_index("ix_price_list", "price_point", ["list_id", "recorded_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_price_list", table_name="price_point")
|
||||||
|
op.drop_index("ix_price_article_market", table_name="price_point")
|
||||||
|
op.drop_table("price_point")
|
||||||
62
backend/alembic/versions/0010_pack_and_count.py
Normal file
62
backend/alembic/versions/0010_pack_and_count.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""Phase 7b: Stückzahl und Gebinde trennen
|
||||||
|
|
||||||
|
Bis hierher steckten beide in `quantity`/`unit`. Bei "500 ml" zu 2,99 EUR
|
||||||
|
rechnete die Summenbildung 500 × 2,99 = 1495 EUR.
|
||||||
|
|
||||||
|
Die vorhandenen Werte werden als Gebinde übernommen (aus "500 ml" wird
|
||||||
|
pack_size=500, pack_unit=ml) und die Stückzahl auf 1 gesetzt. Das ist die
|
||||||
|
sichere Richtung: Wo bisher tatsächlich eine Stückzahl gemeint war,
|
||||||
|
stimmt die Summe danach - sie wird nur nicht mehr vervielfacht.
|
||||||
|
|
||||||
|
Revision ID: 0010
|
||||||
|
Revises: 0009
|
||||||
|
Create Date: 2026-08-08
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0010"
|
||||||
|
down_revision: str | None = "0009"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"list_item",
|
||||||
|
sa.Column("count", sa.Integer(), nullable=False, server_default=sa.text("1")),
|
||||||
|
)
|
||||||
|
op.alter_column("list_item", "quantity",
|
||||||
|
new_column_name="pack_size", existing_type=sa.Numeric(10, 3))
|
||||||
|
op.alter_column("list_item", "unit",
|
||||||
|
new_column_name="pack_unit", existing_type=sa.String(32))
|
||||||
|
|
||||||
|
op.alter_column("price_point", "quantity",
|
||||||
|
new_column_name="pack_size", existing_type=sa.Numeric(10, 3))
|
||||||
|
op.alter_column("price_point", "unit",
|
||||||
|
new_column_name="pack_unit", existing_type=sa.String(32))
|
||||||
|
|
||||||
|
op.add_column("product_cache", sa.Column("count", sa.Integer(), nullable=True))
|
||||||
|
op.alter_column("product_cache", "quantity",
|
||||||
|
new_column_name="pack_size", existing_type=sa.Numeric(10, 3))
|
||||||
|
op.alter_column("product_cache", "unit",
|
||||||
|
new_column_name="pack_unit", existing_type=sa.String(32))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.alter_column("product_cache", "pack_unit",
|
||||||
|
new_column_name="unit", existing_type=sa.String(32))
|
||||||
|
op.alter_column("product_cache", "pack_size",
|
||||||
|
new_column_name="quantity", existing_type=sa.Numeric(10, 3))
|
||||||
|
op.drop_column("product_cache", "count")
|
||||||
|
op.alter_column("price_point", "pack_unit",
|
||||||
|
new_column_name="unit", existing_type=sa.String(32))
|
||||||
|
op.alter_column("price_point", "pack_size",
|
||||||
|
new_column_name="quantity", existing_type=sa.Numeric(10, 3))
|
||||||
|
op.alter_column("list_item", "pack_unit",
|
||||||
|
new_column_name="unit", existing_type=sa.String(32))
|
||||||
|
op.alter_column("list_item", "pack_size",
|
||||||
|
new_column_name="quantity", existing_type=sa.Numeric(10, 3))
|
||||||
|
op.drop_column("list_item", "count")
|
||||||
55
backend/alembic/versions/0011_push.py
Normal file
55
backend/alembic/versions/0011_push.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"""Phase 8: Push-Benachrichtigungen
|
||||||
|
|
||||||
|
Revision ID: 0011
|
||||||
|
Revises: 0010
|
||||||
|
Create Date: 2026-08-08
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0011"
|
||||||
|
down_revision: str | None = "0010"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"push_subscription",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("user_id", sa.String(36), nullable=False),
|
||||||
|
sa.Column("endpoint", sa.String(500), nullable=False),
|
||||||
|
sa.Column("p256dh", sa.String(200), nullable=False),
|
||||||
|
sa.Column("auth", sa.String(100), nullable=False),
|
||||||
|
sa.Column("label", sa.String(80), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("last_success_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("failure_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
|
||||||
|
sa.UniqueConstraint("endpoint", name="uq_push_subscription_endpoint"),
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_unicode_ci",
|
||||||
|
)
|
||||||
|
op.create_index("ix_push_subscription_user", "push_subscription", ["user_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"notify_state",
|
||||||
|
sa.Column("list_id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("user_id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("last_notified_at", sa.DateTime(), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_unicode_ci",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("notify_state")
|
||||||
|
op.drop_index("ix_push_subscription_user", table_name="push_subscription")
|
||||||
|
op.drop_table("push_subscription")
|
||||||
59
backend/alembic/versions/0012_user_admin.py
Normal file
59
backend/alembic/versions/0012_user_admin.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""Phase 10: Benutzerverwaltung, Adressänderung, automatische Bereinigung
|
||||||
|
|
||||||
|
Revision ID: 0012
|
||||||
|
Revises: 0011
|
||||||
|
Create Date: 2026-08-08
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0012"
|
||||||
|
down_revision: str | None = "0011"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("user", sa.Column("last_seen_at", sa.DateTime(), nullable=True))
|
||||||
|
op.add_column("user", sa.Column("deactivated_at", sa.DateTime(), nullable=True))
|
||||||
|
|
||||||
|
# Bestandskonten nicht sofort der automatischen Deaktivierung
|
||||||
|
# aussetzen: Ohne Startwert waere last_seen_at NULL und damit
|
||||||
|
# scheinbar "nie benutzt". Das Erstellungsdatum ist die
|
||||||
|
# konservativere Annahme.
|
||||||
|
op.execute("UPDATE `user` SET last_seen_at = created_at WHERE last_seen_at IS NULL")
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"email_change",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("user_id", sa.String(36), nullable=False),
|
||||||
|
sa.Column("old_email", sa.String(255), nullable=False),
|
||||||
|
sa.Column("new_email", sa.String(255), nullable=False),
|
||||||
|
sa.Column("token_new_hash", sa.String(64), nullable=False),
|
||||||
|
sa.Column("token_old_hash", sa.String(64), nullable=True),
|
||||||
|
sa.Column("requires_old", sa.Boolean(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("confirmed_new_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("confirmed_old_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("applied_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("cancelled_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("requested_by", sa.String(36), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("expires_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["requested_by"], ["user.id"], ondelete="SET NULL"),
|
||||||
|
sa.UniqueConstraint("token_new_hash", name="uq_email_change_token_new"),
|
||||||
|
sa.UniqueConstraint("token_old_hash", name="uq_email_change_token_old"),
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_unicode_ci",
|
||||||
|
)
|
||||||
|
op.create_index("ix_email_change_user", "email_change", ["user_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_email_change_user", table_name="email_change")
|
||||||
|
op.drop_table("email_change")
|
||||||
|
op.drop_column("user", "deactivated_at")
|
||||||
|
op.drop_column("user", "last_seen_at")
|
||||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
78
backend/app/bootstrap_admin.py
Normal file
78
backend/app/bootstrap_admin.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""Anlegen des ersten Administratorkontos beim Start.
|
||||||
|
|
||||||
|
Bewusst eng gefasst: Das Konto entsteht nur, wenn die Datenbank noch
|
||||||
|
*gar keinen* Nutzer enthält. Damit kann ein vergessener Eintrag in der
|
||||||
|
.env kein bestehendes Konto überschreiben und auch kein zweites
|
||||||
|
Admin-Konto nachschieben.
|
||||||
|
|
||||||
|
Das angelegte Konto trägt `must_change_password`. Bis zum Wechsel sind
|
||||||
|
alle Routen gesperrt, die an `verified_user` hängen - also alles außer
|
||||||
|
"eigenes Profil lesen" und "Passwort ändern". Der Wert in der .env ist
|
||||||
|
nach der ersten Anmeldung damit wertlos.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.models import User
|
||||||
|
from app.security import hash_password, normalize_email, utcnow
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MIN_LENGTH = 12
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_initial_admin(db: Session) -> None:
|
||||||
|
password = settings.initial_admin_password
|
||||||
|
user_count = db.scalar(select(func.count()).select_from(User)) or 0
|
||||||
|
email = normalize_email(settings.admin_email)
|
||||||
|
|
||||||
|
if user_count > 0:
|
||||||
|
if password:
|
||||||
|
log.warning(
|
||||||
|
"ADMIN_INITIAL_PASSWORD ist gesetzt, es existieren aber bereits "
|
||||||
|
"%d Konten - der Wert wird ignoriert. Entferne ihn aus der .env, "
|
||||||
|
"damit kein Passwort unnötig in der Umgebung steht.",
|
||||||
|
user_count,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not password:
|
||||||
|
log.info(
|
||||||
|
"Keine Konten vorhanden und kein ADMIN_INITIAL_PASSWORD gesetzt. "
|
||||||
|
"Erstes Konto über POST /api/auth/register anlegen (%s wird dabei "
|
||||||
|
"automatisch Administrator).",
|
||||||
|
email,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if len(password) < MIN_LENGTH:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"ADMIN_INITIAL_PASSWORD ist zu kurz (mindestens {MIN_LENGTH} Zeichen)."
|
||||||
|
)
|
||||||
|
|
||||||
|
admin = User(
|
||||||
|
email=email,
|
||||||
|
display_name="Administrator",
|
||||||
|
password_hash=hash_password(password),
|
||||||
|
# Die Adresse gilt als bestätigt: Wer die .env schreiben kann,
|
||||||
|
# hat ohnehin vollen Zugriff. Ein Verifikationsumweg brächte hier
|
||||||
|
# keinen Sicherheitsgewinn, nur eine Abhängigkeit vom Mailversand
|
||||||
|
# bei der Ersteinrichtung.
|
||||||
|
verified_at=utcnow(),
|
||||||
|
is_admin=True,
|
||||||
|
must_change_password=True,
|
||||||
|
)
|
||||||
|
db.add(admin)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
log.warning(
|
||||||
|
"Administratorkonto %s aus ADMIN_INITIAL_PASSWORD angelegt. "
|
||||||
|
"Das Passwort MUSS bei der ersten Anmeldung geändert werden; bis dahin "
|
||||||
|
"sind alle übrigen Funktionen gesperrt. Danach den Wert aus der .env "
|
||||||
|
"entfernen.",
|
||||||
|
email,
|
||||||
|
)
|
||||||
196
backend/app/config.py
Normal file
196
backend/app/config.py
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
from email.utils import parseaddr
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import field_validator, model_validator
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_file=None, extra="ignore")
|
||||||
|
|
||||||
|
# ---------------- Datenbank ----------------
|
||||||
|
db_host: str = "db"
|
||||||
|
db_port: int = 3306
|
||||||
|
db_name: str = "einkaufsapp"
|
||||||
|
db_user: str = "einkaufsapp"
|
||||||
|
db_password: str = ""
|
||||||
|
|
||||||
|
# ---------------- Anwendung ----------------
|
||||||
|
# Erscheint in der Oberflaeche, im Browsertitel, auf dem
|
||||||
|
# Startbildschirm und in allen Mails. Eine Stelle fuer alles.
|
||||||
|
app_name: str = "Einkaufsliste"
|
||||||
|
# Kuerzere Fassung fuer das Symbol auf dem Startbildschirm - dort ist
|
||||||
|
# nur wenig Platz. Leer = app_name.
|
||||||
|
app_short_name: str = ""
|
||||||
|
|
||||||
|
# Nachschlagen von Strichcodes in einer oeffentlichen Datenbank.
|
||||||
|
# "off" aus - nur der eigene Artikelstamm
|
||||||
|
# "openfoodfacts" Open Food Facts, ueber diesen Server abgefragt
|
||||||
|
product_lookup: Literal["off", "openfoodfacts"] = "openfoodfacts"
|
||||||
|
# Wie lange ein Treffer gilt, bevor erneut gefragt wird.
|
||||||
|
product_cache_days: int = 180
|
||||||
|
# Wie lange ein Fehlschlag gilt. Kuerzer, weil ein Produkt spaeter
|
||||||
|
# eingepflegt worden sein kann.
|
||||||
|
product_miss_days: int = 14
|
||||||
|
product_lookup_timeout: int = 6
|
||||||
|
|
||||||
|
public_base_url: str = "http://localhost:8000"
|
||||||
|
# Kein SECRET_KEY: Sitzungen und CSRF arbeiten mit serverseitig
|
||||||
|
# gespeicherten Zufallstoken (32 Byte aus secrets.token_urlsafe),
|
||||||
|
# nicht mit signierten Werten. Ein Signierschlüssel hätte hier keine
|
||||||
|
# Aufgabe - und eine Einstellung, die Wichtigkeit vortäuscht, ist
|
||||||
|
# schlechter als keine. Falls später etwas signiert werden soll,
|
||||||
|
# gehört er wieder hinein.
|
||||||
|
session_days: int = 30
|
||||||
|
cookie_secure: bool = False
|
||||||
|
|
||||||
|
admin_email: str = "admin@example.com"
|
||||||
|
# Nur beim allerersten Start verwendet - und auch dann nur, wenn noch
|
||||||
|
# kein einziger Nutzer existiert. Das angelegte Konto muss das
|
||||||
|
# Passwort bei der ersten Anmeldung wechseln, danach ist der Wert
|
||||||
|
# hier wertlos und sollte entfernt werden.
|
||||||
|
admin_initial_password: str = ""
|
||||||
|
# Alternative fuer Docker Secrets: Pfad zu einer Datei, deren Inhalt
|
||||||
|
# als Passwort dient. Hat Vorrang vor admin_initial_password.
|
||||||
|
admin_initial_password_file: str = ""
|
||||||
|
|
||||||
|
# "true" = Selbstregistrierung fest an, Admin kann nichts aendern
|
||||||
|
# "false" = fest aus, Admin kann nichts aendern
|
||||||
|
# "admin" = die Datenbankeinstellung entscheidet, Admin darf umschalten
|
||||||
|
allow_self_registration: Literal["true", "false", "admin"] = "admin"
|
||||||
|
# Startwert fuer den Modus "admin", greift nur beim allerersten Start.
|
||||||
|
self_registration_default: bool = True
|
||||||
|
|
||||||
|
# ---------------- Aufraeumen ----------------
|
||||||
|
# Wie lange weich geloeschte Daten aufbewahrt werden. Solange kann
|
||||||
|
# ein Geraet offline bleiben und beim naechsten Abgleich noch
|
||||||
|
# erfahren, dass etwas verschwunden ist.
|
||||||
|
cleanup_deleted_days: int = 30
|
||||||
|
# Quittungen der Outbox. Kuerzer, weil ein Geraet seine Operationen
|
||||||
|
# nach so langer Zeit ohnehin aufgegeben haette.
|
||||||
|
cleanup_ops_days: int = 7
|
||||||
|
# Abstand zwischen zwei Durchlaeufen.
|
||||||
|
cleanup_interval_hours: int = 24
|
||||||
|
|
||||||
|
# ---------------- Push-Benachrichtigungen ----------------
|
||||||
|
# Erzeugen mit: python3 tools/vapid-keys.py
|
||||||
|
# Leer = Push abgeschaltet.
|
||||||
|
vapid_public_key: str = ""
|
||||||
|
vapid_private_key: str = ""
|
||||||
|
# Kontaktadresse fuer die Push-Dienste der Browserhersteller.
|
||||||
|
vapid_subject: str = ""
|
||||||
|
# Innerhalb dieser Spanne bekommt ein Geraet hoechstens eine
|
||||||
|
# Benachrichtigung je Liste - nicht bei jeder einzelnen Aenderung.
|
||||||
|
push_throttle_hours: int = 2
|
||||||
|
push_timeout: int = 10
|
||||||
|
|
||||||
|
# ---------------- SMTP-Relay ----------------
|
||||||
|
smtp_host: str = "mailpit"
|
||||||
|
smtp_port: int = 1025
|
||||||
|
smtp_user: str = ""
|
||||||
|
smtp_password: str = ""
|
||||||
|
# "none" = unverschluesselt (nur fuer lokale Testrelays)
|
||||||
|
# "starttls" = Klartextverbindung, dann Upgrade (typisch Port 587)
|
||||||
|
# "ssl" = TLS von Anfang an, "implicit TLS" (typisch Port 465)
|
||||||
|
smtp_security: Literal["none", "starttls", "ssl"] = "none"
|
||||||
|
smtp_timeout: int = 20
|
||||||
|
# Hostname im EHLO. Viele Relays und Spamfilter erwarten hier einen
|
||||||
|
# FQDN, der zur sendenden Domain passt - nicht die Container-ID.
|
||||||
|
smtp_helo_hostname: str = ""
|
||||||
|
|
||||||
|
# Absender in der Kopfzeile (das, was der Empfaenger sieht)
|
||||||
|
smtp_from: str = "einkaufsapp@example.com"
|
||||||
|
# Leer = app_name wird verwendet.
|
||||||
|
smtp_from_name: str = ""
|
||||||
|
# Envelope-Absender / Return-Path fuer Bounces. Leer = wie smtp_from.
|
||||||
|
# SPF wird gegen DIESE Adresse geprueft, nicht gegen den From-Header.
|
||||||
|
smtp_envelope_from: str = ""
|
||||||
|
# Optional: Antworten sollen woanders hingehen als an den Absender.
|
||||||
|
smtp_reply_to: str = ""
|
||||||
|
smtp_max_retries: int = 3
|
||||||
|
|
||||||
|
@field_validator("allow_self_registration", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_registration(cls, v: object) -> str:
|
||||||
|
s = str(v).strip().lower()
|
||||||
|
if s in {"1", "yes", "on", "enabled"}:
|
||||||
|
return "true"
|
||||||
|
if s in {"0", "no", "off", "disabled"}:
|
||||||
|
return "false"
|
||||||
|
if s in {"", "db", "database", "runtime", "managed"}:
|
||||||
|
return "admin"
|
||||||
|
return s
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _check_mail(self) -> "Settings":
|
||||||
|
if not parseaddr(self.smtp_from)[1]:
|
||||||
|
raise ValueError("SMTP_FROM ist keine gueltige E-Mail-Adresse")
|
||||||
|
if self.smtp_user and self.smtp_security == "none":
|
||||||
|
raise ValueError(
|
||||||
|
"SMTP_USER gesetzt, aber SMTP_SECURITY=none - "
|
||||||
|
"Zugangsdaten duerfen nicht unverschluesselt uebertragen werden. "
|
||||||
|
"Setze SMTP_SECURITY auf starttls oder ssl."
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
@property
|
||||||
|
def push_enabled(self) -> bool:
|
||||||
|
return bool(self.vapid_public_key and self.vapid_private_key)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def vapid_contact(self) -> str:
|
||||||
|
# Die Push-Dienste verlangen eine erreichbare Kontaktangabe.
|
||||||
|
# Ohne eigene Angabe die Absenderadresse verwenden.
|
||||||
|
return self.vapid_subject or f"mailto:{self.envelope_from}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def from_name(self) -> str:
|
||||||
|
return self.smtp_from_name or self.app_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def short_name(self) -> str:
|
||||||
|
return self.app_short_name or self.app_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def initial_admin_password(self) -> str:
|
||||||
|
"""Passwort aus Datei (Docker Secret) oder Umgebungsvariable."""
|
||||||
|
if self.admin_initial_password_file:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
path = Path(self.admin_initial_password_file)
|
||||||
|
if path.is_file():
|
||||||
|
return path.read_text(encoding="utf-8").strip()
|
||||||
|
raise ValueError(
|
||||||
|
f"ADMIN_INITIAL_PASSWORD_FILE zeigt auf {path}, "
|
||||||
|
"die Datei existiert nicht."
|
||||||
|
)
|
||||||
|
return self.admin_initial_password
|
||||||
|
|
||||||
|
@property
|
||||||
|
def envelope_from(self) -> str:
|
||||||
|
return parseaddr(self.smtp_envelope_from or self.smtp_from)[1]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mail_domain(self) -> str:
|
||||||
|
"""Domain fuer die Message-ID. Sollte zur DKIM-signierten Domain
|
||||||
|
passen, sonst werten manche Filter das ab."""
|
||||||
|
return self.envelope_from.rpartition("@")[2] or "localhost"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def database_url(self) -> str:
|
||||||
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"mysql+pymysql://{quote_plus(self.db_user)}:"
|
||||||
|
f"{quote_plus(self.db_password)}@{self.db_host}:{self.db_port}/"
|
||||||
|
f"{self.db_name}?charset=utf8mb4"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
|
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
27
backend/app/db.py
Normal file
27
backend/app/db.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from collections.abc import Iterator
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
settings.database_url,
|
||||||
|
pool_pre_ping=True, # tote Verbindungen nach DB-Neustart erkennen
|
||||||
|
pool_recycle=1800, # unter MariaDBs wait_timeout bleiben
|
||||||
|
future=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def get_db() -> Iterator[Session]:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
187
backend/app/deps.py
Normal file
187
backend/app/deps.py
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, Request, Response, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.db import get_db
|
||||||
|
from app.models import Setting, User, UserSession
|
||||||
|
from app.security import (
|
||||||
|
hash_token,
|
||||||
|
new_token,
|
||||||
|
tokens_equal,
|
||||||
|
utcnow,
|
||||||
|
)
|
||||||
|
|
||||||
|
SESSION_COOKIE = "ea_session"
|
||||||
|
CSRF_COOKIE = "ea_csrf"
|
||||||
|
CSRF_HEADER = "X-CSRF-Token"
|
||||||
|
|
||||||
|
DbSession = Annotated[Session, Depends(get_db)]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Laufzeit-Einstellungen
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_setting(db: Session, key: str, default: str = "") -> str:
|
||||||
|
row = db.get(Setting, key)
|
||||||
|
return row.value if row else default
|
||||||
|
|
||||||
|
|
||||||
|
def set_setting(db: Session, key: str, value: str) -> None:
|
||||||
|
row = db.get(Setting, key)
|
||||||
|
if row is None:
|
||||||
|
db.add(Setting(key=key, value=value))
|
||||||
|
else:
|
||||||
|
row.value = value
|
||||||
|
|
||||||
|
|
||||||
|
def registration_locked_by_env() -> bool:
|
||||||
|
"""True, wenn ALLOW_SELF_REGISTRATION hart auf true/false steht.
|
||||||
|
Dann darf die Admin-Oberflaeche den Wert nicht aendern."""
|
||||||
|
return settings.allow_self_registration in ("true", "false")
|
||||||
|
|
||||||
|
|
||||||
|
def self_registration_enabled(db: Session) -> bool:
|
||||||
|
mode = settings.allow_self_registration
|
||||||
|
if mode == "true":
|
||||||
|
return True
|
||||||
|
if mode == "false":
|
||||||
|
return False
|
||||||
|
# mode == "admin": die Laufzeiteinstellung entscheidet.
|
||||||
|
return get_setting(db, "allow_self_registration", "true") == "true"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Sessions
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def create_session(db: Session, user: User, response: Response) -> UserSession:
|
||||||
|
raw = new_token()
|
||||||
|
csrf = new_token()
|
||||||
|
sess = UserSession(
|
||||||
|
token_hash=hash_token(raw),
|
||||||
|
user_id=user.id,
|
||||||
|
csrf_token=csrf,
|
||||||
|
expires_at=utcnow() + timedelta(days=settings.session_days),
|
||||||
|
)
|
||||||
|
db.add(sess)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
max_age = settings.session_days * 24 * 3600
|
||||||
|
# HttpOnly: fuer JavaScript unsichtbar, damit ein XSS-Fund das Token
|
||||||
|
# nicht abgreifen kann.
|
||||||
|
response.set_cookie(
|
||||||
|
SESSION_COOKIE, raw, max_age=max_age, httponly=True,
|
||||||
|
secure=settings.cookie_secure, samesite="lax", path="/",
|
||||||
|
)
|
||||||
|
# Bewusst NICHT HttpOnly: der Client muss den Wert lesen und als
|
||||||
|
# Header zurueckschicken koennen (Double-Submit-Verfahren).
|
||||||
|
response.set_cookie(
|
||||||
|
CSRF_COOKIE, csrf, max_age=max_age, httponly=False,
|
||||||
|
secure=settings.cookie_secure, samesite="lax", path="/",
|
||||||
|
)
|
||||||
|
return sess
|
||||||
|
|
||||||
|
|
||||||
|
def destroy_session(db: Session, request: Request, response: Response) -> None:
|
||||||
|
raw = request.cookies.get(SESSION_COOKIE)
|
||||||
|
if raw:
|
||||||
|
sess = db.scalar(
|
||||||
|
select(UserSession).where(UserSession.token_hash == hash_token(raw))
|
||||||
|
)
|
||||||
|
if sess:
|
||||||
|
db.delete(sess)
|
||||||
|
response.delete_cookie(SESSION_COOKIE, path="/")
|
||||||
|
response.delete_cookie(CSRF_COOKIE, path="/")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_session(db: Session, request: Request) -> UserSession | None:
|
||||||
|
raw = request.cookies.get(SESSION_COOKIE)
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
sess = db.scalar(
|
||||||
|
select(UserSession).where(UserSession.token_hash == hash_token(raw))
|
||||||
|
)
|
||||||
|
if sess is None:
|
||||||
|
return None
|
||||||
|
if sess.expires_at <= utcnow():
|
||||||
|
db.delete(sess)
|
||||||
|
db.commit()
|
||||||
|
return None
|
||||||
|
return sess
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Abhaengigkeiten fuer Routen
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_UNSAFE = {"POST", "PUT", "PATCH", "DELETE"}
|
||||||
|
|
||||||
|
|
||||||
|
def current_user(request: Request, db: DbSession) -> User:
|
||||||
|
sess = _load_session(db, request)
|
||||||
|
if sess is None:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Nicht angemeldet")
|
||||||
|
|
||||||
|
if request.method in _UNSAFE:
|
||||||
|
supplied = request.headers.get(CSRF_HEADER, "")
|
||||||
|
if not supplied or not tokens_equal(supplied, sess.csrf_token):
|
||||||
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "CSRF-Token fehlt oder ungültig")
|
||||||
|
|
||||||
|
user = db.get(User, sess.user_id)
|
||||||
|
if user is None or not user.is_active:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Konto nicht aktiv")
|
||||||
|
|
||||||
|
# Nur einmal pro Stunde schreiben, sonst erzeugt jeder Request ein
|
||||||
|
# UPDATE. Der Zeitstempel am Konto ist die Grundlage der
|
||||||
|
# automatischen Deaktivierung - er muss auch dann mitlaufen, wenn
|
||||||
|
# sich jemand monatelang nicht neu anmeldet, weil die Sitzung hält.
|
||||||
|
if utcnow() - sess.last_seen_at > timedelta(hours=1):
|
||||||
|
sess.last_seen_at = utcnow()
|
||||||
|
user.last_seen_at = utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
CurrentUser = Annotated[User, Depends(current_user)]
|
||||||
|
|
||||||
|
|
||||||
|
def verified_user(user: CurrentUser) -> User:
|
||||||
|
if user.verified_at is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_403_FORBIDDEN, "E-Mail-Adresse noch nicht bestätigt"
|
||||||
|
)
|
||||||
|
# Alles ausser GET /api/auth/me und POST /api/auth/password/change
|
||||||
|
# haengt an dieser Abhaengigkeit - der Zwang wirkt also flaechendeckend,
|
||||||
|
# ohne dass jede Route ihn einzeln pruefen muesste.
|
||||||
|
if user.must_change_password:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_403_FORBIDDEN,
|
||||||
|
"Das Startpasswort muss zuerst geändert werden: "
|
||||||
|
"POST /api/auth/password/change",
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
VerifiedUser = Annotated[User, Depends(verified_user)]
|
||||||
|
|
||||||
|
|
||||||
|
def admin_user(user: VerifiedUser) -> User:
|
||||||
|
if not user.is_admin:
|
||||||
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "Administratorrechte erforderlich")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
AdminUser = Annotated[User, Depends(admin_user)]
|
||||||
|
|
||||||
|
|
||||||
|
def client_ip(request: Request) -> str:
|
||||||
|
"""Fuer Rate Limiting. Hinter einem Reverse Proxy liefert
|
||||||
|
request.client.host die Proxy-IP - ab Phase 3 setzen wir dafuer
|
||||||
|
ProxyHeadersMiddleware mit einer Liste vertrauenswuerdiger Hosts."""
|
||||||
|
return request.client.host if request.client else "unknown"
|
||||||
156
backend/app/list_view.py
Normal file
156
backend/app/list_view.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
"""Aufbereitung der gruppierten Listenansicht: Markt → Warengruppe → Artikel.
|
||||||
|
|
||||||
|
Liegt bewusst außerhalb der Router, weil drei Stellen dieselbe Gliederung
|
||||||
|
brauchen: die App, der öffentliche Link und der Ausdruck. Läge die Logik
|
||||||
|
im Router, würden die drei mit der Zeit auseinanderlaufen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import Category, ListItem, Market, ShoppingList
|
||||||
|
from app.schemas_shopping import ItemOut, ListView, ViewCategory, ViewMarket
|
||||||
|
|
||||||
|
NO_MARKET = "Ohne Markt"
|
||||||
|
NO_CATEGORY = "Ohne Warengruppe"
|
||||||
|
|
||||||
|
|
||||||
|
def creator_label(item: ListItem) -> str | None:
|
||||||
|
"""Anzeigename, sonst der lokale Teil der Adresse. Die vollständige
|
||||||
|
E-Mail-Adresse anderer Mitglieder wird nie ausgeliefert."""
|
||||||
|
if item.creator is None:
|
||||||
|
return None
|
||||||
|
return item.creator.display_name or item.creator.email.split("@")[0]
|
||||||
|
|
||||||
|
|
||||||
|
def item_out(item: ListItem, *, anonymous: bool = False) -> ItemOut:
|
||||||
|
"""`anonymous=True` für öffentliche Links: Dort ist der Empfängerkreis
|
||||||
|
unbestimmt, deshalb darf nicht erkennbar sein, wer was eingetragen hat."""
|
||||||
|
return ItemOut(
|
||||||
|
id=item.id,
|
||||||
|
article_id=item.article_id,
|
||||||
|
article_name=item.article.name,
|
||||||
|
created_by=None if anonymous else item.created_by,
|
||||||
|
created_by_name=None if anonymous else creator_label(item),
|
||||||
|
market_id=item.market_id,
|
||||||
|
category_id=item.category_id,
|
||||||
|
count=item.count,
|
||||||
|
pack_size=item.pack_size,
|
||||||
|
pack_unit=item.pack_unit,
|
||||||
|
variant=item.variant,
|
||||||
|
note=item.note,
|
||||||
|
status=item.status,
|
||||||
|
price_cents=item.price_cents,
|
||||||
|
total_cents=(item.price_cents * item.count) if item.price_cents else None,
|
||||||
|
row_rev=item.row_rev,
|
||||||
|
updated_at=item.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_view(
|
||||||
|
db: Session,
|
||||||
|
lst: ShoppingList,
|
||||||
|
*,
|
||||||
|
include_bought: bool = True,
|
||||||
|
include_deferred: bool = True,
|
||||||
|
anonymous: bool = False,
|
||||||
|
) -> ListView:
|
||||||
|
"""Märkte und Warengruppen ordnen sich nach `sort_order`, dann nach
|
||||||
|
Namen - so lässt sich die Reihenfolge an den tatsächlichen Weg durch
|
||||||
|
den Laden anpassen. Artikel innerhalb einer Gruppe alphabetisch.
|
||||||
|
|
||||||
|
Einträge ohne Zuordnung landen in Sammelgruppen am Ende, damit nichts
|
||||||
|
unsichtbar wird.
|
||||||
|
"""
|
||||||
|
wanted = {"open"}
|
||||||
|
if include_bought:
|
||||||
|
wanted.add("bought")
|
||||||
|
if include_deferred:
|
||||||
|
wanted.add("deferred")
|
||||||
|
|
||||||
|
items = db.scalars(
|
||||||
|
select(ListItem).where(
|
||||||
|
ListItem.list_id == lst.id,
|
||||||
|
ListItem.deleted_at.is_(None),
|
||||||
|
ListItem.status.in_(wanted),
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
markets = {
|
||||||
|
m.id: m
|
||||||
|
for m in db.scalars(
|
||||||
|
select(Market).where(Market.list_id == lst.id, Market.deleted_at.is_(None))
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
categories = {
|
||||||
|
c.id: c
|
||||||
|
for c in db.scalars(
|
||||||
|
select(Category).where(
|
||||||
|
Category.list_id == lst.id, Category.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
buckets: dict[str | None, dict[str | None, list[ListItem]]] = {}
|
||||||
|
for item in items:
|
||||||
|
mid = item.market_id if item.market_id in markets else None
|
||||||
|
cid = item.category_id if item.category_id in categories else None
|
||||||
|
buckets.setdefault(mid, {}).setdefault(cid, []).append(item)
|
||||||
|
|
||||||
|
def market_key(mid: str | None) -> tuple[int, int, str]:
|
||||||
|
if mid is None:
|
||||||
|
return (1, 0, "") # "Ohne Markt" ganz nach hinten
|
||||||
|
m = markets[mid]
|
||||||
|
return (0, m.sort_order, m.name.casefold())
|
||||||
|
|
||||||
|
def category_key(cid: str | None) -> tuple[int, int, str]:
|
||||||
|
if cid is None:
|
||||||
|
return (1, 0, "")
|
||||||
|
c = categories[cid]
|
||||||
|
return (0, c.sort_order, c.name.casefold())
|
||||||
|
|
||||||
|
out_markets: list[ViewMarket] = []
|
||||||
|
grand_total = 0
|
||||||
|
|
||||||
|
for mid in sorted(buckets, key=market_key):
|
||||||
|
out_categories: list[ViewCategory] = []
|
||||||
|
market_total = 0
|
||||||
|
open_count = 0
|
||||||
|
|
||||||
|
for cid in sorted(buckets[mid], key=category_key):
|
||||||
|
group = sorted(buckets[mid][cid], key=lambda i: i.article.name.casefold())
|
||||||
|
for item in group:
|
||||||
|
if item.status == "open":
|
||||||
|
open_count += 1
|
||||||
|
if item.price_cents:
|
||||||
|
# Preis gilt je Gebinde, multipliziert wird mit der
|
||||||
|
# Stückzahl - nicht mit der Packungsgröße. Vorher
|
||||||
|
# ergaben 500 ml zu 2,99 EUR eine Summe von 1495 EUR.
|
||||||
|
market_total += item.price_cents * item.count
|
||||||
|
|
||||||
|
out_categories.append(
|
||||||
|
ViewCategory(
|
||||||
|
category_id=cid,
|
||||||
|
category_name=categories[cid].name if cid else NO_CATEGORY,
|
||||||
|
items=[item_out(i, anonymous=anonymous) for i in group],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
grand_total += market_total
|
||||||
|
out_markets.append(
|
||||||
|
ViewMarket(
|
||||||
|
market_id=mid,
|
||||||
|
market_name=markets[mid].name if mid else NO_MARKET,
|
||||||
|
categories=out_categories,
|
||||||
|
open_count=open_count,
|
||||||
|
total_cents=market_total,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return ListView(
|
||||||
|
list_id=lst.id,
|
||||||
|
list_name=lst.name,
|
||||||
|
rev=lst.rev,
|
||||||
|
markets=out_markets,
|
||||||
|
grand_total_cents=grand_total,
|
||||||
|
)
|
||||||
270
backend/app/mail.py
Normal file
270
backend/app/mail.py
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
import logging
|
||||||
|
import smtplib
|
||||||
|
import ssl
|
||||||
|
import time
|
||||||
|
from email.headerregistry import Address
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from email.utils import format_datetime, make_msgid, parseaddr
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.security import utcnow
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Fehler, bei denen ein erneuter Versuch sinnlos ist: das Relay hat die
|
||||||
|
# Nachricht endgueltig abgelehnt (4xx waere temporaer, 5xx ist permanent).
|
||||||
|
_PERMANENT = (smtplib.SMTPRecipientsRefused, smtplib.SMTPSenderRefused,
|
||||||
|
smtplib.SMTPAuthenticationError)
|
||||||
|
|
||||||
|
|
||||||
|
def _connect() -> smtplib.SMTP:
|
||||||
|
"""Baut die Verbindung zum Relay auf. local_hostname landet im EHLO;
|
||||||
|
ohne diesen Wert schickt Python den Container-Hostnamen, was bei
|
||||||
|
strengen Filtern Punkte kostet."""
|
||||||
|
helo = settings.smtp_helo_hostname or None
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
|
||||||
|
if settings.smtp_security == "ssl":
|
||||||
|
server = smtplib.SMTP_SSL(
|
||||||
|
settings.smtp_host, settings.smtp_port,
|
||||||
|
local_hostname=helo, context=context, timeout=settings.smtp_timeout,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
server = smtplib.SMTP(
|
||||||
|
settings.smtp_host, settings.smtp_port,
|
||||||
|
local_hostname=helo, timeout=settings.smtp_timeout,
|
||||||
|
)
|
||||||
|
if settings.smtp_security == "starttls":
|
||||||
|
server.ehlo()
|
||||||
|
server.starttls(context=context)
|
||||||
|
server.ehlo()
|
||||||
|
|
||||||
|
if settings.smtp_user:
|
||||||
|
server.login(settings.smtp_user, settings.smtp_password)
|
||||||
|
|
||||||
|
return server
|
||||||
|
|
||||||
|
|
||||||
|
def build_message(to: str, subject: str, body: str) -> EmailMessage:
|
||||||
|
"""Setzt die Kopfzeilen, die Spamfilter erwarten. Fehlendes Date oder
|
||||||
|
eine Message-ID mit fremder Domain sind zwei der haeufigsten Gruende,
|
||||||
|
warum sonst harmlose Transaktionsmails im Spam landen."""
|
||||||
|
msg = EmailMessage()
|
||||||
|
|
||||||
|
local, _, domain = parseaddr(settings.smtp_from)[1].partition("@")
|
||||||
|
msg["From"] = Address(settings.from_name, local, domain)
|
||||||
|
msg["To"] = to
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg["Date"] = format_datetime(utcnow().astimezone())
|
||||||
|
# Domain der Message-ID sollte zur DKIM-Signatur passen.
|
||||||
|
msg["Message-ID"] = make_msgid(domain=settings.mail_domain)
|
||||||
|
|
||||||
|
if settings.smtp_reply_to:
|
||||||
|
msg["Reply-To"] = settings.smtp_reply_to
|
||||||
|
|
||||||
|
# RFC 3834: automatisch erzeugte Nachricht. Verhindert Abwesenheits-
|
||||||
|
# Autoresponder und Ping-Pong-Schleifen.
|
||||||
|
msg["Auto-Submitted"] = "auto-generated"
|
||||||
|
msg["X-Auto-Response-Suppress"] = "All"
|
||||||
|
|
||||||
|
msg.set_content(body, charset="utf-8")
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
def send_mail(to: str, subject: str, body: str) -> bool:
|
||||||
|
"""Blockierender Versand - immer ueber BackgroundTasks aufrufen,
|
||||||
|
nie direkt im Request-Pfad.
|
||||||
|
|
||||||
|
Fehler werden geloggt, aber nicht nach oben gereicht: aus einem 500er
|
||||||
|
koennte man sonst ableiten, ob ein Konto existiert.
|
||||||
|
"""
|
||||||
|
msg = build_message(to, subject, body)
|
||||||
|
envelope = settings.envelope_from
|
||||||
|
last: Exception | None = None
|
||||||
|
|
||||||
|
for attempt in range(1, settings.smtp_max_retries + 1):
|
||||||
|
try:
|
||||||
|
with _connect() as server:
|
||||||
|
# from_addr getrennt vom From-Header: hierueber laeuft die
|
||||||
|
# SPF-Pruefung und hierhin gehen Bounces (Return-Path).
|
||||||
|
server.send_message(msg, from_addr=envelope, to_addrs=[to])
|
||||||
|
log.info("Mail an %s versendet (Versuch %d).", to, attempt)
|
||||||
|
return True
|
||||||
|
except _PERMANENT as exc:
|
||||||
|
log.error("Relay hat die Mail an %s endgueltig abgelehnt: %s", to, exc)
|
||||||
|
return False
|
||||||
|
except Exception as exc:
|
||||||
|
last = exc
|
||||||
|
if attempt < settings.smtp_max_retries:
|
||||||
|
delay = 2 ** attempt
|
||||||
|
log.warning(
|
||||||
|
"Versand an %s fehlgeschlagen (Versuch %d/%d): %s - "
|
||||||
|
"neuer Versuch in %ds",
|
||||||
|
to, attempt, settings.smtp_max_retries, exc, delay,
|
||||||
|
)
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
log.error("Versand an %s endgueltig fehlgeschlagen: %s", to, last)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def check_connection() -> tuple[bool, str]:
|
||||||
|
"""Verbindungstest ohne Versand - fuer /api/admin/mail/check."""
|
||||||
|
try:
|
||||||
|
with _connect() as server:
|
||||||
|
code, _ = server.noop()
|
||||||
|
return True, f"Verbindung zu {settings.smtp_host}:{settings.smtp_port} ok (NOOP {code})"
|
||||||
|
except Exception as exc:
|
||||||
|
return False, f"{type(exc).__name__}: {exc}"
|
||||||
|
|
||||||
|
|
||||||
|
def send_verification(to: str, token: str) -> None:
|
||||||
|
url = f"{settings.public_base_url.rstrip('/')}/api/auth/verify?token={token}"
|
||||||
|
send_mail(
|
||||||
|
to,
|
||||||
|
f"{settings.app_name}: E-Mail-Adresse bestätigen",
|
||||||
|
"Hallo,\n\n"
|
||||||
|
"bitte bestätige deine E-Mail-Adresse über den folgenden Link:\n\n"
|
||||||
|
f"{url}\n\n"
|
||||||
|
"Der Link ist 24 Stunden gültig.\n\n"
|
||||||
|
"Wenn du dich nicht registriert hast, ignoriere diese Nachricht.\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_password_reset(to: str, token: str) -> None:
|
||||||
|
url = f"{settings.public_base_url.rstrip('/')}/reset?token={token}"
|
||||||
|
send_mail(
|
||||||
|
to,
|
||||||
|
f"{settings.app_name}: Passwort zurücksetzen",
|
||||||
|
"Hallo,\n\n"
|
||||||
|
"über den folgenden Link kannst du ein neues Passwort setzen:\n\n"
|
||||||
|
f"{url}\n\n"
|
||||||
|
"Der Link ist 1 Stunde gültig.\n\n"
|
||||||
|
"Wenn du das nicht angefordert hast, ignoriere diese Nachricht -\n"
|
||||||
|
"dein Passwort bleibt dann unverändert.\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_invitation(
|
||||||
|
to: str, token: str, list_name: str, inviter: str, valid_days: int
|
||||||
|
) -> None:
|
||||||
|
url = f"{settings.public_base_url.rstrip('/')}/invite?token={token}"
|
||||||
|
send_mail(
|
||||||
|
to,
|
||||||
|
f"{settings.app_name}: {inviter} teilt die Liste „{list_name}“ mit dir",
|
||||||
|
"Hallo,\n\n"
|
||||||
|
f"{inviter} möchte die Einkaufsliste „{list_name}“ mit dir teilen.\n\n"
|
||||||
|
"Über den folgenden Link kannst du die Einladung annehmen:\n\n"
|
||||||
|
f"{url}\n\n"
|
||||||
|
f"Der Link ist {valid_days} Tage gültig und gilt nur für diese "
|
||||||
|
"E-Mail-Adresse.\n"
|
||||||
|
"Falls du noch kein Konto hast, kannst du dir beim Öffnen des Links "
|
||||||
|
"eines anlegen.\n\n"
|
||||||
|
"Wenn du damit nichts anfangen kannst, ignoriere diese Nachricht -\n"
|
||||||
|
"ohne den Link passiert nichts.\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_welcome(to: str, token: str, inviter: str, valid_days: int) -> None:
|
||||||
|
"""Willkommensnachricht mit Link zum Setzen des Passworts.
|
||||||
|
|
||||||
|
Bewusst kein vom Administrator vergebenes Passwort: Das wäre ihm
|
||||||
|
bekannt und ginge zudem im Klartext per Mail.
|
||||||
|
"""
|
||||||
|
url = f"{settings.public_base_url.rstrip('/')}/willkommen?token={token}"
|
||||||
|
send_mail(
|
||||||
|
to,
|
||||||
|
f"{settings.app_name}: Dein Zugang steht bereit",
|
||||||
|
"Hallo,\n\n"
|
||||||
|
f"{inviter} hat für dich einen Zugang zu {settings.app_name} "
|
||||||
|
"eingerichtet.\n\n"
|
||||||
|
"Über den folgenden Link legst du dein Passwort fest und schaltest "
|
||||||
|
"den Zugang frei:\n\n"
|
||||||
|
f"{url}\n\n"
|
||||||
|
f"Der Link ist {valid_days} Tage gültig.\n\n"
|
||||||
|
"Wenn du damit nichts anfangen kannst, ignoriere diese Nachricht - "
|
||||||
|
"ohne den Link\npassiert nichts.\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_email_change_verify(to: str, token: str, old_email: str, hours: int) -> None:
|
||||||
|
"""An die NEUE Adresse: Bestätigung, dass sie erreichbar ist."""
|
||||||
|
url = f"{settings.public_base_url.rstrip('/')}/adresswechsel?token={token}"
|
||||||
|
send_mail(
|
||||||
|
to,
|
||||||
|
f"{settings.app_name}: Neue E-Mail-Adresse bestätigen",
|
||||||
|
"Hallo,\n\n"
|
||||||
|
f"für das Konto {old_email} soll künftig diese Adresse verwendet "
|
||||||
|
"werden.\n\n"
|
||||||
|
"Bitte bestätige das über den folgenden Link:\n\n"
|
||||||
|
f"{url}\n\n"
|
||||||
|
f"Der Link ist {hours} Stunden gültig.\n\n"
|
||||||
|
"Wenn du das nicht veranlasst hast, ignoriere diese Nachricht.\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_email_change_notice(to: str, new_email: str, hours: int) -> None:
|
||||||
|
"""An die ALTE Adresse: Hinweis, damit eine untergeschobene Änderung
|
||||||
|
auffällt."""
|
||||||
|
send_mail(
|
||||||
|
to,
|
||||||
|
f"{settings.app_name}: Änderung deiner E-Mail-Adresse angefordert",
|
||||||
|
"Hallo,\n\n"
|
||||||
|
"für dein Konto wurde eine neue E-Mail-Adresse angefordert:\n\n"
|
||||||
|
f" {new_email}\n\n"
|
||||||
|
f"Sobald die neue Adresse bestätigt ist (innerhalb von {hours} "
|
||||||
|
"Stunden), gilt sie\nfür die Anmeldung. Diese Adresse hier "
|
||||||
|
"funktioniert dann nicht mehr.\n\n"
|
||||||
|
"Wenn du das nicht veranlasst hast, wende dich bitte umgehend an "
|
||||||
|
"die Administration -\ndann versucht jemand, dir den Zugang zu "
|
||||||
|
"entziehen.\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_email_change_verify_old(
|
||||||
|
to: str, token: str, new_email: str, hours: int
|
||||||
|
) -> None:
|
||||||
|
"""An die ALTE Adresse eines Administratorkontos: hier reicht der
|
||||||
|
Hinweis nicht, es wird eine ausdrückliche Zustimmung verlangt."""
|
||||||
|
url = f"{settings.public_base_url.rstrip('/')}/adresswechsel?token={token}"
|
||||||
|
send_mail(
|
||||||
|
to,
|
||||||
|
f"{settings.app_name}: Adressänderung deines Administratorkontos bestätigen",
|
||||||
|
"Hallo,\n\n"
|
||||||
|
"für dein Administratorkonto wurde eine neue E-Mail-Adresse "
|
||||||
|
"angefordert:\n\n"
|
||||||
|
f" {new_email}\n\n"
|
||||||
|
"Bei Administratorkonten muss die Änderung von BEIDEN Adressen "
|
||||||
|
"bestätigt werden.\n"
|
||||||
|
"Bitte bestätige hier von deiner bisherigen Adresse aus:\n\n"
|
||||||
|
f"{url}\n\n"
|
||||||
|
f"Der Link ist {hours} Stunden gültig.\n\n"
|
||||||
|
"Wenn du das nicht veranlasst hast, klicke NICHT auf den Link. Ohne "
|
||||||
|
"deine\nBestätigung bleibt die Adresse unverändert - und du "
|
||||||
|
"solltest dein Passwort ändern.\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_deactivation_notice(to: str, months: int) -> None:
|
||||||
|
send_mail(
|
||||||
|
to,
|
||||||
|
f"{settings.app_name}: Dein Zugang wurde deaktiviert",
|
||||||
|
"Hallo,\n\n"
|
||||||
|
f"dein Zugang zu {settings.app_name} wurde deaktiviert, weil er "
|
||||||
|
f"länger als {months} Monate\nnicht genutzt wurde.\n\n"
|
||||||
|
"Deine Listen bleiben erhalten. Wende dich an die Administration, "
|
||||||
|
"wenn du den\nZugang wieder brauchst.\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_test_mail(to: str) -> None:
|
||||||
|
send_mail(
|
||||||
|
to,
|
||||||
|
f"{settings.app_name}: Testnachricht",
|
||||||
|
"Diese Nachricht bestätigt, dass der SMTP-Versand funktioniert.\n\n"
|
||||||
|
f"Relay: {settings.smtp_host}:{settings.smtp_port}\n"
|
||||||
|
f"Verschlüsselung: {settings.smtp_security}\n"
|
||||||
|
f"From-Header: {settings.smtp_from}\n"
|
||||||
|
f"Envelope-From: {settings.envelope_from}\n"
|
||||||
|
f"EHLO-Hostname: {settings.smtp_helo_hostname or '(automatisch)'}\n",
|
||||||
|
)
|
||||||
184
backend/app/main.py
Normal file
184
backend/app/main.py
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from sqlalchemy import select, text
|
||||||
|
|
||||||
|
from app.bootstrap_admin import ensure_initial_admin
|
||||||
|
from app.config import settings
|
||||||
|
from app.db import SessionLocal, engine
|
||||||
|
from app.deps import get_setting, set_setting
|
||||||
|
from app.maintenance import describe, run_cleanup
|
||||||
|
from app.models import User
|
||||||
|
from app.routers import (
|
||||||
|
admin, appinfo, auth, catalog, items, lists, prices, public, push,
|
||||||
|
sharing, sync,
|
||||||
|
)
|
||||||
|
from app.security import normalize_email, purge_rate_limits, utcnow
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
||||||
|
log = logging.getLogger("einkaufsapp")
|
||||||
|
|
||||||
|
|
||||||
|
def bootstrap() -> None:
|
||||||
|
"""Beim Start: Startwerte setzen, abgelaufene Sessions und
|
||||||
|
Rate-Limit-Zaehler aufraeumen."""
|
||||||
|
with SessionLocal() as db:
|
||||||
|
ensure_initial_admin(db)
|
||||||
|
|
||||||
|
if get_setting(db, "allow_self_registration") == "":
|
||||||
|
set_setting(
|
||||||
|
db,
|
||||||
|
"allow_self_registration",
|
||||||
|
"true" if settings.self_registration_default else "false",
|
||||||
|
)
|
||||||
|
|
||||||
|
if settings.allow_self_registration == "admin":
|
||||||
|
log.info(
|
||||||
|
"Selbstregistrierung: über die Admin-API steuerbar (aktuell %s).",
|
||||||
|
"an" if get_setting(db, "allow_self_registration") == "true" else "aus",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
log.info(
|
||||||
|
"Selbstregistrierung: per ALLOW_SELF_REGISTRATION fest auf '%s' "
|
||||||
|
"gesetzt, über die Admin-API nicht änderbar.",
|
||||||
|
settings.allow_self_registration,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Nur ein gesetzter Schlüssel ist immer ein Fehler - dann bleibt
|
||||||
|
# Push still abgeschaltet, und niemand weiß warum.
|
||||||
|
has_public = bool(settings.vapid_public_key)
|
||||||
|
has_private = bool(settings.vapid_private_key)
|
||||||
|
if has_public != has_private:
|
||||||
|
fehlend = "VAPID_PRIVATE_KEY" if has_public else "VAPID_PUBLIC_KEY"
|
||||||
|
log.error(
|
||||||
|
"Push-Benachrichtigungen sind ABGESCHALTET: %s fehlt oder ist "
|
||||||
|
"leer, während der andere Schlüssel gesetzt ist. Häufigste "
|
||||||
|
"Ursache: die Variable steht mehrfach in der .env - Docker "
|
||||||
|
"Compose nimmt die letzte Definition, und das ist oft die "
|
||||||
|
"leere Vorlagenzeile. Prüfen mit: grep -n VAPID .env",
|
||||||
|
fehlend,
|
||||||
|
)
|
||||||
|
elif has_public:
|
||||||
|
log.info(
|
||||||
|
"Push-Benachrichtigungen: aktiv, Drosselung %d Stunde(n), "
|
||||||
|
"Kontakt %s",
|
||||||
|
settings.push_throttle_hours, settings.vapid_contact,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
log.info(
|
||||||
|
"Push-Benachrichtigungen: abgeschaltet (kein VAPID-Schlüsselpaar). "
|
||||||
|
"Erzeugen mit: python3 tools/vapid-keys.py"
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"SMTP-Relay: %s:%s, Verschlüsselung=%s, Auth=%s, Envelope-From=%s",
|
||||||
|
settings.smtp_host, settings.smtp_port, settings.smtp_security,
|
||||||
|
"ja" if settings.smtp_user else "nein", settings.envelope_from,
|
||||||
|
)
|
||||||
|
if settings.smtp_security == "none" and settings.smtp_host not in ("mailpit", "localhost"):
|
||||||
|
log.warning(
|
||||||
|
"SMTP_SECURITY=none bei externem Relay %s - Mails gehen "
|
||||||
|
"unverschlüsselt über das Netz.", settings.smtp_host,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Konfiguriertes Admin-Konto markieren, falls es schon existiert.
|
||||||
|
admin_mail = normalize_email(settings.admin_email)
|
||||||
|
user = db.scalar(select(User).where(User.email == admin_mail))
|
||||||
|
if user is not None and not user.is_admin:
|
||||||
|
user.is_admin = True
|
||||||
|
log.info("Konto %s als Administrator markiert.", admin_mail)
|
||||||
|
|
||||||
|
db.execute(text("DELETE FROM user_session WHERE expires_at <= :now"),
|
||||||
|
{"now": utcnow()})
|
||||||
|
purge_rate_limits(db)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_once() -> None:
|
||||||
|
"""Laeuft im Threadpool - SQLAlchemy ist hier synchron konfiguriert,
|
||||||
|
und ein blockierender Aufruf in der Ereignisschleife wuerde den
|
||||||
|
gesamten Server anhalten."""
|
||||||
|
try:
|
||||||
|
with SessionLocal() as db:
|
||||||
|
counts = run_cleanup(db)
|
||||||
|
log.info("Aufräumen: %s", describe(counts))
|
||||||
|
except Exception:
|
||||||
|
log.exception("Aufräumen fehlgeschlagen")
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_loop() -> None:
|
||||||
|
"""Einmal kurz nach dem Start, danach im eingestellten Abstand.
|
||||||
|
|
||||||
|
Bewusst kein zusaetzlicher Cron-Container: Das waere eine weitere
|
||||||
|
Stelle, an der etwas kaputtgehen kann, fuer eine Aufgabe, die
|
||||||
|
einmal am Tag ein paar Zeilen loescht.
|
||||||
|
"""
|
||||||
|
import anyio
|
||||||
|
|
||||||
|
# Kurz warten, damit der Start nicht durch Aufräumarbeiten
|
||||||
|
# verzögert wird.
|
||||||
|
await asyncio.sleep(30)
|
||||||
|
while True:
|
||||||
|
await anyio.to_thread.run_sync(cleanup_once)
|
||||||
|
await asyncio.sleep(settings.cleanup_interval_hours * 3600)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
bootstrap()
|
||||||
|
task = asyncio.create_task(cleanup_loop())
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
task.cancel()
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title=settings.app_name,
|
||||||
|
version="0.1.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
# Kein CORS-Middleware: PWA und API laufen ab Phase 3 unter derselben
|
||||||
|
# Origin hinter nginx. Damit entfaellt eine ganze Fehlerklasse.
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def security_headers(request: Request, call_next):
|
||||||
|
response = await call_next(request)
|
||||||
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
|
response.headers["Referrer-Policy"] = "no-referrer"
|
||||||
|
response.headers["X-Frame-Options"] = "DENY"
|
||||||
|
response.headers["Permissions-Policy"] = "camera=(self), geolocation=(), microphone=()"
|
||||||
|
if settings.cookie_secure:
|
||||||
|
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/healthz", tags=["system"])
|
||||||
|
def healthz():
|
||||||
|
"""Liveness: sagt nur, dass der Prozess antwortet."""
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/readyz", tags=["system"])
|
||||||
|
def readyz():
|
||||||
|
"""Readiness: prueft zusaetzlich die Datenbankverbindung."""
|
||||||
|
with SessionLocal() as db:
|
||||||
|
db.execute(text("SELECT 1"))
|
||||||
|
return {"status": "ok", "database": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
app.include_router(auth.router)
|
||||||
|
app.include_router(admin.router)
|
||||||
|
app.include_router(lists.router)
|
||||||
|
app.include_router(catalog.router)
|
||||||
|
app.include_router(items.router)
|
||||||
|
app.include_router(sharing.router)
|
||||||
|
app.include_router(public.router)
|
||||||
|
app.include_router(sync.router)
|
||||||
|
app.include_router(prices.router)
|
||||||
|
app.include_router(push.router)
|
||||||
|
app.include_router(appinfo.router)
|
||||||
212
backend/app/maintenance.py
Normal file
212
backend/app/maintenance.py
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
"""Regelmäßiges Aufräumen.
|
||||||
|
|
||||||
|
Die Anwendung sammelt an mehreren Stellen bewusst mehr Daten, als für den
|
||||||
|
Augenblick nötig sind - Soft Delete, damit offline gebliebene Geräte vom
|
||||||
|
Löschen erfahren; Quittungen für die Outbox, damit Wiederholungen erkannt
|
||||||
|
werden; Zwischenspeicher für Produktabfragen. Ohne Aufräumen wächst das
|
||||||
|
unbegrenzt.
|
||||||
|
|
||||||
|
Läuft ohne zusätzlichen Dienst: eine Hintergrundaufgabe im api-Container,
|
||||||
|
einmal beim Start und danach täglich. Kein Cron-Container, kein Redis, und
|
||||||
|
damit auch keine weitere Stelle, an der etwas kaputtgehen kann.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.mail import send_deactivation_notice
|
||||||
|
from app.models import (
|
||||||
|
AppliedOp,
|
||||||
|
Article,
|
||||||
|
ArticleAttribute,
|
||||||
|
ArticleMarket,
|
||||||
|
Category,
|
||||||
|
EmailChange,
|
||||||
|
EmailToken,
|
||||||
|
ListInvite,
|
||||||
|
ListItem,
|
||||||
|
Market,
|
||||||
|
ProductCache,
|
||||||
|
PublicShare,
|
||||||
|
RateLimit,
|
||||||
|
ShoppingList,
|
||||||
|
User,
|
||||||
|
UserSession,
|
||||||
|
)
|
||||||
|
from app.security import utcnow
|
||||||
|
from app.users import deactivate, delete_user, months_setting
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def run_cleanup(db: Session) -> dict[str, int]:
|
||||||
|
"""Räumt auf und gibt zurück, was entfernt wurde."""
|
||||||
|
now = utcnow()
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
|
||||||
|
def purge(model, condition, label: str) -> None:
|
||||||
|
result = db.execute(delete(model).where(condition))
|
||||||
|
if result.rowcount:
|
||||||
|
counts[label] = result.rowcount
|
||||||
|
|
||||||
|
# --- Sitzungen und Token ---
|
||||||
|
purge(UserSession, UserSession.expires_at <= now, "abgelaufene Sitzungen")
|
||||||
|
|
||||||
|
# Verbrauchte oder abgelaufene Mail-Token: nach sieben Tagen weg. Der
|
||||||
|
# Puffer erlaubt es, im Zweifelsfall nachzusehen, warum ein Link nicht
|
||||||
|
# mehr ging.
|
||||||
|
purge(
|
||||||
|
EmailToken,
|
||||||
|
EmailToken.expires_at <= now - timedelta(days=7),
|
||||||
|
"abgelaufene Mail-Token",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Rate-Limit-Zähler ---
|
||||||
|
purge(
|
||||||
|
RateLimit,
|
||||||
|
RateLimit.window_start < now - timedelta(days=1),
|
||||||
|
"Rate-Limit-Zähler",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Quittungen der Outbox ---
|
||||||
|
# Sie verhindern doppeltes Ausführen. Ein Gerät, das länger als die
|
||||||
|
# Aufbewahrungsfrist offline war, hätte seine Operationen ohnehin
|
||||||
|
# längst über die Wiederholungsgrenze hinaus versucht.
|
||||||
|
purge(
|
||||||
|
AppliedOp,
|
||||||
|
AppliedOp.created_at < now - timedelta(days=settings.cleanup_ops_days),
|
||||||
|
"Outbox-Quittungen",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Abgelaufene Einladungen und öffentliche Links ---
|
||||||
|
purge(
|
||||||
|
ListInvite,
|
||||||
|
ListInvite.expires_at < now - timedelta(days=settings.cleanup_deleted_days),
|
||||||
|
"alte Einladungen",
|
||||||
|
)
|
||||||
|
purge(
|
||||||
|
PublicShare,
|
||||||
|
PublicShare.expires_at < now - timedelta(days=settings.cleanup_deleted_days),
|
||||||
|
"abgelaufene öffentliche Links",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Produktzwischenspeicher ---
|
||||||
|
purge(
|
||||||
|
ProductCache,
|
||||||
|
ProductCache.fetched_at
|
||||||
|
< now - timedelta(days=max(settings.product_cache_days * 2, 365)),
|
||||||
|
"Produktzwischenspeicher",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Endgültiges Löschen weicher Löschungen ---
|
||||||
|
# Erst jetzt verschwinden die Daten wirklich. Bis dahin konnten
|
||||||
|
# offline gebliebene Geräte erfahren, dass es sie nicht mehr gibt.
|
||||||
|
cutoff = now - timedelta(days=settings.cleanup_deleted_days)
|
||||||
|
|
||||||
|
purge(ListItem, ListItem.deleted_at < cutoff, "gelöschte Einträge")
|
||||||
|
|
||||||
|
# Artikel erst nach ihren Einträgen: Die Fremdschlüssel räumen zwar
|
||||||
|
# per CASCADE mit auf, aber die Reihenfolge macht die Zählung ehrlich.
|
||||||
|
stale_articles = db.scalars(
|
||||||
|
select(Article.id).where(Article.deleted_at < cutoff)
|
||||||
|
).all()
|
||||||
|
if stale_articles:
|
||||||
|
db.execute(
|
||||||
|
delete(ArticleAttribute).where(
|
||||||
|
ArticleAttribute.article_id.in_(stale_articles))
|
||||||
|
)
|
||||||
|
db.execute(
|
||||||
|
delete(ArticleMarket).where(ArticleMarket.article_id.in_(stale_articles))
|
||||||
|
)
|
||||||
|
db.execute(delete(Article).where(Article.id.in_(stale_articles)))
|
||||||
|
counts["gelöschte Artikel"] = len(stale_articles)
|
||||||
|
|
||||||
|
purge(Market, Market.deleted_at < cutoff, "gelöschte Märkte")
|
||||||
|
purge(Category, Category.deleted_at < cutoff, "gelöschte Warengruppen")
|
||||||
|
|
||||||
|
# Gelöschte Listen zuletzt - daran hängt per CASCADE alles Übrige.
|
||||||
|
purge(ShoppingList, ShoppingList.deleted_at < cutoff, "gelöschte Listen")
|
||||||
|
|
||||||
|
# --- Abgeschlossene Adressänderungen ---
|
||||||
|
purge(
|
||||||
|
EmailChange,
|
||||||
|
EmailChange.expires_at < now - timedelta(days=settings.cleanup_deleted_days),
|
||||||
|
"alte Adressänderungen",
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
counts.update(_retire_users(db))
|
||||||
|
db.commit()
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def _retire_users(db: Session) -> dict[str, int]:
|
||||||
|
"""Deaktiviert lange untätige Konten und löscht lange deaktivierte.
|
||||||
|
|
||||||
|
Administratorkonten bleiben ausgenommen - sonst könnte sich die
|
||||||
|
Verwaltung selbst aussperren, und zwar unbemerkt, weil niemand
|
||||||
|
hinsieht, solange alles läuft.
|
||||||
|
|
||||||
|
Ein Wert von 0 bedeutet "abgeschaltet", nicht "sofort".
|
||||||
|
"""
|
||||||
|
now = utcnow()
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
|
||||||
|
deactivate_months = months_setting(db, "auto_deactivate_months", 12)
|
||||||
|
delete_months = months_setting(db, "auto_delete_months", 12)
|
||||||
|
|
||||||
|
if deactivate_months:
|
||||||
|
cutoff = now - timedelta(days=deactivate_months * 30)
|
||||||
|
candidates = db.scalars(
|
||||||
|
select(User).where(
|
||||||
|
User.is_active.is_(True),
|
||||||
|
User.is_admin.is_(False),
|
||||||
|
User.last_seen_at.is_not(None),
|
||||||
|
User.last_seen_at < cutoff,
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for user in candidates:
|
||||||
|
log.info(
|
||||||
|
"Deaktiviere %s - seit %s nicht mehr gesehen",
|
||||||
|
user.email, user.last_seen_at.date(),
|
||||||
|
)
|
||||||
|
address = user.email
|
||||||
|
deactivate(db, user)
|
||||||
|
# Der Versand blockiert; hier ist das vertretbar, weil der
|
||||||
|
# Aufräumlauf ohnehin im Hintergrund läuft und selten
|
||||||
|
# mehr als eine Handvoll Konten betrifft.
|
||||||
|
send_deactivation_notice(address, deactivate_months)
|
||||||
|
if candidates:
|
||||||
|
counts["deaktivierte Konten"] = len(candidates)
|
||||||
|
|
||||||
|
if delete_months:
|
||||||
|
cutoff = now - timedelta(days=delete_months * 30)
|
||||||
|
candidates = db.scalars(
|
||||||
|
select(User).where(
|
||||||
|
User.is_active.is_(False),
|
||||||
|
User.is_admin.is_(False),
|
||||||
|
User.deactivated_at.is_not(None),
|
||||||
|
User.deactivated_at < cutoff,
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for user in candidates:
|
||||||
|
log.info(
|
||||||
|
"Lösche %s - seit %s deaktiviert",
|
||||||
|
user.email, user.deactivated_at.date(),
|
||||||
|
)
|
||||||
|
delete_user(db, user)
|
||||||
|
if candidates:
|
||||||
|
counts["gelöschte Konten"] = len(candidates)
|
||||||
|
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def describe(counts: dict[str, int]) -> str:
|
||||||
|
if not counts:
|
||||||
|
return "nichts zu tun"
|
||||||
|
return ", ".join(f"{value} {label}" for label, value in sorted(counts.items()))
|
||||||
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"),)
|
||||||
80
backend/app/permissions.py
Normal file
80
backend/app/permissions.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""Zugriffsrechte auf Listen und Fortschreibung des Revisionszählers.
|
||||||
|
|
||||||
|
Rollen, aufsteigend:
|
||||||
|
viewer darf lesen
|
||||||
|
editor darf Einträge, Artikel, Märkte und Warengruppen ändern
|
||||||
|
owner darf zusätzlich die Liste umbenennen, löschen und
|
||||||
|
Mitgliedschaften verwalten
|
||||||
|
|
||||||
|
Der Eigentümer steht doppelt fest: in `shopping_list.owner_id` und als
|
||||||
|
Mitglied mit der Rolle "owner". Das ist bewusst redundant - die Spalte
|
||||||
|
verhindert, dass eine Liste durch Löschen des letzten Mitglieds
|
||||||
|
verwaist, die Mitgliedschaft macht Abfragen einheitlich.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.deps import DbSession, VerifiedUser
|
||||||
|
from app.models import ListMember, ShoppingList
|
||||||
|
|
||||||
|
ROLE_RANK = {"viewer": 1, "editor": 2, "owner": 3}
|
||||||
|
|
||||||
|
|
||||||
|
def bump_rev(db: Session, list_id: str) -> int:
|
||||||
|
"""Erhöht den Revisionszähler der Liste und gibt den neuen Wert zurück.
|
||||||
|
|
||||||
|
SELECT ... FOR UPDATE sperrt die Zeile bis zum Commit. Ohne die Sperre
|
||||||
|
könnten zwei gleichzeitige Änderungen denselben Wert vergeben - dann
|
||||||
|
verpasst ein synchronisierender Client eine davon.
|
||||||
|
"""
|
||||||
|
lst = db.scalar(
|
||||||
|
select(ShoppingList).where(ShoppingList.id == list_id).with_for_update()
|
||||||
|
)
|
||||||
|
if lst is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Liste nicht gefunden")
|
||||||
|
lst.rev += 1
|
||||||
|
db.flush()
|
||||||
|
return lst.rev
|
||||||
|
|
||||||
|
|
||||||
|
def _access(db: Session, list_id: str, user_id: str, minimum: str) -> ShoppingList:
|
||||||
|
lst = db.scalar(
|
||||||
|
select(ShoppingList).where(
|
||||||
|
ShoppingList.id == list_id, ShoppingList.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
membership = db.get(ListMember, (list_id, user_id))
|
||||||
|
|
||||||
|
# Gleiche Antwort für "gibt es nicht" und "darfst du nicht sehen".
|
||||||
|
# Sonst ließe sich über die Statuscodes herausfinden, welche
|
||||||
|
# Listen-IDs existieren.
|
||||||
|
if lst is None or membership is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Liste nicht gefunden")
|
||||||
|
|
||||||
|
if ROLE_RANK.get(membership.role, 0) < ROLE_RANK[minimum]:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_403_FORBIDDEN,
|
||||||
|
f"Für diese Aktion ist mindestens die Rolle '{minimum}' erforderlich.",
|
||||||
|
)
|
||||||
|
return lst
|
||||||
|
|
||||||
|
|
||||||
|
def list_reader(list_id: str, db: DbSession, user: VerifiedUser) -> ShoppingList:
|
||||||
|
return _access(db, list_id, user.id, "viewer")
|
||||||
|
|
||||||
|
|
||||||
|
def list_editor(list_id: str, db: DbSession, user: VerifiedUser) -> ShoppingList:
|
||||||
|
return _access(db, list_id, user.id, "editor")
|
||||||
|
|
||||||
|
|
||||||
|
def list_owner(list_id: str, db: DbSession, user: VerifiedUser) -> ShoppingList:
|
||||||
|
return _access(db, list_id, user.id, "owner")
|
||||||
|
|
||||||
|
|
||||||
|
ReadableList = Annotated[ShoppingList, Depends(list_reader)]
|
||||||
|
EditableList = Annotated[ShoppingList, Depends(list_editor)]
|
||||||
|
OwnedList = Annotated[ShoppingList, Depends(list_owner)]
|
||||||
232
backend/app/prices.py
Normal file
232
backend/app/prices.py
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
"""Preiserfassung und Preisvergleich.
|
||||||
|
|
||||||
|
Ein Preis wird festgehalten, sobald er an einem Eintrag steht, der einem
|
||||||
|
Markt zugeordnet ist. Ohne Markt ergibt er keinen Vergleichswert und wird
|
||||||
|
deshalb nicht aufgenommen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import Article, ListItem, Market, PricePoint
|
||||||
|
from app.schemas_shopping import (
|
||||||
|
ArticlePrices,
|
||||||
|
MarketPrice,
|
||||||
|
PriceEntry,
|
||||||
|
PriceHint,
|
||||||
|
PriceOverview,
|
||||||
|
PriceOverviewRow,
|
||||||
|
)
|
||||||
|
from app.security import utcnow
|
||||||
|
|
||||||
|
# Innerhalb dieser Spanne gilt ein gleicher Preis am selben Markt als
|
||||||
|
# derselbe Beobachtungswert. Ohne das entstünde bei jedem Tippen im
|
||||||
|
# Preisfeld ein neuer Eintrag.
|
||||||
|
DEDUPE_WINDOW = timedelta(hours=12)
|
||||||
|
|
||||||
|
|
||||||
|
def record_price(db: Session, item: ListItem) -> None:
|
||||||
|
"""Hält den Preis eines Eintrags fest, sofern sinnvoll.
|
||||||
|
|
||||||
|
Wird nach jeder Änderung an einem Eintrag aufgerufen; die Prüfungen
|
||||||
|
hier entscheiden, ob daraus wirklich ein Datenpunkt wird.
|
||||||
|
"""
|
||||||
|
if not item.price_cents or not item.market_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
latest = db.scalar(
|
||||||
|
select(PricePoint)
|
||||||
|
.where(
|
||||||
|
PricePoint.article_id == item.article_id,
|
||||||
|
PricePoint.market_id == item.market_id,
|
||||||
|
)
|
||||||
|
.order_by(PricePoint.recorded_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
|
||||||
|
if latest is not None:
|
||||||
|
same_price = latest.price_cents == item.price_cents
|
||||||
|
same_size = (latest.pack_size == item.pack_size
|
||||||
|
and latest.pack_unit == item.pack_unit)
|
||||||
|
if same_price and same_size and utcnow() - latest.recorded_at < DEDUPE_WINDOW:
|
||||||
|
return
|
||||||
|
|
||||||
|
db.add(
|
||||||
|
PricePoint(
|
||||||
|
list_id=item.list_id,
|
||||||
|
article_id=item.article_id,
|
||||||
|
market_id=item.market_id,
|
||||||
|
price_cents=item.price_cents,
|
||||||
|
pack_size=item.pack_size,
|
||||||
|
pack_unit=item.pack_unit,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unit_price(price_cents: int, pack_size: Decimal | None) -> int | None:
|
||||||
|
"""Preis je Mengeneinheit des Gebindes, in Zehntelcent - sonst gingen
|
||||||
|
bei kleinen Mengen zu viele Stellen verloren. Ohne Gebindeangabe
|
||||||
|
nicht bestimmbar."""
|
||||||
|
if not pack_size or pack_size <= 0:
|
||||||
|
return None
|
||||||
|
return int(round(price_cents * 10 / float(pack_size)))
|
||||||
|
|
||||||
|
|
||||||
|
def article_prices(db: Session, list_id: str, article: Article) -> ArticlePrices:
|
||||||
|
"""Preisverlauf eines Artikels, gegliedert nach Markt."""
|
||||||
|
points = db.scalars(
|
||||||
|
select(PricePoint)
|
||||||
|
.where(PricePoint.article_id == article.id)
|
||||||
|
.order_by(PricePoint.recorded_at.desc())
|
||||||
|
.limit(500)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
markets = {
|
||||||
|
m.id: m
|
||||||
|
for m in db.scalars(
|
||||||
|
select(Market).where(Market.list_id == list_id)
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
grouped: dict[str, list[PricePoint]] = {}
|
||||||
|
for point in points:
|
||||||
|
grouped.setdefault(point.market_id, []).append(point)
|
||||||
|
|
||||||
|
rows: list[MarketPrice] = []
|
||||||
|
for market_id, entries in grouped.items():
|
||||||
|
market = markets.get(market_id)
|
||||||
|
if market is None or market.deleted_at is not None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
amounts = [e.price_cents for e in entries]
|
||||||
|
newest = entries[0]
|
||||||
|
rows.append(
|
||||||
|
MarketPrice(
|
||||||
|
market_id=market_id,
|
||||||
|
market_name=market.name,
|
||||||
|
latest_cents=newest.price_cents,
|
||||||
|
latest_at=newest.recorded_at,
|
||||||
|
latest_pack_size=newest.pack_size,
|
||||||
|
latest_pack_unit=newest.pack_unit,
|
||||||
|
unit_price_deci=_unit_price(newest.price_cents, newest.pack_size),
|
||||||
|
min_cents=min(amounts),
|
||||||
|
max_cents=max(amounts),
|
||||||
|
observations=len(entries),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
rows.sort(key=lambda r: r.latest_cents)
|
||||||
|
|
||||||
|
history = [
|
||||||
|
PriceEntry(
|
||||||
|
market_id=p.market_id,
|
||||||
|
market_name=markets[p.market_id].name if p.market_id in markets else "?",
|
||||||
|
price_cents=p.price_cents,
|
||||||
|
pack_size=p.pack_size,
|
||||||
|
pack_unit=p.pack_unit,
|
||||||
|
recorded_at=p.recorded_at,
|
||||||
|
)
|
||||||
|
for p in points[:100]
|
||||||
|
if p.market_id in markets
|
||||||
|
]
|
||||||
|
|
||||||
|
return ArticlePrices(
|
||||||
|
article_id=article.id,
|
||||||
|
article_name=article.name,
|
||||||
|
markets=rows,
|
||||||
|
history=history,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def overview(db: Session, list_id: str) -> PriceOverview:
|
||||||
|
"""Vergleichstabelle über alle Artikel mit erfassten Preisen."""
|
||||||
|
points = db.scalars(
|
||||||
|
select(PricePoint)
|
||||||
|
.where(PricePoint.list_id == list_id)
|
||||||
|
.order_by(PricePoint.recorded_at.desc())
|
||||||
|
.limit(5000)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
markets = {
|
||||||
|
m.id: m
|
||||||
|
for m in db.scalars(
|
||||||
|
select(Market).where(
|
||||||
|
Market.list_id == list_id, Market.deleted_at.is_(None)
|
||||||
|
).order_by(Market.sort_order, Market.name)
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
articles = {
|
||||||
|
a.id: a
|
||||||
|
for a in db.scalars(
|
||||||
|
select(Article).where(
|
||||||
|
Article.list_id == list_id, Article.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Je Artikel und Markt nur der jüngste Wert - die Liste ist bereits
|
||||||
|
# absteigend sortiert, der erste Treffer gewinnt.
|
||||||
|
latest: dict[tuple[str, str], PricePoint] = {}
|
||||||
|
for point in points:
|
||||||
|
key = (point.article_id, point.market_id)
|
||||||
|
if key not in latest:
|
||||||
|
latest[key] = point
|
||||||
|
|
||||||
|
rows: list[PriceOverviewRow] = []
|
||||||
|
for article_id, article in articles.items():
|
||||||
|
prices = {
|
||||||
|
market_id: point.price_cents
|
||||||
|
for (aid, market_id), point in latest.items()
|
||||||
|
if aid == article_id and market_id in markets
|
||||||
|
}
|
||||||
|
if not prices:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cheapest = min(prices.values())
|
||||||
|
best = [mid for mid, cents in prices.items() if cents == cheapest]
|
||||||
|
dearest = max(prices.values())
|
||||||
|
|
||||||
|
rows.append(
|
||||||
|
PriceOverviewRow(
|
||||||
|
article_id=article_id,
|
||||||
|
article_name=article.name,
|
||||||
|
prices=prices,
|
||||||
|
best_market_ids=best,
|
||||||
|
best_cents=cheapest,
|
||||||
|
spread_cents=dearest - cheapest,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
rows.sort(key=lambda r: r.article_name.casefold())
|
||||||
|
|
||||||
|
return PriceOverview(
|
||||||
|
markets=[
|
||||||
|
MarketPrice(
|
||||||
|
market_id=m.id, market_name=m.name, latest_cents=0,
|
||||||
|
latest_at=utcnow(), min_cents=0, max_cents=0, observations=0,
|
||||||
|
)
|
||||||
|
for m in markets.values()
|
||||||
|
],
|
||||||
|
market_names={m.id: m.name for m in markets.values()},
|
||||||
|
rows=rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def hints(db: Session, list_id: str) -> list[PriceHint]:
|
||||||
|
"""Kurzhinweise für die Listenansicht: Wo war dieser Artikel zuletzt
|
||||||
|
am günstigsten?"""
|
||||||
|
data = overview(db, list_id)
|
||||||
|
return [
|
||||||
|
PriceHint(
|
||||||
|
article_id=row.article_id,
|
||||||
|
best_market_ids=row.best_market_ids,
|
||||||
|
best_cents=row.best_cents,
|
||||||
|
spread_cents=row.spread_cents,
|
||||||
|
prices=row.prices,
|
||||||
|
)
|
||||||
|
for row in data.rows
|
||||||
|
if row.spread_cents > 0
|
||||||
|
]
|
||||||
127
backend/app/print_view.py
Normal file
127
backend/app/print_view.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
"""Druckansicht als eigenständige HTML-Seite.
|
||||||
|
|
||||||
|
Warum serverseitig, obwohl die App schon `@media print` mitbringt: Der
|
||||||
|
Ausdruck soll auch ohne geöffnete App möglich sein - aus einem Lesezeichen,
|
||||||
|
über einen öffentlichen Link, oder von einem Rechner, auf dem niemand die
|
||||||
|
PWA installiert hat. Die Gliederung stammt aus `app.list_view`, also
|
||||||
|
derselben Quelle wie die Bildschirmansicht; ein zweiter Sortieralgorithmus
|
||||||
|
würde mit der Zeit abweichen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from jinja2 import Environment, select_autoescape
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.schemas_shopping import ListView
|
||||||
|
|
||||||
|
# autoescape ist hier keine Formsache: Artikelnamen und Notizen sind
|
||||||
|
# freie Nutzereingaben und landen direkt im HTML.
|
||||||
|
_env = Environment(autoescape=select_autoescape(default=True, default_for_string=True))
|
||||||
|
|
||||||
|
_TEMPLATE = _env.from_string("""<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>{{ view.list_name }} – {{ app_name }}</title>
|
||||||
|
<!-- Stil und Skript liegen als eigene Dateien vor, nicht im Dokument:
|
||||||
|
Die Content-Security-Policy erlaubt weder unsafe-inline noch
|
||||||
|
Ereignisbehandler im Markup. Eingebetteter Stil käme unformatiert
|
||||||
|
an, ohne dass irgendwo ein Fehler sichtbar wäre. -->
|
||||||
|
<link rel="stylesheet" href="/print.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<button type="button" id="print-button">Drucken</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1>{{ view.list_name }}</h1>
|
||||||
|
<div class="meta">
|
||||||
|
Stand {{ printed_at }}
|
||||||
|
{%- if open_count %} · {{ open_count }} offene Position{{ "en" if open_count != 1 }}{% endif %}
|
||||||
|
{%- if not include_bought %} · gekaufte Artikel ausgeblendet{% endif %}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% if not view.markets %}
|
||||||
|
<p class="empty">Die Liste ist leer.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for market in view.markets %}
|
||||||
|
<section class="market">
|
||||||
|
<h2>
|
||||||
|
<span>{{ market.market_name }}</span>
|
||||||
|
{% if market.total_cents %}<span class="sum">{{ money(market.total_cents) }}</span>{% endif %}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{% for category in market.categories %}
|
||||||
|
<div class="category">
|
||||||
|
<h3>{{ category.category_name }}</h3>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
{% for item in category.items %}
|
||||||
|
<tr class="{{ 'bought' if item.status == 'bought' }}">
|
||||||
|
<td class="box"><span></span></td>
|
||||||
|
<td class="count {{ 'many' if item.count > 1 }}">{{ item.count }}×</td>
|
||||||
|
<td class="name">
|
||||||
|
<span class="article">{{ item.article_name }}</span>
|
||||||
|
{% set detail = [] %}
|
||||||
|
{%- if item.pack_size %}{% set _ = detail.append(pack(item)) %}{% endif %}
|
||||||
|
{%- if item.variant %}{% set _ = detail.append(item.variant) %}{% endif %}
|
||||||
|
{%- if item.note %}{% set _ = detail.append(item.note) %}{% endif %}
|
||||||
|
{%- if detail %}<span class="detail">{{ detail | join(" · ") }}</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="price">{{ money(item.total_cents) if item.total_cents else "" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<span>Gesamt</span>
|
||||||
|
<span class="total">{{ money(view.grand_total_cents) }}</span>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<p class="note">{{ app_name }} · erzeugt {{ printed_at }}</p>
|
||||||
|
|
||||||
|
<script src="/print.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def _money(cents: int | None) -> str:
|
||||||
|
value = (cents or 0) / 100
|
||||||
|
return f"{value:,.2f} €".replace(",", "\u00a0").replace(".", ",")
|
||||||
|
|
||||||
|
|
||||||
|
def _pack(item) -> str:
|
||||||
|
"""Gebindegröße lesbar machen: 250 statt 250.000, 1,5 statt 1.500.
|
||||||
|
|
||||||
|
Nicht über rstrip("0"): Bei einem Wert ohne Dezimalpunkt - und den
|
||||||
|
liefert Decimal("250") - würde das die letzte Null abschneiden und
|
||||||
|
aus 250 g plötzlich 25 g machen. `normalize()` entfernt nur
|
||||||
|
tatsächlich überflüssige Nachkommastellen.
|
||||||
|
"""
|
||||||
|
value = item.pack_size
|
||||||
|
text = format(value.normalize(), "f") if hasattr(value, "normalize") else str(value)
|
||||||
|
return f"{text.replace('.', ',')} {item.pack_unit or ''}".strip()
|
||||||
|
|
||||||
|
|
||||||
|
def render_print(view: ListView, *, include_bought: bool = True) -> str:
|
||||||
|
open_count = sum(market.open_count for market in view.markets)
|
||||||
|
return _TEMPLATE.render(
|
||||||
|
view=view,
|
||||||
|
app_name=settings.app_name,
|
||||||
|
printed_at=datetime.now().strftime("%d.%m.%Y, %H:%M"),
|
||||||
|
open_count=open_count,
|
||||||
|
include_bought=include_bought,
|
||||||
|
money=_money,
|
||||||
|
pack=_pack,
|
||||||
|
)
|
||||||
193
backend/app/product_lookup.py
Normal file
193
backend/app/product_lookup.py
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
"""Nachschlagen von Strichcodes bei Open Food Facts.
|
||||||
|
|
||||||
|
Bewusst über diesen Server statt direkt aus dem Browser:
|
||||||
|
|
||||||
|
* Die IP-Adresse der Nutzer geht nicht an einen Dritten. Bei einer
|
||||||
|
Abfrage aus dem Browser wüsste Open Food Facts, wer wann welches
|
||||||
|
Produkt scannt - genau die Art von Datenspur, die vermieden werden
|
||||||
|
soll.
|
||||||
|
* Die Content-Security-Policy bleibt bei `connect-src 'self'`. Eine
|
||||||
|
Ausnahme für eine fremde Domain zu öffnen, wäre eine dauerhafte
|
||||||
|
Schwächung für eine gelegentliche Abfrage.
|
||||||
|
* Derselbe Code wird nur einmal abgefragt, egal wie viele Geräte ihn
|
||||||
|
scannen. Das schont auch den fremden Dienst.
|
||||||
|
|
||||||
|
Abschaltbar über PRODUCT_LOOKUP=off in der .env. Dann bleibt der eigene
|
||||||
|
Artikelstamm die einzige Quelle.
|
||||||
|
|
||||||
|
Die Daten stammen aus Open Food Facts und stehen unter der Open Database
|
||||||
|
License (ODbL). Für die Verwendung in einer privaten Einkaufsliste ist
|
||||||
|
das unproblematisch; wer sie weiterverbreitet, muss die Lizenz beachten.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from datetime import timedelta
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.models import ProductCache
|
||||||
|
from app.security import utcnow
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
API_URL = "https://world.openfoodfacts.org/api/v2/product/{barcode}.json"
|
||||||
|
FIELDS = "product_name,product_name_de,generic_name_de,generic_name,brands,quantity"
|
||||||
|
|
||||||
|
# Open Food Facts verlangt eine aussagekraeftige Kennung. Ohne sie
|
||||||
|
# werden Anfragen abgewiesen.
|
||||||
|
def _user_agent() -> str:
|
||||||
|
return f"{settings.app_name}/1.0 ({settings.public_base_url})"
|
||||||
|
|
||||||
|
|
||||||
|
_UNITS = r"kg|g|mg|l|ml|cl|dl|stk|stück|st"
|
||||||
|
|
||||||
|
# Mehrstueckpackung zuerst pruefen: "6 x 33 cl", "4x500g"
|
||||||
|
_MULTIPACK = re.compile(
|
||||||
|
rf"(?P<count>\d+)\s*[x×*]\s*(?P<value>\d+(?:[.,]\d+)?)\s*(?P<unit>{_UNITS})\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# "500 g", "1,5 l", "250ml"
|
||||||
|
_QUANTITY = re.compile(
|
||||||
|
rf"(?P<value>\d+(?:[.,]\d+)?)\s*(?P<unit>{_UNITS})\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_package(
|
||||||
|
text: str | None,
|
||||||
|
) -> tuple[int | None, Decimal | None, str | None]:
|
||||||
|
"""Zerlegt eine Mengenangabe in Stueckzahl, Gebinde und Einheit.
|
||||||
|
|
||||||
|
"500 g" -> (None, 500, "g") eine Packung zu 500 g
|
||||||
|
"6 x 33 cl" -> (6, 33, "cl") sechs Flaschen zu je 33 cl
|
||||||
|
|
||||||
|
Gibt (None, None, None) zurueck, wenn nichts Verwertbares drinsteht -
|
||||||
|
dann werden die Felder nicht vorbelegt, statt zu raten.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
def normalize(raw: str) -> str:
|
||||||
|
return {"stück": "Stk", "stk": "Stk", "st": "Stk"}.get(raw.lower(), raw.lower())
|
||||||
|
|
||||||
|
# Mehrstueckpackung: Stueckzahl und Gebinde getrennt.
|
||||||
|
multi = _MULTIPACK.search(text)
|
||||||
|
if multi:
|
||||||
|
try:
|
||||||
|
count = int(multi.group("count"))
|
||||||
|
size = Decimal(multi.group("value").replace(",", "."))
|
||||||
|
except (InvalidOperation, ValueError):
|
||||||
|
return None, None, None
|
||||||
|
return count, size, normalize(multi.group("unit"))
|
||||||
|
|
||||||
|
match = _QUANTITY.search(text)
|
||||||
|
if not match:
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
try:
|
||||||
|
value = Decimal(match.group("value").replace(",", "."))
|
||||||
|
except InvalidOperation:
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
return None, value, normalize(match.group("unit"))
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch(barcode: str) -> dict | None:
|
||||||
|
"""Einzelne Abfrage. Gibt None zurueck, wenn nichts gefunden wurde
|
||||||
|
oder der Dienst nicht erreichbar war - der Aufrufer unterscheidet
|
||||||
|
das nicht, weil es fuer ihn dasselbe Ergebnis bedeutet."""
|
||||||
|
try:
|
||||||
|
response = httpx.get(
|
||||||
|
API_URL.format(barcode=barcode),
|
||||||
|
params={"fields": FIELDS},
|
||||||
|
headers={"User-Agent": _user_agent()},
|
||||||
|
timeout=settings.product_lookup_timeout,
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
log.warning("Produktabfrage für %s fehlgeschlagen: %s", barcode, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if data.get("status") != 1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
product = data.get("product") or {}
|
||||||
|
# Deutsche Bezeichnung bevorzugen, dann die allgemeine.
|
||||||
|
name = (
|
||||||
|
product.get("product_name_de")
|
||||||
|
or product.get("product_name")
|
||||||
|
or product.get("generic_name_de")
|
||||||
|
or product.get("generic_name")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
|
||||||
|
brands = (product.get("brands") or "").split(",")[0].strip()
|
||||||
|
package = (product.get("quantity") or "").strip()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": name[:300],
|
||||||
|
"brand": brands[:200] or None,
|
||||||
|
"package": package[:120] or None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def lookup(db: Session, barcode: str) -> ProductCache | None:
|
||||||
|
"""Nachschlagen mit Zwischenspeicher.
|
||||||
|
|
||||||
|
@returns None, wenn die Funktion abgeschaltet ist. Sonst immer einen
|
||||||
|
Datensatz - auch bei Misserfolg, damit derselbe Code nicht bei jedem
|
||||||
|
Scan erneut nach draußen geht.
|
||||||
|
"""
|
||||||
|
if settings.product_lookup == "off":
|
||||||
|
return None
|
||||||
|
|
||||||
|
barcode = barcode.strip()
|
||||||
|
if not barcode.isdigit() or not 6 <= len(barcode) <= 20:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cached = db.get(ProductCache, barcode)
|
||||||
|
if cached is not None:
|
||||||
|
age = utcnow() - cached.fetched_at
|
||||||
|
max_age = timedelta(
|
||||||
|
days=settings.product_cache_days if cached.found
|
||||||
|
else settings.product_miss_days
|
||||||
|
)
|
||||||
|
if age < max_age:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
result = _fetch(barcode)
|
||||||
|
count, pack_size, pack_unit = parse_package(
|
||||||
|
result.get("package") if result else None)
|
||||||
|
|
||||||
|
if cached is None:
|
||||||
|
cached = ProductCache(barcode=barcode)
|
||||||
|
db.add(cached)
|
||||||
|
|
||||||
|
cached.found = result is not None
|
||||||
|
cached.name = result["name"] if result else None
|
||||||
|
cached.brand = result["brand"] if result else None
|
||||||
|
cached.package = result["package"] if result else None
|
||||||
|
cached.count = count
|
||||||
|
cached.pack_size = pack_size
|
||||||
|
cached.pack_unit = pack_unit
|
||||||
|
cached.source = "openfoodfacts"
|
||||||
|
cached.fetched_at = utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(cached)
|
||||||
|
return cached
|
||||||
153
backend/app/push.py
Normal file
153
backend/app/push.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"""Push-Benachrichtigungen mit Zwei-Stunden-Drosselung.
|
||||||
|
|
||||||
|
Regel: Wer eine Liste ändert, löst bei allen anderen Mitgliedern eine
|
||||||
|
Benachrichtigung aus - aber höchstens eine je Liste und Person innerhalb
|
||||||
|
des eingestellten Zeitraums.
|
||||||
|
|
||||||
|
Der Grund: Wer im Laden steht und abhakt, erzeugt in wenigen Minuten
|
||||||
|
Dutzende Änderungen. Ohne Drosselung bekämen die anderen ebenso viele
|
||||||
|
Meldungen und würden die Funktion nach dem ersten Einkauf abschalten.
|
||||||
|
|
||||||
|
Was in der Nachricht steht, ist bewusst knapp: Listenname und wer sie
|
||||||
|
geändert hat. Keine Artikelnamen. Eine Benachrichtigung erscheint auf
|
||||||
|
dem gesperrten Bildschirm, und was dort steht, sieht jeder, der das
|
||||||
|
Gerät gerade in der Hand hält.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.db import SessionLocal
|
||||||
|
from app.models import ListMember, NotifyState, PushSubscription, ShoppingList, User
|
||||||
|
from app.security import utcnow
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Fehlercodes, bei denen die Anmeldung endgültig ungültig ist.
|
||||||
|
_GONE = (404, 410)
|
||||||
|
|
||||||
|
|
||||||
|
def _send_one(subscription: PushSubscription, payload: dict) -> str:
|
||||||
|
"""Zustellversuch an einen Endpunkt.
|
||||||
|
|
||||||
|
@returns "ok" | "gone" | "error"
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from pywebpush import WebPushException, webpush
|
||||||
|
except ImportError:
|
||||||
|
log.error("pywebpush ist nicht installiert - Push nicht möglich.")
|
||||||
|
return "error"
|
||||||
|
|
||||||
|
try:
|
||||||
|
webpush(
|
||||||
|
subscription_info={
|
||||||
|
"endpoint": subscription.endpoint,
|
||||||
|
"keys": {"p256dh": subscription.p256dh, "auth": subscription.auth},
|
||||||
|
},
|
||||||
|
data=json.dumps(payload),
|
||||||
|
vapid_private_key=settings.vapid_private_key,
|
||||||
|
vapid_claims={"sub": settings.vapid_contact},
|
||||||
|
timeout=settings.push_timeout,
|
||||||
|
ttl=3600,
|
||||||
|
)
|
||||||
|
return "ok"
|
||||||
|
except WebPushException as exc:
|
||||||
|
status = getattr(exc.response, "status_code", None)
|
||||||
|
if status in _GONE:
|
||||||
|
# Der Browser hat die Anmeldung verworfen - typisch nach
|
||||||
|
# Deinstallation oder Löschen der Websitedaten.
|
||||||
|
return "gone"
|
||||||
|
log.warning("Push an %s… fehlgeschlagen (%s): %s",
|
||||||
|
subscription.endpoint[:40], status, exc)
|
||||||
|
return "error"
|
||||||
|
except Exception:
|
||||||
|
log.exception("Push an %s… fehlgeschlagen", subscription.endpoint[:40])
|
||||||
|
return "error"
|
||||||
|
|
||||||
|
|
||||||
|
def send_to_user(db: Session, user_id: str, payload: dict) -> int:
|
||||||
|
"""Zustellung an alle Geräte einer Person. Gibt die Zahl der
|
||||||
|
erfolgreichen Zustellungen zurück."""
|
||||||
|
if not settings.push_enabled:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
subscriptions = db.scalars(
|
||||||
|
select(PushSubscription).where(PushSubscription.user_id == user_id)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
delivered = 0
|
||||||
|
for subscription in subscriptions:
|
||||||
|
result = _send_one(subscription, payload)
|
||||||
|
if result == "ok":
|
||||||
|
subscription.last_success_at = utcnow()
|
||||||
|
subscription.failure_count = 0
|
||||||
|
delivered += 1
|
||||||
|
elif result == "gone":
|
||||||
|
db.delete(subscription)
|
||||||
|
else:
|
||||||
|
subscription.failure_count += 1
|
||||||
|
# Nach genug Fehlschlägen aufräumen: Ein Endpunkt, der
|
||||||
|
# dauerhaft nicht antwortet, wird nicht wieder gut.
|
||||||
|
if subscription.failure_count >= 10:
|
||||||
|
db.delete(subscription)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return delivered
|
||||||
|
|
||||||
|
|
||||||
|
def notify_list_changed(list_id: str, actor_id: str) -> None:
|
||||||
|
"""Benachrichtigt die übrigen Mitglieder über eine Änderung.
|
||||||
|
|
||||||
|
Läuft im Hintergrund (BackgroundTasks) und öffnet dafür eine eigene
|
||||||
|
Sitzung: Die Sitzung des Requests ist zu dem Zeitpunkt bereits
|
||||||
|
geschlossen.
|
||||||
|
"""
|
||||||
|
if not settings.push_enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
with SessionLocal() as db:
|
||||||
|
lst = db.get(ShoppingList, list_id)
|
||||||
|
if lst is None or lst.deleted_at is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
actor = db.get(User, actor_id)
|
||||||
|
actor_name = (
|
||||||
|
(actor.display_name or actor.email.split("@")[0]) if actor else "Jemand"
|
||||||
|
)
|
||||||
|
|
||||||
|
members = db.scalars(
|
||||||
|
select(ListMember).where(
|
||||||
|
ListMember.list_id == list_id, ListMember.user_id != actor_id
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
threshold = utcnow() - timedelta(hours=settings.push_throttle_hours)
|
||||||
|
|
||||||
|
for member in members:
|
||||||
|
state = db.get(NotifyState, (list_id, member.user_id))
|
||||||
|
if state is not None and state.last_notified_at > threshold:
|
||||||
|
continue # innerhalb der Sperrfrist
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"title": lst.name,
|
||||||
|
"body": f"{actor_name} hat die Liste geändert.",
|
||||||
|
"list_id": list_id,
|
||||||
|
"tag": f"list-{list_id}",
|
||||||
|
}
|
||||||
|
delivered = send_to_user(db, member.user_id, payload)
|
||||||
|
if delivered == 0:
|
||||||
|
# Nichts zugestellt - dann auch nicht die Sperrfrist
|
||||||
|
# starten, sonst verpasst die Person die nächste Änderung.
|
||||||
|
continue
|
||||||
|
|
||||||
|
if state is None:
|
||||||
|
db.add(NotifyState(list_id=list_id, user_id=member.user_id,
|
||||||
|
last_notified_at=utcnow()))
|
||||||
|
else:
|
||||||
|
state.last_notified_at = utcnow()
|
||||||
|
db.commit()
|
||||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
458
backend/app/routers/admin.py
Normal file
458
backend/app/routers/admin.py
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
"""Administration: Einstellungen, Mailprüfung, Benutzerverwaltung."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.deps import (
|
||||||
|
AdminUser,
|
||||||
|
DbSession,
|
||||||
|
client_ip,
|
||||||
|
get_setting,
|
||||||
|
registration_locked_by_env,
|
||||||
|
self_registration_enabled,
|
||||||
|
set_setting,
|
||||||
|
)
|
||||||
|
from app.mail import (
|
||||||
|
check_connection,
|
||||||
|
send_email_change_notice,
|
||||||
|
send_email_change_verify,
|
||||||
|
send_email_change_verify_old,
|
||||||
|
send_test_mail,
|
||||||
|
send_welcome,
|
||||||
|
)
|
||||||
|
from app.maintenance import describe, run_cleanup
|
||||||
|
from app.models import EmailChange, User
|
||||||
|
from app.schemas import MailCheckOut, MailTestIn, MessageOut
|
||||||
|
from app.schemas_admin import (
|
||||||
|
AdminSettingsIn,
|
||||||
|
AdminSettingsOut,
|
||||||
|
AdminStatsOut,
|
||||||
|
AdminUserOut,
|
||||||
|
DeleteUserIn,
|
||||||
|
EmailChangeIn,
|
||||||
|
UserCreateIn,
|
||||||
|
)
|
||||||
|
from app.security import check_rate_limit, normalize_email, utcnow
|
||||||
|
from app.users import (
|
||||||
|
EMAIL_CHANGE_HOURS,
|
||||||
|
WELCOME_DAYS,
|
||||||
|
create_user,
|
||||||
|
deactivate,
|
||||||
|
delete_user,
|
||||||
|
issue_token,
|
||||||
|
membership_counts,
|
||||||
|
months_setting,
|
||||||
|
owned_list_counts,
|
||||||
|
reactivate,
|
||||||
|
start_email_change,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||||
|
|
||||||
|
DEFAULT_DEACTIVATE_MONTHS = 12
|
||||||
|
DEFAULT_DELETE_MONTHS = 12
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Einstellungen
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
def _settings_out(db) -> AdminSettingsOut:
|
||||||
|
return AdminSettingsOut(
|
||||||
|
allow_self_registration=self_registration_enabled(db),
|
||||||
|
locked_by_env=registration_locked_by_env(),
|
||||||
|
auto_deactivate_months=months_setting(
|
||||||
|
db, "auto_deactivate_months", DEFAULT_DEACTIVATE_MONTHS
|
||||||
|
),
|
||||||
|
auto_delete_months=months_setting(
|
||||||
|
db, "auto_delete_months", DEFAULT_DELETE_MONTHS
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings", response_model=AdminSettingsOut)
|
||||||
|
def read_settings(db: DbSession, admin: AdminUser):
|
||||||
|
return _settings_out(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/settings", response_model=AdminSettingsOut)
|
||||||
|
def write_settings(payload: AdminSettingsIn, db: DbSession, admin: AdminUser):
|
||||||
|
if payload.allow_self_registration is not None:
|
||||||
|
if registration_locked_by_env():
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_409_CONFLICT,
|
||||||
|
"Die Selbstregistrierung ist über die Umgebungsvariable "
|
||||||
|
f"ALLOW_SELF_REGISTRATION={settings.allow_self_registration} "
|
||||||
|
"festgelegt und lässt sich hier nicht ändern. Setze die Variable "
|
||||||
|
"auf 'admin', um sie über diese Schnittstelle steuerbar zu machen.",
|
||||||
|
)
|
||||||
|
set_setting(
|
||||||
|
db, "allow_self_registration",
|
||||||
|
"true" if payload.allow_self_registration else "false",
|
||||||
|
)
|
||||||
|
|
||||||
|
if payload.auto_deactivate_months is not None:
|
||||||
|
set_setting(db, "auto_deactivate_months", str(payload.auto_deactivate_months))
|
||||||
|
if payload.auto_delete_months is not None:
|
||||||
|
set_setting(db, "auto_delete_months", str(payload.auto_delete_months))
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return _settings_out(db)
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Benutzerübersicht
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
def _user_out(
|
||||||
|
user: User,
|
||||||
|
owned: dict[str, int],
|
||||||
|
memberships: dict[str, int],
|
||||||
|
pending: dict[str, str],
|
||||||
|
) -> AdminUserOut:
|
||||||
|
return AdminUserOut(
|
||||||
|
id=user.id,
|
||||||
|
email=user.email,
|
||||||
|
display_name=user.display_name,
|
||||||
|
is_admin=user.is_admin,
|
||||||
|
is_active=user.is_active,
|
||||||
|
verified=user.verified_at is not None,
|
||||||
|
last_seen_at=user.last_seen_at,
|
||||||
|
deactivated_at=user.deactivated_at,
|
||||||
|
created_at=user.created_at,
|
||||||
|
owned_lists=owned.get(user.id, 0),
|
||||||
|
memberships=memberships.get(user.id, 0),
|
||||||
|
pending_email=pending.get(user.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pending_changes(db) -> dict[str, str]:
|
||||||
|
rows = db.scalars(
|
||||||
|
select(EmailChange).where(
|
||||||
|
EmailChange.applied_at.is_(None),
|
||||||
|
EmailChange.cancelled_at.is_(None),
|
||||||
|
EmailChange.expires_at > utcnow(),
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return {row.user_id: row.new_email for row in rows}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users", response_model=list[AdminUserOut])
|
||||||
|
def list_users(db: DbSession, admin: AdminUser):
|
||||||
|
users = db.scalars(select(User).order_by(User.created_at)).all()
|
||||||
|
owned = owned_list_counts(db)
|
||||||
|
memberships = membership_counts(db)
|
||||||
|
pending = _pending_changes(db)
|
||||||
|
return [_user_out(u, owned, memberships, pending) for u in users]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats", response_model=AdminStatsOut)
|
||||||
|
def stats(db: DbSession, admin: AdminUser):
|
||||||
|
users = db.scalars(select(User)).all()
|
||||||
|
now = utcnow()
|
||||||
|
|
||||||
|
deactivate_months = months_setting(
|
||||||
|
db, "auto_deactivate_months", DEFAULT_DEACTIVATE_MONTHS)
|
||||||
|
delete_months = months_setting(db, "auto_delete_months", DEFAULT_DELETE_MONTHS)
|
||||||
|
|
||||||
|
due_deactivation = 0
|
||||||
|
due_deletion = 0
|
||||||
|
for user in users:
|
||||||
|
# Administratorkonten unterliegen der Automatik nicht.
|
||||||
|
if user.is_admin:
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
deactivate_months
|
||||||
|
and user.is_active
|
||||||
|
and user.last_seen_at
|
||||||
|
and user.last_seen_at < now - timedelta(days=deactivate_months * 30)
|
||||||
|
):
|
||||||
|
due_deactivation += 1
|
||||||
|
if (
|
||||||
|
delete_months
|
||||||
|
and not user.is_active
|
||||||
|
and user.deactivated_at
|
||||||
|
and user.deactivated_at < now - timedelta(days=delete_months * 30)
|
||||||
|
):
|
||||||
|
due_deletion += 1
|
||||||
|
|
||||||
|
return AdminStatsOut(
|
||||||
|
total=len(users),
|
||||||
|
active=sum(1 for u in users if u.is_active),
|
||||||
|
inactive=sum(1 for u in users if not u.is_active),
|
||||||
|
unverified=sum(1 for u in users if u.verified_at is None),
|
||||||
|
admins=sum(1 for u in users if u.is_admin),
|
||||||
|
due_deactivation=due_deactivation,
|
||||||
|
due_deletion=due_deletion,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Benutzer anlegen
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
def _target(db, user_id: str) -> User:
|
||||||
|
user = db.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Konto nicht gefunden")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users", response_model=AdminUserOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def add_user(
|
||||||
|
payload: UserCreateIn,
|
||||||
|
request: Request,
|
||||||
|
db: DbSession,
|
||||||
|
admin: AdminUser,
|
||||||
|
background: BackgroundTasks,
|
||||||
|
):
|
||||||
|
if not check_rate_limit(
|
||||||
|
db, f"admin-create:{client_ip(request)}", limit=30, window_minutes=60
|
||||||
|
):
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Anlagen.")
|
||||||
|
|
||||||
|
email = normalize_email(payload.email)
|
||||||
|
if db.scalar(select(User).where(User.email == email)) is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_409_CONFLICT, "Unter dieser Adresse existiert bereits ein Konto."
|
||||||
|
)
|
||||||
|
|
||||||
|
user, token = create_user(
|
||||||
|
db, email=email, display_name=payload.display_name, is_admin=payload.is_admin
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
|
||||||
|
background.add_task(
|
||||||
|
send_welcome, email, token,
|
||||||
|
admin.display_name or admin.email.split("@")[0], WELCOME_DAYS,
|
||||||
|
)
|
||||||
|
return _user_out(user, {}, {}, {})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/{user_id}/welcome", response_model=MessageOut)
|
||||||
|
def resend_welcome(
|
||||||
|
user_id: str,
|
||||||
|
db: DbSession,
|
||||||
|
admin: AdminUser,
|
||||||
|
background: BackgroundTasks,
|
||||||
|
):
|
||||||
|
"""Erneuter Versand der Willkommensnachricht.
|
||||||
|
|
||||||
|
Nur solange das Konto noch nicht bestätigt ist. Danach wäre es kein
|
||||||
|
Willkommensgruß mehr, sondern ein vom Administrator ausgelöstes
|
||||||
|
Zurücksetzen des Passworts - das soll vom Kontoinhaber ausgehen und
|
||||||
|
läuft über "Passwort vergessen".
|
||||||
|
"""
|
||||||
|
user = _target(db, user_id)
|
||||||
|
if user.verified_at is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_409_CONFLICT,
|
||||||
|
"Dieses Konto ist bereits eingerichtet. Für ein neues Passwort "
|
||||||
|
"nutzt die Person „Passwort vergessen“ auf der Anmeldeseite.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not check_rate_limit(db, f"admin-welcome:{user.id}", limit=5, window_minutes=60):
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Versuche.")
|
||||||
|
|
||||||
|
token = issue_token(db, user, "welcome", hours=WELCOME_DAYS * 24)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
background.add_task(
|
||||||
|
send_welcome, user.email, token,
|
||||||
|
admin.display_name or admin.email.split("@")[0], WELCOME_DAYS,
|
||||||
|
)
|
||||||
|
return MessageOut(detail=f"Willkommensnachricht erneut an {user.email} versendet.")
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Adressänderung
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
@router.post("/users/{user_id}/email", response_model=MessageOut)
|
||||||
|
def change_email(
|
||||||
|
user_id: str,
|
||||||
|
payload: EmailChangeIn,
|
||||||
|
db: DbSession,
|
||||||
|
admin: AdminUser,
|
||||||
|
background: BackgroundTasks,
|
||||||
|
):
|
||||||
|
user = _target(db, user_id)
|
||||||
|
target = normalize_email(payload.new_email)
|
||||||
|
|
||||||
|
if target == user.email:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST, "Das ist bereits die aktuelle Adresse."
|
||||||
|
)
|
||||||
|
if db.scalar(select(User).where(User.email == target)) is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_409_CONFLICT, "Unter dieser Adresse existiert bereits ein Konto."
|
||||||
|
)
|
||||||
|
|
||||||
|
old_email = user.email
|
||||||
|
change, raw_new, raw_old = start_email_change(db, user, target, admin.id)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
background.add_task(
|
||||||
|
send_email_change_verify, target, raw_new, old_email, EMAIL_CHANGE_HOURS
|
||||||
|
)
|
||||||
|
if change.requires_old and raw_old:
|
||||||
|
background.add_task(
|
||||||
|
send_email_change_verify_old, old_email, raw_old, target, EMAIL_CHANGE_HOURS
|
||||||
|
)
|
||||||
|
detail = (
|
||||||
|
f"Bestätigungslinks an {target} und {old_email} versendet. "
|
||||||
|
"Bei Administratorkonten müssen beide Adressen zustimmen; die "
|
||||||
|
"Änderung wird erst danach wirksam."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
background.add_task(
|
||||||
|
send_email_change_notice, old_email, target, EMAIL_CHANGE_HOURS
|
||||||
|
)
|
||||||
|
detail = (
|
||||||
|
f"Bestätigungslink an {target} versendet, Hinweis an {old_email}. "
|
||||||
|
"Die Änderung wird wirksam, sobald die neue Adresse bestätigt hat."
|
||||||
|
)
|
||||||
|
|
||||||
|
return MessageOut(detail=detail)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/users/{user_id}/email", response_model=MessageOut)
|
||||||
|
def cancel_email_change(user_id: str, db: DbSession, admin: AdminUser):
|
||||||
|
user = _target(db, user_id)
|
||||||
|
count = 0
|
||||||
|
for change in db.scalars(
|
||||||
|
select(EmailChange).where(
|
||||||
|
EmailChange.user_id == user.id,
|
||||||
|
EmailChange.applied_at.is_(None),
|
||||||
|
EmailChange.cancelled_at.is_(None),
|
||||||
|
)
|
||||||
|
).all():
|
||||||
|
change.cancelled_at = utcnow()
|
||||||
|
count += 1
|
||||||
|
db.commit()
|
||||||
|
return MessageOut(
|
||||||
|
detail=f"{count} offene(r) Adresswechsel zurückgezogen."
|
||||||
|
if count else "Es lief kein Adresswechsel."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Deaktivieren, Reaktivieren, Löschen
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
def _guard_admin_target(user: User, admin: User, action: str) -> None:
|
||||||
|
"""Administratorkonten sind vor Deaktivierung und Löschung geschützt.
|
||||||
|
|
||||||
|
Sonst könnte ein Administrator alle anderen aussperren - oder sich
|
||||||
|
selbst, und dann käme niemand mehr an die Verwaltung. Wer einen
|
||||||
|
Administrator entfernen will, nimmt ihm zuerst die Rechte; das geht
|
||||||
|
bewusst nur direkt in der Datenbank und ist damit ein Schritt, den
|
||||||
|
man nicht versehentlich tut.
|
||||||
|
"""
|
||||||
|
if user.is_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_409_CONFLICT,
|
||||||
|
f"Administratorkonten können nicht {action} werden. Entziehe die "
|
||||||
|
"Administratorrechte zuerst - das geht bewusst nur direkt in der "
|
||||||
|
"Datenbank.",
|
||||||
|
)
|
||||||
|
if user.id == admin.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_409_CONFLICT, "Das eigene Konto lässt sich nicht ändern."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/{user_id}/deactivate", response_model=AdminUserOut)
|
||||||
|
def deactivate_user(user_id: str, db: DbSession, admin: AdminUser):
|
||||||
|
user = _target(db, user_id)
|
||||||
|
_guard_admin_target(user, admin, "deaktiviert")
|
||||||
|
deactivate(db, user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
return _user_out(user, owned_list_counts(db), membership_counts(db), {})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/{user_id}/activate", response_model=AdminUserOut)
|
||||||
|
def activate_user(user_id: str, db: DbSession, admin: AdminUser):
|
||||||
|
user = _target(db, user_id)
|
||||||
|
reactivate(db, user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
return _user_out(user, owned_list_counts(db), membership_counts(db), {})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/{user_id}/delete", response_model=MessageOut)
|
||||||
|
def remove_user(user_id: str, payload: DeleteUserIn, db: DbSession, admin: AdminUser):
|
||||||
|
"""Löschen als POST mit Rumpf, nicht als DELETE.
|
||||||
|
|
||||||
|
Der Vorgang braucht zwei Angaben: was mit den Listen geschehen soll
|
||||||
|
und eine Bestätigung der Adresse. Ein DELETE mit Rumpf ist in
|
||||||
|
Zwischenschichten unzuverlässig.
|
||||||
|
"""
|
||||||
|
user = _target(db, user_id)
|
||||||
|
_guard_admin_target(user, admin, "gelöscht")
|
||||||
|
|
||||||
|
if normalize_email(payload.confirm_email) != user.email:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
"Die Bestätigungsadresse stimmt nicht mit dem Konto überein.",
|
||||||
|
)
|
||||||
|
|
||||||
|
owned = owned_list_counts(db).get(user.id, 0)
|
||||||
|
if owned and payload.lists == "refuse":
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_409_CONFLICT,
|
||||||
|
f"Diesem Konto gehören {owned} Liste(n). Wähle „übergeben“, um "
|
||||||
|
"geteilte Listen an das dienstälteste andere Mitglied zu "
|
||||||
|
"übertragen und die übrigen zu löschen.",
|
||||||
|
)
|
||||||
|
|
||||||
|
email = user.email
|
||||||
|
counts = delete_user(db, user)
|
||||||
|
db.commit()
|
||||||
|
return MessageOut(
|
||||||
|
detail=f"Konto {email} gelöscht. "
|
||||||
|
f"{counts['übertragene Listen']} Liste(n) übertragen, "
|
||||||
|
f"{counts['gelöschte Listen']} gelöscht."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Betrieb
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
@router.post("/cleanup", response_model=MessageOut)
|
||||||
|
def trigger_cleanup(db: DbSession, admin: AdminUser):
|
||||||
|
"""Räumt sofort auf, statt auf den täglichen Durchlauf zu warten."""
|
||||||
|
counts = run_cleanup(db)
|
||||||
|
return MessageOut(detail=f"Aufgeräumt: {describe(counts)}.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mail/check", response_model=MailCheckOut)
|
||||||
|
def mail_check(admin: AdminUser):
|
||||||
|
"""Verbindungstest zum Relay, ohne eine Nachricht zu versenden."""
|
||||||
|
ok, detail = check_connection()
|
||||||
|
return MailCheckOut(
|
||||||
|
ok=ok,
|
||||||
|
detail=detail,
|
||||||
|
host=settings.smtp_host,
|
||||||
|
port=settings.smtp_port,
|
||||||
|
security=settings.smtp_security,
|
||||||
|
envelope_from=settings.envelope_from,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/mail/test", response_model=MessageOut,
|
||||||
|
status_code=status.HTTP_202_ACCEPTED)
|
||||||
|
def mail_test(payload: MailTestIn, background: BackgroundTasks, admin: AdminUser):
|
||||||
|
background.add_task(send_test_mail, str(payload.to))
|
||||||
|
return MessageOut(
|
||||||
|
detail=f"Testnachricht an {payload.to} in Auftrag gegeben. "
|
||||||
|
"Ergebnis steht im Log des api-Containers."
|
||||||
|
)
|
||||||
56
backend/app/routers/appinfo.py
Normal file
56
backend/app/routers/appinfo.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""Angaben zur Anwendung, die die Oberflaeche zur Laufzeit braucht.
|
||||||
|
|
||||||
|
Die statischen Dateien im web-Container kennen den Anwendungsnamen nicht -
|
||||||
|
er steht in der .env. Statt ihn beim Bauen einzusetzen (was ein
|
||||||
|
Neubau-Erfordernis bei jeder Umbenennung bedeutete), liefert ihn die API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
router = APIRouter(tags=["app"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/config")
|
||||||
|
def public_config():
|
||||||
|
"""Ohne Anmeldung erreichbar: Das Anmeldeformular braucht den Namen,
|
||||||
|
bevor jemand angemeldet ist. Enthaelt bewusst nichts Vertrauliches."""
|
||||||
|
return {
|
||||||
|
"app_name": settings.app_name,
|
||||||
|
"app_short_name": settings.short_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/manifest.webmanifest", include_in_schema=False)
|
||||||
|
def manifest():
|
||||||
|
"""Erzeugt statt statisch ausgeliefert, damit der Name aus der .env
|
||||||
|
auch auf dem Startbildschirm erscheint."""
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"name": settings.app_name,
|
||||||
|
"short_name": settings.short_name,
|
||||||
|
"description": "Gemeinsame Einkaufslisten mit Märkten, "
|
||||||
|
"Warengruppen und Preisen.",
|
||||||
|
"lang": "de",
|
||||||
|
"dir": "ltr",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"background_color": "#f6f6f4",
|
||||||
|
"theme_color": "#2f6f4e",
|
||||||
|
"categories": ["shopping", "productivity"],
|
||||||
|
"icons": [
|
||||||
|
{"src": "/icons/icon-192.png", "sizes": "192x192",
|
||||||
|
"type": "image/png", "purpose": "any"},
|
||||||
|
{"src": "/icons/icon-512.png", "sizes": "512x512",
|
||||||
|
"type": "image/png", "purpose": "any"},
|
||||||
|
{"src": "/icons/icon-maskable-512.png", "sizes": "512x512",
|
||||||
|
"type": "image/png", "purpose": "maskable"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
media_type="application/manifest+json",
|
||||||
|
headers={"Cache-Control": "no-cache"},
|
||||||
|
)
|
||||||
396
backend/app/routers/auth.py
Normal file
396
backend/app/routers/auth.py
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Response, status
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.deps import (
|
||||||
|
CSRF_COOKIE,
|
||||||
|
SESSION_COOKIE,
|
||||||
|
CurrentUser,
|
||||||
|
DbSession,
|
||||||
|
client_ip,
|
||||||
|
create_session,
|
||||||
|
destroy_session,
|
||||||
|
self_registration_enabled,
|
||||||
|
)
|
||||||
|
from app.mail import send_password_reset, send_verification
|
||||||
|
from app.models import EmailChange, EmailToken, User, UserSession
|
||||||
|
from app.schemas_admin import WelcomeCompleteIn, WelcomePreviewOut
|
||||||
|
from app.schemas import (
|
||||||
|
LoginIn,
|
||||||
|
MessageOut,
|
||||||
|
PasswordChangeIn,
|
||||||
|
PasswordResetIn,
|
||||||
|
PasswordResetRequestIn,
|
||||||
|
ProfileUpdateIn,
|
||||||
|
RegisterIn,
|
||||||
|
UserOut,
|
||||||
|
)
|
||||||
|
from app.users import apply_if_complete
|
||||||
|
from app.security import (
|
||||||
|
check_rate_limit,
|
||||||
|
hash_password,
|
||||||
|
hash_token,
|
||||||
|
needs_rehash,
|
||||||
|
new_token,
|
||||||
|
normalize_email,
|
||||||
|
utcnow,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
|
# Bewusst identische Antwort fuer "Konto existiert" und "Konto existiert nicht".
|
||||||
|
_NEUTRAL = "Falls die Adresse bei uns registriert ist, wurde eine E-Mail versendet."
|
||||||
|
|
||||||
|
|
||||||
|
def _issue_token(db, user: User, purpose: str, hours: int) -> str:
|
||||||
|
# Aeltere, noch offene Token desselben Zwecks entwerten.
|
||||||
|
for old in db.scalars(
|
||||||
|
select(EmailToken).where(
|
||||||
|
EmailToken.user_id == user.id,
|
||||||
|
EmailToken.purpose == purpose,
|
||||||
|
EmailToken.used_at.is_(None),
|
||||||
|
)
|
||||||
|
).all():
|
||||||
|
old.used_at = utcnow()
|
||||||
|
|
||||||
|
raw = new_token()
|
||||||
|
db.add(
|
||||||
|
EmailToken(
|
||||||
|
token_hash=hash_token(raw),
|
||||||
|
user_id=user.id,
|
||||||
|
purpose=purpose,
|
||||||
|
expires_at=utcnow() + timedelta(hours=hours),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def _consume_token(db, raw: str, purpose: str) -> User | None:
|
||||||
|
row = db.scalar(
|
||||||
|
select(EmailToken).where(
|
||||||
|
EmailToken.token_hash == hash_token(raw),
|
||||||
|
EmailToken.purpose == purpose,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if row is None or row.used_at is not None or row.expires_at <= utcnow():
|
||||||
|
return None
|
||||||
|
row.used_at = utcnow()
|
||||||
|
return db.get(User, row.user_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", response_model=MessageOut, status_code=status.HTTP_202_ACCEPTED)
|
||||||
|
def register(
|
||||||
|
payload: RegisterIn,
|
||||||
|
request: Request,
|
||||||
|
db: DbSession,
|
||||||
|
background: BackgroundTasks,
|
||||||
|
):
|
||||||
|
if not self_registration_enabled(db):
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_403_FORBIDDEN,
|
||||||
|
"Die Selbstregistrierung ist deaktiviert. Bitte lass dich einladen.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not check_rate_limit(
|
||||||
|
db, f"register:{client_ip(request)}", limit=5, window_minutes=60
|
||||||
|
):
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Versuche.")
|
||||||
|
|
||||||
|
email = normalize_email(payload.email)
|
||||||
|
existing = db.scalar(select(User).where(User.email == email))
|
||||||
|
|
||||||
|
if existing is None:
|
||||||
|
user = User(
|
||||||
|
email=email,
|
||||||
|
display_name=(payload.display_name or None),
|
||||||
|
password_hash=hash_password(payload.password),
|
||||||
|
is_admin=(email == normalize_email(settings.admin_email)),
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.flush()
|
||||||
|
raw = _issue_token(db, user, "verify", hours=24)
|
||||||
|
background.add_task(send_verification, email, raw)
|
||||||
|
elif existing.verified_at is None:
|
||||||
|
# Unbestaetigtes Konto: neuen Link schicken, statt zu verraten,
|
||||||
|
# dass die Adresse schon vergeben ist.
|
||||||
|
raw = _issue_token(db, existing, "verify", hours=24)
|
||||||
|
background.add_task(send_verification, email, raw)
|
||||||
|
# Bestaetigtes Konto: nichts tun, aber gleiche Antwort geben.
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return MessageOut(detail=_NEUTRAL)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/verify", include_in_schema=True)
|
||||||
|
def verify(token: str, db: DbSession):
|
||||||
|
"""Ziel des Links aus der Bestätigungsmail.
|
||||||
|
|
||||||
|
Antwortet mit einer Weiterleitung auf die Oberfläche, nicht mit JSON:
|
||||||
|
Der Nutzer klickt hier im Browser, nicht per API-Aufruf.
|
||||||
|
"""
|
||||||
|
user = _consume_token(db, token, "verify")
|
||||||
|
if user is None:
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse("/?verified=invalid", status_code=303)
|
||||||
|
|
||||||
|
if user.verified_at is None:
|
||||||
|
user.verified_at = utcnow()
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse("/?verified=ok", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=UserOut)
|
||||||
|
def login(payload: LoginIn, request: Request, response: Response, db: DbSession):
|
||||||
|
email = normalize_email(payload.email)
|
||||||
|
|
||||||
|
ip_ok = check_rate_limit(db, f"login-ip:{client_ip(request)}", limit=20, window_minutes=15)
|
||||||
|
acct_ok = check_rate_limit(db, f"login-acct:{email}", limit=8, window_minutes=15)
|
||||||
|
if not (ip_ok and acct_ok):
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
"Zu viele Anmeldeversuche. Bitte warte einen Moment.",
|
||||||
|
)
|
||||||
|
|
||||||
|
user = db.scalar(select(User).where(User.email == email))
|
||||||
|
# Laeuft auch ohne Treffer gegen einen Dummy-Hash - gleiche Laufzeit.
|
||||||
|
ok = verify_password(payload.password, user.password_hash if user else None)
|
||||||
|
|
||||||
|
if not ok or user is None:
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_401_UNAUTHORIZED, "E-Mail-Adresse oder Passwort ist falsch."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Erst NACH erfolgreicher Passwortprüfung über die Deaktivierung
|
||||||
|
# informieren. Vorher wäre es ein Hinweis darauf, dass es das Konto
|
||||||
|
# gibt - danach weiß die Person das ohnehin, weil sie das Passwort
|
||||||
|
# kennt. Und wer nur "falsches Passwort" liest, obwohl es stimmt,
|
||||||
|
# sucht den Fehler an der falschen Stelle.
|
||||||
|
if not user.is_active:
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_403_FORBIDDEN,
|
||||||
|
"Dieses Konto ist deaktiviert. Deine Listen bleiben erhalten – "
|
||||||
|
"wende dich an die Administration, wenn du den Zugang wieder "
|
||||||
|
"brauchst.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if user.verified_at is None:
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_403_FORBIDDEN,
|
||||||
|
"Bitte bestätige zuerst deine E-Mail-Adresse.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if needs_rehash(user.password_hash):
|
||||||
|
user.password_hash = hash_password(payload.password)
|
||||||
|
|
||||||
|
# Grundlage der automatischen Deaktivierung nach langer Untätigkeit.
|
||||||
|
user.last_seen_at = utcnow()
|
||||||
|
|
||||||
|
create_session(db, user, response)
|
||||||
|
db.commit()
|
||||||
|
return UserOut.of(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout", response_model=MessageOut)
|
||||||
|
def logout(request: Request, response: Response, db: DbSession, user: CurrentUser):
|
||||||
|
destroy_session(db, request, response)
|
||||||
|
db.commit()
|
||||||
|
return MessageOut(detail="Abgemeldet.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout-all", response_model=MessageOut)
|
||||||
|
def logout_all(request: Request, response: Response, db: DbSession, user: CurrentUser):
|
||||||
|
"""Meldet alle Geräte ab - z.B. nach Verdacht auf Kompromittierung."""
|
||||||
|
for sess in db.scalars(
|
||||||
|
select(UserSession).where(UserSession.user_id == user.id)
|
||||||
|
).all():
|
||||||
|
db.delete(sess)
|
||||||
|
response.delete_cookie(SESSION_COOKIE, path="/")
|
||||||
|
response.delete_cookie(CSRF_COOKIE, path="/")
|
||||||
|
db.commit()
|
||||||
|
return MessageOut(detail="Auf allen Geräten abgemeldet.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserOut)
|
||||||
|
def me(user: CurrentUser):
|
||||||
|
return UserOut.of(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/me", response_model=UserOut)
|
||||||
|
def update_me(payload: ProfileUpdateIn, db: DbSession, user: CurrentUser):
|
||||||
|
user.display_name = payload.display_name or None
|
||||||
|
db.commit()
|
||||||
|
return UserOut.of(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/password/change", response_model=MessageOut)
|
||||||
|
def change_password(
|
||||||
|
payload: PasswordChangeIn, request: Request, response: Response,
|
||||||
|
db: DbSession, user: CurrentUser,
|
||||||
|
):
|
||||||
|
if not verify_password(payload.current_password, user.password_hash):
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Aktuelles Passwort ist falsch.")
|
||||||
|
|
||||||
|
if verify_password(payload.new_password, user.password_hash):
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
"Das neue Passwort muss sich vom bisherigen unterscheiden.",
|
||||||
|
)
|
||||||
|
|
||||||
|
user.password_hash = hash_password(payload.new_password)
|
||||||
|
user.must_change_password = False
|
||||||
|
# Alle bestehenden Sessions verwerfen und eine neue ausstellen.
|
||||||
|
for sess in db.scalars(
|
||||||
|
select(UserSession).where(UserSession.user_id == user.id)
|
||||||
|
).all():
|
||||||
|
db.delete(sess)
|
||||||
|
db.flush()
|
||||||
|
create_session(db, user, response)
|
||||||
|
db.commit()
|
||||||
|
return MessageOut(detail="Passwort geändert. Andere Geräte wurden abgemeldet.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/password/reset-request", response_model=MessageOut)
|
||||||
|
def reset_request(
|
||||||
|
payload: PasswordResetRequestIn,
|
||||||
|
request: Request,
|
||||||
|
db: DbSession,
|
||||||
|
background: BackgroundTasks,
|
||||||
|
):
|
||||||
|
if not check_rate_limit(
|
||||||
|
db, f"reset:{client_ip(request)}", limit=5, window_minutes=60
|
||||||
|
):
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Versuche.")
|
||||||
|
|
||||||
|
email = normalize_email(payload.email)
|
||||||
|
user = db.scalar(select(User).where(User.email == email))
|
||||||
|
if user is not None and user.is_active and user.verified_at is not None:
|
||||||
|
raw = _issue_token(db, user, "reset", hours=1)
|
||||||
|
background.add_task(send_password_reset, email, raw)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return MessageOut(detail=_NEUTRAL)
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Willkommensnachricht: Passwort setzen und Konto freischalten
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
@router.get("/welcome/{token}", response_model=WelcomePreviewOut)
|
||||||
|
def welcome_preview(token: str, db: DbSession):
|
||||||
|
"""Zeigt, für welche Adresse der Link gilt - damit die Person sieht,
|
||||||
|
worauf sie sich einlässt, bevor sie ein Passwort vergibt."""
|
||||||
|
row = db.scalar(
|
||||||
|
select(EmailToken).where(
|
||||||
|
EmailToken.token_hash == hash_token(token),
|
||||||
|
EmailToken.purpose == "welcome",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if row is None or row.used_at is not None or row.expires_at <= utcnow():
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
"Der Link ist ungültig oder abgelaufen. Bitte die Administration "
|
||||||
|
"um eine neue Einladung.",
|
||||||
|
)
|
||||||
|
user = db.get(User, row.user_id)
|
||||||
|
if user is None or not user.is_active:
|
||||||
|
raise HTTPException(status.HTTP_410_GONE, "Das Konto steht nicht mehr bereit.")
|
||||||
|
|
||||||
|
return WelcomePreviewOut(email=user.email, display_name=user.display_name)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/welcome/complete", response_model=MessageOut)
|
||||||
|
def welcome_complete(payload: WelcomeCompleteIn, db: DbSession):
|
||||||
|
"""Setzt das erste Passwort und bestätigt die Adresse in einem Schritt.
|
||||||
|
|
||||||
|
Der Klick auf den Link aus der Willkommensnachricht beweist, dass das
|
||||||
|
Postfach erreichbar ist - eine zusätzliche Verifikationsmail wäre nur
|
||||||
|
ein weiterer Schritt ohne Erkenntnisgewinn.
|
||||||
|
"""
|
||||||
|
user = _consume_token(db, payload.token, "welcome")
|
||||||
|
if user is None:
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST, "Der Link ist ungültig oder abgelaufen."
|
||||||
|
)
|
||||||
|
if not user.is_active:
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(status.HTTP_410_GONE, "Das Konto steht nicht mehr bereit.")
|
||||||
|
|
||||||
|
user.password_hash = hash_password(payload.password)
|
||||||
|
user.must_change_password = False
|
||||||
|
user.verified_at = user.verified_at or utcnow()
|
||||||
|
user.last_seen_at = utcnow()
|
||||||
|
if payload.display_name is not None:
|
||||||
|
user.display_name = payload.display_name.strip() or None
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return MessageOut(detail="Zugang eingerichtet. Du kannst dich jetzt anmelden.")
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Bestätigung einer Adressänderung
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
@router.get("/email-change/{token}", include_in_schema=False)
|
||||||
|
def confirm_email_change(token: str, db: DbSession):
|
||||||
|
"""Ziel der Links aus den Bestätigungsmails.
|
||||||
|
|
||||||
|
Antwortet mit einer Weiterleitung auf die Oberfläche - hier klickt
|
||||||
|
jemand im Browser, nicht per API-Aufruf.
|
||||||
|
"""
|
||||||
|
digest = hash_token(token)
|
||||||
|
change = db.scalar(
|
||||||
|
select(EmailChange).where(
|
||||||
|
(EmailChange.token_new_hash == digest)
|
||||||
|
| (EmailChange.token_old_hash == digest)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if change is None:
|
||||||
|
return RedirectResponse("/?adresswechsel=unbekannt", status_code=303)
|
||||||
|
if change.cancelled_at or change.applied_at:
|
||||||
|
return RedirectResponse("/?adresswechsel=erledigt", status_code=303)
|
||||||
|
if change.expires_at <= utcnow():
|
||||||
|
return RedirectResponse("/?adresswechsel=abgelaufen", status_code=303)
|
||||||
|
|
||||||
|
if change.token_new_hash == digest:
|
||||||
|
change.confirmed_new_at = change.confirmed_new_at or utcnow()
|
||||||
|
else:
|
||||||
|
change.confirmed_old_at = change.confirmed_old_at or utcnow()
|
||||||
|
|
||||||
|
done = apply_if_complete(db, change)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
if done:
|
||||||
|
return RedirectResponse("/?adresswechsel=fertig", status_code=303)
|
||||||
|
# Bei Administratorkonten fehlt jetzt noch die zweite Bestätigung.
|
||||||
|
return RedirectResponse("/?adresswechsel=teilweise", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/password/reset", response_model=MessageOut)
|
||||||
|
def reset_password(payload: PasswordResetIn, db: DbSession):
|
||||||
|
user = _consume_token(db, payload.token, "reset")
|
||||||
|
if user is None:
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST, "Der Link ist ungültig oder abgelaufen."
|
||||||
|
)
|
||||||
|
|
||||||
|
user.password_hash = hash_password(payload.password)
|
||||||
|
user.must_change_password = False
|
||||||
|
for sess in db.scalars(
|
||||||
|
select(UserSession).where(UserSession.user_id == user.id)
|
||||||
|
).all():
|
||||||
|
db.delete(sess)
|
||||||
|
db.commit()
|
||||||
|
return MessageOut(detail="Passwort gesetzt. Du kannst dich jetzt anmelden.")
|
||||||
413
backend/app/routers/catalog.py
Normal file
413
backend/app/routers/catalog.py
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
"""Katalogdaten einer Liste: Märkte, Warengruppen, Artikel.
|
||||||
|
|
||||||
|
Alle Schreibzugriffe erhöhen den Revisionszähler der Liste und schreiben
|
||||||
|
den neuen Wert in `row_rev` der geänderten Zeile.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.deps import DbSession
|
||||||
|
from app.models import (
|
||||||
|
Article,
|
||||||
|
ArticleAttribute,
|
||||||
|
ArticleMarket,
|
||||||
|
Category,
|
||||||
|
ListItem,
|
||||||
|
Market,
|
||||||
|
)
|
||||||
|
from app.permissions import EditableList, ReadableList, bump_rev
|
||||||
|
from app.schemas import MessageOut
|
||||||
|
from app.product_lookup import lookup
|
||||||
|
from app.schemas_shopping import (
|
||||||
|
ArticleIn,
|
||||||
|
ArticleOut,
|
||||||
|
ArticleUpdateIn,
|
||||||
|
AttributeOut,
|
||||||
|
CategoryIn,
|
||||||
|
CategoryOut,
|
||||||
|
MarketIn,
|
||||||
|
MarketOut,
|
||||||
|
ProductLookupOut,
|
||||||
|
)
|
||||||
|
from app.security import utcnow
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/lists/{list_id}", tags=["catalog"])
|
||||||
|
|
||||||
|
|
||||||
|
def _check_belongs(db: Session, model, obj_id: str | None, list_id: str, label: str):
|
||||||
|
"""Verhindert, dass eine Liste auf Objekte einer fremden Liste zeigt.
|
||||||
|
Ohne diese Prüfung könnte ein Mitglied durch Angabe einer fremden ID
|
||||||
|
Rückschlüsse auf andere Listen ziehen."""
|
||||||
|
if obj_id is None:
|
||||||
|
return None
|
||||||
|
obj = db.get(model, obj_id)
|
||||||
|
if obj is None or obj.list_id != list_id or obj.deleted_at is not None:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"{label} nicht gefunden")
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Märkte
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
@router.get("/markets", response_model=list[MarketOut])
|
||||||
|
def get_markets(lst: ReadableList, db: DbSession):
|
||||||
|
rows = db.scalars(
|
||||||
|
select(Market)
|
||||||
|
.where(Market.list_id == lst.id, Market.deleted_at.is_(None))
|
||||||
|
.order_by(Market.sort_order, Market.name)
|
||||||
|
).all()
|
||||||
|
return [MarketOut.model_validate(m) for m in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/markets", response_model=MarketOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_market(payload: MarketIn, lst: EditableList, db: DbSession):
|
||||||
|
rev = bump_rev(db, lst.id)
|
||||||
|
market = Market(
|
||||||
|
list_id=lst.id, name=payload.name.strip(),
|
||||||
|
sort_order=payload.sort_order, row_rev=rev,
|
||||||
|
)
|
||||||
|
db.add(market)
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_409_CONFLICT, "Ein Markt mit diesem Namen existiert bereits."
|
||||||
|
) from None
|
||||||
|
return MarketOut.model_validate(market)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/markets/{market_id}", response_model=MarketOut)
|
||||||
|
def update_market(market_id: str, payload: MarketIn, lst: EditableList, db: DbSession):
|
||||||
|
market = _check_belongs(db, Market, market_id, lst.id, "Markt")
|
||||||
|
market.name = payload.name.strip()
|
||||||
|
market.sort_order = payload.sort_order
|
||||||
|
market.row_rev = bump_rev(db, lst.id)
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_409_CONFLICT, "Ein Markt mit diesem Namen existiert bereits."
|
||||||
|
) from None
|
||||||
|
return MarketOut.model_validate(market)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/markets/{market_id}", response_model=MessageOut)
|
||||||
|
def delete_market(market_id: str, lst: EditableList, db: DbSession):
|
||||||
|
market = _check_belongs(db, Market, market_id, lst.id, "Markt")
|
||||||
|
rev = bump_rev(db, lst.id)
|
||||||
|
market.deleted_at = utcnow()
|
||||||
|
market.row_rev = rev
|
||||||
|
|
||||||
|
# Einträge nicht mitlöschen, nur den Marktbezug lösen - sie landen
|
||||||
|
# dann in der Gruppe "Ohne Markt" und gehen nicht verloren.
|
||||||
|
for item in db.scalars(
|
||||||
|
select(ListItem).where(
|
||||||
|
ListItem.list_id == lst.id,
|
||||||
|
ListItem.market_id == market_id,
|
||||||
|
ListItem.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
).all():
|
||||||
|
item.market_id = None
|
||||||
|
item.row_rev = rev
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return MessageOut(detail="Markt gelöscht. Betroffene Einträge sind ohne Markt.")
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Warengruppen
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
@router.get("/categories", response_model=list[CategoryOut])
|
||||||
|
def get_categories(lst: ReadableList, db: DbSession):
|
||||||
|
rows = db.scalars(
|
||||||
|
select(Category)
|
||||||
|
.where(Category.list_id == lst.id, Category.deleted_at.is_(None))
|
||||||
|
.order_by(Category.sort_order, Category.name)
|
||||||
|
).all()
|
||||||
|
return [CategoryOut.model_validate(c) for c in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/categories", response_model=CategoryOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_category(payload: CategoryIn, lst: EditableList, db: DbSession):
|
||||||
|
rev = bump_rev(db, lst.id)
|
||||||
|
cat = Category(
|
||||||
|
list_id=lst.id, name=payload.name.strip(),
|
||||||
|
sort_order=payload.sort_order, row_rev=rev,
|
||||||
|
)
|
||||||
|
db.add(cat)
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_409_CONFLICT, "Diese Warengruppe existiert bereits."
|
||||||
|
) from None
|
||||||
|
return CategoryOut.model_validate(cat)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/categories/{category_id}", response_model=CategoryOut)
|
||||||
|
def update_category(
|
||||||
|
category_id: str, payload: CategoryIn, lst: EditableList, db: DbSession
|
||||||
|
):
|
||||||
|
cat = _check_belongs(db, Category, category_id, lst.id, "Warengruppe")
|
||||||
|
cat.name = payload.name.strip()
|
||||||
|
cat.sort_order = payload.sort_order
|
||||||
|
cat.row_rev = bump_rev(db, lst.id)
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_409_CONFLICT, "Diese Warengruppe existiert bereits."
|
||||||
|
) from None
|
||||||
|
return CategoryOut.model_validate(cat)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/categories/{category_id}", response_model=MessageOut)
|
||||||
|
def delete_category(category_id: str, lst: EditableList, db: DbSession):
|
||||||
|
cat = _check_belongs(db, Category, category_id, lst.id, "Warengruppe")
|
||||||
|
rev = bump_rev(db, lst.id)
|
||||||
|
cat.deleted_at = utcnow()
|
||||||
|
cat.row_rev = rev
|
||||||
|
|
||||||
|
for item in db.scalars(
|
||||||
|
select(ListItem).where(
|
||||||
|
ListItem.list_id == lst.id,
|
||||||
|
ListItem.category_id == category_id,
|
||||||
|
ListItem.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
).all():
|
||||||
|
item.category_id = None
|
||||||
|
item.row_rev = rev
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return MessageOut(detail="Warengruppe gelöscht.")
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Artikel
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
def _article_out(a: Article) -> ArticleOut:
|
||||||
|
return ArticleOut(
|
||||||
|
id=a.id, name=a.name, barcode=a.barcode, note=a.note,
|
||||||
|
default_market_id=a.default_market_id,
|
||||||
|
default_category_id=a.default_category_id,
|
||||||
|
attributes=[
|
||||||
|
AttributeOut(name=x.attr_name, value=x.attr_value) for x in a.attributes
|
||||||
|
],
|
||||||
|
available_market_ids=[x.market_id for x in a.availability],
|
||||||
|
row_rev=a.row_rev,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_attributes(db: Session, article: Article, attributes) -> None:
|
||||||
|
"""Ersetzt die Attributmenge vollständig."""
|
||||||
|
for old in list(article.attributes):
|
||||||
|
db.delete(old)
|
||||||
|
article.attributes = []
|
||||||
|
db.flush()
|
||||||
|
seen = set()
|
||||||
|
for attr in attributes:
|
||||||
|
name = attr.name.strip()
|
||||||
|
if not name or name in seen:
|
||||||
|
continue
|
||||||
|
seen.add(name)
|
||||||
|
db.add(
|
||||||
|
ArticleAttribute(
|
||||||
|
article_id=article.id, attr_name=name, attr_value=attr.value.strip()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_availability(db: Session, article: Article, market_ids, list_id: str) -> None:
|
||||||
|
for old in db.scalars(
|
||||||
|
select(ArticleMarket).where(ArticleMarket.article_id == article.id)
|
||||||
|
).all():
|
||||||
|
db.delete(old)
|
||||||
|
db.flush()
|
||||||
|
for mid in dict.fromkeys(market_ids):
|
||||||
|
_check_belongs(db, Market, mid, list_id, "Markt")
|
||||||
|
db.add(ArticleMarket(article_id=article.id, market_id=mid))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/articles", response_model=list[ArticleOut])
|
||||||
|
def get_articles(
|
||||||
|
lst: ReadableList,
|
||||||
|
db: DbSession,
|
||||||
|
q: str | None = None,
|
||||||
|
with_barcode: bool | None = None,
|
||||||
|
):
|
||||||
|
stmt = select(Article).where(
|
||||||
|
Article.list_id == lst.id, Article.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
if q:
|
||||||
|
# Nur der LIKE-Operator, kein zusammengebautes SQL - der Suchtext
|
||||||
|
# geht als gebundener Parameter in die Abfrage. Sonderzeichen von
|
||||||
|
# LIKE werden maskiert, damit "50%" nicht alles findet.
|
||||||
|
needle = q.strip().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
stmt = stmt.where(Article.name.like(f"%{needle}%", escape="\\"))
|
||||||
|
if with_barcode is True:
|
||||||
|
stmt = stmt.where(Article.barcode.is_not(None))
|
||||||
|
elif with_barcode is False:
|
||||||
|
stmt = stmt.where(Article.barcode.is_(None))
|
||||||
|
|
||||||
|
rows = db.scalars(stmt.order_by(Article.name).limit(500)).all()
|
||||||
|
return [_article_out(a) for a in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/barcode/{barcode}", response_model=ProductLookupOut)
|
||||||
|
def resolve_barcode(barcode: str, lst: ReadableList, db: DbSession):
|
||||||
|
"""Strichcode auflösen - erst im eigenen Bestand, dann außerhalb.
|
||||||
|
|
||||||
|
Die Reihenfolge ist wichtig: Was hier schon gepflegt wurde, ist
|
||||||
|
verlässlicher als eine Fremdquelle, und ein eigener Name soll nicht
|
||||||
|
von einer Datenbank überschrieben werden.
|
||||||
|
"""
|
||||||
|
article = db.scalar(
|
||||||
|
select(Article).where(
|
||||||
|
Article.list_id == lst.id,
|
||||||
|
Article.barcode == barcode,
|
||||||
|
Article.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if article is not None:
|
||||||
|
return ProductLookupOut(
|
||||||
|
barcode=barcode, found=True, source="catalog",
|
||||||
|
article_id=article.id, name=article.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
hit = lookup(db, barcode)
|
||||||
|
if hit is None or not hit.found:
|
||||||
|
return ProductLookupOut(barcode=barcode, found=False, source="none")
|
||||||
|
|
||||||
|
return ProductLookupOut(
|
||||||
|
barcode=barcode,
|
||||||
|
found=True,
|
||||||
|
source="openfoodfacts",
|
||||||
|
name=hit.name,
|
||||||
|
brand=hit.brand,
|
||||||
|
package=hit.package,
|
||||||
|
count=hit.count,
|
||||||
|
pack_size=hit.pack_size,
|
||||||
|
pack_unit=hit.pack_unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/articles/by-barcode/{barcode}", response_model=ArticleOut)
|
||||||
|
def article_by_barcode(barcode: str, lst: ReadableList, db: DbSession):
|
||||||
|
article = db.scalar(
|
||||||
|
select(Article).where(
|
||||||
|
Article.list_id == lst.id,
|
||||||
|
Article.barcode == barcode,
|
||||||
|
Article.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if article is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_404_NOT_FOUND, "Kein Artikel mit diesem Barcode"
|
||||||
|
)
|
||||||
|
return _article_out(article)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/articles", response_model=ArticleOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_article(payload: ArticleIn, lst: EditableList, db: DbSession):
|
||||||
|
_check_belongs(db, Market, payload.default_market_id, lst.id, "Markt")
|
||||||
|
_check_belongs(db, Category, payload.default_category_id, lst.id, "Warengruppe")
|
||||||
|
|
||||||
|
rev = bump_rev(db, lst.id)
|
||||||
|
article = Article(
|
||||||
|
list_id=lst.id, name=payload.name.strip(),
|
||||||
|
barcode=(payload.barcode or None), note=(payload.note or None),
|
||||||
|
default_market_id=payload.default_market_id,
|
||||||
|
default_category_id=payload.default_category_id,
|
||||||
|
row_rev=rev,
|
||||||
|
)
|
||||||
|
db.add(article)
|
||||||
|
db.flush()
|
||||||
|
_apply_attributes(db, article, payload.attributes)
|
||||||
|
_apply_availability(db, article, payload.available_market_ids, lst.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_409_CONFLICT, "Ein Artikel mit diesem Namen existiert bereits."
|
||||||
|
) from None
|
||||||
|
|
||||||
|
db.refresh(article)
|
||||||
|
return _article_out(article)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/articles/{article_id}", response_model=ArticleOut)
|
||||||
|
def update_article(
|
||||||
|
article_id: str, payload: ArticleUpdateIn, lst: EditableList, db: DbSession
|
||||||
|
):
|
||||||
|
article = _check_belongs(db, Article, article_id, lst.id, "Artikel")
|
||||||
|
data = payload.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
if "default_market_id" in data:
|
||||||
|
_check_belongs(db, Market, data["default_market_id"], lst.id, "Markt")
|
||||||
|
article.default_market_id = data["default_market_id"]
|
||||||
|
if "default_category_id" in data:
|
||||||
|
_check_belongs(db, Category, data["default_category_id"], lst.id, "Warengruppe")
|
||||||
|
article.default_category_id = data["default_category_id"]
|
||||||
|
if "name" in data and data["name"]:
|
||||||
|
article.name = data["name"].strip()
|
||||||
|
if "barcode" in data:
|
||||||
|
article.barcode = data["barcode"] or None
|
||||||
|
if "note" in data:
|
||||||
|
article.note = data["note"] or None
|
||||||
|
|
||||||
|
if payload.attributes is not None:
|
||||||
|
_apply_attributes(db, article, payload.attributes)
|
||||||
|
if payload.available_market_ids is not None:
|
||||||
|
_apply_availability(db, article, payload.available_market_ids, lst.id)
|
||||||
|
|
||||||
|
article.row_rev = bump_rev(db, lst.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_409_CONFLICT, "Ein Artikel mit diesem Namen existiert bereits."
|
||||||
|
) from None
|
||||||
|
|
||||||
|
db.refresh(article)
|
||||||
|
return _article_out(article)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/articles/{article_id}", response_model=MessageOut)
|
||||||
|
def delete_article(article_id: str, lst: EditableList, db: DbSession):
|
||||||
|
article = _check_belongs(db, Article, article_id, lst.id, "Artikel")
|
||||||
|
rev = bump_rev(db, lst.id)
|
||||||
|
article.deleted_at = utcnow()
|
||||||
|
article.row_rev = rev
|
||||||
|
|
||||||
|
# Offene Einträge dieses Artikels verschwinden mit. Alles andere
|
||||||
|
# hinterließe Einträge, die auf einen gelöschten Artikel zeigen.
|
||||||
|
removed = 0
|
||||||
|
for item in db.scalars(
|
||||||
|
select(ListItem).where(
|
||||||
|
ListItem.list_id == lst.id,
|
||||||
|
ListItem.article_id == article_id,
|
||||||
|
ListItem.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
).all():
|
||||||
|
item.deleted_at = utcnow()
|
||||||
|
item.row_rev = rev
|
||||||
|
removed += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return MessageOut(
|
||||||
|
detail=f"Artikel gelöscht, dazu {removed} Eintrag/Einträge auf der Liste."
|
||||||
|
)
|
||||||
245
backend/app/routers/items.py
Normal file
245
backend/app/routers/items.py
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
"""Listeneinträge: anlegen, ändern, verschieben, zurückstellen, löschen.
|
||||||
|
|
||||||
|
Dazu die gruppierte Ansicht Markt -> Warengruppe -> Artikel, die sowohl
|
||||||
|
die App als auch der Druck in Phase 9 verwendet. Die Sortierlogik liegt
|
||||||
|
bewusst nur hier, damit Bildschirm und Papier nicht auseinanderlaufen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, status
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
from app.deps import DbSession, VerifiedUser
|
||||||
|
from app.models import Article, Category, ListItem, Market
|
||||||
|
from app.list_view import build_view, item_out
|
||||||
|
from app.print_view import render_print
|
||||||
|
from app.permissions import EditableList, ReadableList, bump_rev
|
||||||
|
from app.prices import record_price
|
||||||
|
from app.push import notify_list_changed
|
||||||
|
from app.routers.catalog import _check_belongs
|
||||||
|
from app.schemas import MessageOut
|
||||||
|
from app.schemas_shopping import (
|
||||||
|
ClearBoughtOut,
|
||||||
|
ItemCreateIn,
|
||||||
|
ItemOut,
|
||||||
|
ItemStatus,
|
||||||
|
ItemUpdateIn,
|
||||||
|
ListView,
|
||||||
|
)
|
||||||
|
from app.security import utcnow
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/lists/{list_id}", tags=["items"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/items", response_model=list[ItemOut])
|
||||||
|
def get_items(
|
||||||
|
lst: ReadableList,
|
||||||
|
db: DbSession,
|
||||||
|
status_filter: ItemStatus | None = Query(default=None, alias="status"),
|
||||||
|
):
|
||||||
|
stmt = select(ListItem).where(
|
||||||
|
ListItem.list_id == lst.id, ListItem.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
if status_filter:
|
||||||
|
stmt = stmt.where(ListItem.status == status_filter)
|
||||||
|
rows = db.scalars(stmt.order_by(ListItem.created_at)).all()
|
||||||
|
return [item_out(i) for i in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/items", response_model=ItemOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_item(
|
||||||
|
payload: ItemCreateIn,
|
||||||
|
lst: EditableList,
|
||||||
|
db: DbSession,
|
||||||
|
user: VerifiedUser,
|
||||||
|
background: BackgroundTasks,
|
||||||
|
):
|
||||||
|
if not payload.article_id and not payload.article_name:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
"Entweder article_id oder article_name angeben.",
|
||||||
|
)
|
||||||
|
|
||||||
|
rev = bump_rev(db, lst.id)
|
||||||
|
|
||||||
|
if payload.article_id:
|
||||||
|
article = _check_belongs(db, Article, payload.article_id, lst.id, "Artikel")
|
||||||
|
else:
|
||||||
|
name = payload.article_name.strip()
|
||||||
|
# Vorhandenen Artikel wiederverwenden. Der Vergleich ist dank
|
||||||
|
# utf8mb4_unicode_ci von Haus aus unabhaengig von Gross- und
|
||||||
|
# Kleinschreibung.
|
||||||
|
article = db.scalar(
|
||||||
|
select(Article).where(
|
||||||
|
Article.list_id == lst.id,
|
||||||
|
Article.name == name,
|
||||||
|
Article.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if article is None:
|
||||||
|
article = Article(list_id=lst.id, name=name, row_rev=rev)
|
||||||
|
db.add(article)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
_check_belongs(db, Market, payload.market_id, lst.id, "Markt")
|
||||||
|
_check_belongs(db, Category, payload.category_id, lst.id, "Warengruppe")
|
||||||
|
|
||||||
|
item = ListItem(
|
||||||
|
list_id=lst.id,
|
||||||
|
article_id=article.id,
|
||||||
|
# Ohne Angabe die Vorgaben aus dem Artikel übernehmen.
|
||||||
|
market_id=payload.market_id or article.default_market_id,
|
||||||
|
category_id=payload.category_id or article.default_category_id,
|
||||||
|
count=payload.count,
|
||||||
|
pack_size=payload.pack_size,
|
||||||
|
pack_unit=(payload.pack_unit or None),
|
||||||
|
variant=(payload.variant or None),
|
||||||
|
note=(payload.note or None),
|
||||||
|
status="open",
|
||||||
|
created_by=user.id,
|
||||||
|
row_rev=rev,
|
||||||
|
)
|
||||||
|
db.add(item)
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_409_CONFLICT, "Eintrag konnte nicht angelegt werden."
|
||||||
|
) from None
|
||||||
|
|
||||||
|
db.refresh(item)
|
||||||
|
background.add_task(notify_list_changed, lst.id, user.id)
|
||||||
|
return item_out(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/items/{item_id}", response_model=ItemOut)
|
||||||
|
def update_item(
|
||||||
|
item_id: str,
|
||||||
|
payload: ItemUpdateIn,
|
||||||
|
lst: EditableList,
|
||||||
|
db: DbSession,
|
||||||
|
user: VerifiedUser,
|
||||||
|
background: BackgroundTasks,
|
||||||
|
):
|
||||||
|
"""Deckt alles ab, was während des Einkaufs passiert: abhaken,
|
||||||
|
zurückstellen, Menge ändern, Preis erfassen, in einen anderen Markt
|
||||||
|
verschieben."""
|
||||||
|
item = db.get(ListItem, item_id)
|
||||||
|
if item is None or item.list_id != lst.id or item.deleted_at is not None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Eintrag nicht gefunden")
|
||||||
|
|
||||||
|
data = payload.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
if payload.clear_market:
|
||||||
|
item.market_id = None
|
||||||
|
elif "market_id" in data:
|
||||||
|
_check_belongs(db, Market, data["market_id"], lst.id, "Markt")
|
||||||
|
item.market_id = data["market_id"]
|
||||||
|
|
||||||
|
if payload.clear_category:
|
||||||
|
item.category_id = None
|
||||||
|
elif "category_id" in data:
|
||||||
|
_check_belongs(db, Category, data["category_id"], lst.id, "Warengruppe")
|
||||||
|
item.category_id = data["category_id"]
|
||||||
|
|
||||||
|
for field in ("count", "pack_size", "pack_unit", "variant", "note",
|
||||||
|
"status", "price_cents"):
|
||||||
|
if field in data:
|
||||||
|
setattr(item, field, data[field])
|
||||||
|
|
||||||
|
item.row_rev = bump_rev(db, lst.id)
|
||||||
|
# Nach dem Setzen der Felder: Der Preis wird nur festgehalten, wenn
|
||||||
|
# auch ein Markt zugeordnet ist - ohne den ergibt er keinen
|
||||||
|
# Vergleichswert.
|
||||||
|
record_price(db, item)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(item)
|
||||||
|
background.add_task(notify_list_changed, lst.id, user.id)
|
||||||
|
return item_out(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/items/{item_id}", response_model=MessageOut)
|
||||||
|
def delete_item(
|
||||||
|
item_id: str, lst: EditableList, db: DbSession, user: VerifiedUser,
|
||||||
|
background: BackgroundTasks,
|
||||||
|
):
|
||||||
|
item = db.get(ListItem, item_id)
|
||||||
|
if item is None or item.list_id != lst.id or item.deleted_at is not None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Eintrag nicht gefunden")
|
||||||
|
|
||||||
|
item.deleted_at = utcnow()
|
||||||
|
item.row_rev = bump_rev(db, lst.id)
|
||||||
|
db.commit()
|
||||||
|
background.add_task(notify_list_changed, lst.id, user.id)
|
||||||
|
return MessageOut(detail="Eintrag gelöscht.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/items/clear-bought", response_model=ClearBoughtOut)
|
||||||
|
def clear_bought(
|
||||||
|
lst: EditableList, db: DbSession, user: VerifiedUser,
|
||||||
|
background: BackgroundTasks,
|
||||||
|
):
|
||||||
|
"""Entfernt alle als gekauft markierten Einträge in einem Zug.
|
||||||
|
|
||||||
|
Ein einziger Revisionsschritt für den gesamten Vorgang - so sieht ein
|
||||||
|
synchronisierender Client eine geschlossene Änderung statt dutzender
|
||||||
|
einzelner.
|
||||||
|
"""
|
||||||
|
rev = bump_rev(db, lst.id)
|
||||||
|
now = utcnow()
|
||||||
|
rows = db.scalars(
|
||||||
|
select(ListItem).where(
|
||||||
|
ListItem.list_id == lst.id,
|
||||||
|
ListItem.status == "bought",
|
||||||
|
ListItem.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for item in rows:
|
||||||
|
item.deleted_at = now
|
||||||
|
item.row_rev = rev
|
||||||
|
db.commit()
|
||||||
|
if rows:
|
||||||
|
background.add_task(notify_list_changed, lst.id, user.id)
|
||||||
|
return ClearBoughtOut(removed=len(rows), rev=rev)
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Gruppierte Ansicht
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
@router.get("/print", response_class=HTMLResponse, include_in_schema=True)
|
||||||
|
def print_view(
|
||||||
|
lst: ReadableList,
|
||||||
|
db: DbSession,
|
||||||
|
include_bought: bool = Query(default=True),
|
||||||
|
include_deferred: bool = Query(default=False),
|
||||||
|
):
|
||||||
|
"""Druckfertige Seite auf A4.
|
||||||
|
|
||||||
|
Standardmäßig ohne zurückgestellte Artikel: Wer ausdruckt, will die
|
||||||
|
Einkaufsliste, nicht die Merkliste.
|
||||||
|
"""
|
||||||
|
view = build_view(
|
||||||
|
db, lst, include_bought=include_bought, include_deferred=include_deferred
|
||||||
|
)
|
||||||
|
return HTMLResponse(render_print(view, include_bought=include_bought))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/view", response_model=ListView)
|
||||||
|
def grouped_view(
|
||||||
|
lst: ReadableList,
|
||||||
|
db: DbSession,
|
||||||
|
include_bought: bool = Query(default=True),
|
||||||
|
include_deferred: bool = Query(default=True),
|
||||||
|
):
|
||||||
|
"""Markt -> Warengruppe -> Artikel, alphabetisch innerhalb der Gruppe.
|
||||||
|
|
||||||
|
Die Aufbereitung liegt in app/list_view.py, weil der oeffentliche Link
|
||||||
|
und der Ausdruck dieselbe Gliederung brauchen.
|
||||||
|
"""
|
||||||
|
return build_view(
|
||||||
|
db, lst, include_bought=include_bought, include_deferred=include_deferred
|
||||||
|
)
|
||||||
205
backend/app/routers/lists.py
Normal file
205
backend/app/routers/lists.py
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from app.deps import DbSession, VerifiedUser
|
||||||
|
from app.models import Category, ListMember, Market, ShoppingList, User
|
||||||
|
from app.permissions import OwnedList, ReadableList
|
||||||
|
from app.schemas import MessageOut
|
||||||
|
from app.list_view import build_view
|
||||||
|
from app.schemas_shopping import (
|
||||||
|
CategoryOut,
|
||||||
|
ListCreateIn,
|
||||||
|
ListOut,
|
||||||
|
ListSnapshot,
|
||||||
|
ListUpdateIn,
|
||||||
|
MarketOut,
|
||||||
|
MemberOut,
|
||||||
|
MemberRoleIn,
|
||||||
|
)
|
||||||
|
from app.security import utcnow
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/lists", tags=["lists"])
|
||||||
|
|
||||||
|
MAX_LISTS_PER_USER = 100
|
||||||
|
|
||||||
|
|
||||||
|
def _to_out(db, lst: ShoppingList, role: str, may_share: bool = False) -> ListOut:
|
||||||
|
count = db.scalar(
|
||||||
|
select(func.count()).select_from(ListMember).where(ListMember.list_id == lst.id)
|
||||||
|
)
|
||||||
|
return ListOut(
|
||||||
|
id=lst.id, name=lst.name, owner_id=lst.owner_id, rev=lst.rev,
|
||||||
|
role=role, may_share_public=(role == "owner") or may_share,
|
||||||
|
member_count=count or 0, created_at=lst.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[ListOut])
|
||||||
|
def my_lists(db: DbSession, user: VerifiedUser):
|
||||||
|
rows = db.execute(
|
||||||
|
select(ShoppingList, ListMember.role, ListMember.may_share_public)
|
||||||
|
.join(ListMember, ListMember.list_id == ShoppingList.id)
|
||||||
|
.where(ListMember.user_id == user.id, ShoppingList.deleted_at.is_(None))
|
||||||
|
.order_by(ShoppingList.created_at)
|
||||||
|
).all()
|
||||||
|
return [_to_out(db, lst, role, may) for lst, role, may in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ListOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_list(payload: ListCreateIn, db: DbSession, user: VerifiedUser):
|
||||||
|
owned = db.scalar(
|
||||||
|
select(func.count()).select_from(ShoppingList).where(
|
||||||
|
ShoppingList.owner_id == user.id, ShoppingList.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (owned or 0) >= MAX_LISTS_PER_USER:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_409_CONFLICT,
|
||||||
|
f"Höchstens {MAX_LISTS_PER_USER} eigene Listen.",
|
||||||
|
)
|
||||||
|
|
||||||
|
lst = ShoppingList(name=payload.name.strip(), owner_id=user.id, rev=1)
|
||||||
|
db.add(lst)
|
||||||
|
db.flush()
|
||||||
|
db.add(ListMember(
|
||||||
|
list_id=lst.id, user_id=user.id, role="owner", may_share_public=True
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
return _to_out(db, lst, "owner")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{list_id}", response_model=ListOut)
|
||||||
|
def read_list(lst: ReadableList, db: DbSession, user: VerifiedUser):
|
||||||
|
member = db.get(ListMember, (lst.id, user.id))
|
||||||
|
return _to_out(db, lst, member.role, member.may_share_public)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{list_id}/snapshot", response_model=ListSnapshot)
|
||||||
|
def snapshot(lst: ReadableList, db: DbSession, user: VerifiedUser):
|
||||||
|
"""Gesamtstand in einem Aufruf: Liste, gruppierte Ansicht, Märkte,
|
||||||
|
Warengruppen. Ersetzt vier einzelne Abfragen."""
|
||||||
|
member = db.get(ListMember, (lst.id, user.id))
|
||||||
|
markets = db.scalars(
|
||||||
|
select(Market)
|
||||||
|
.where(Market.list_id == lst.id, Market.deleted_at.is_(None))
|
||||||
|
.order_by(Market.sort_order, Market.name)
|
||||||
|
).all()
|
||||||
|
categories = db.scalars(
|
||||||
|
select(Category)
|
||||||
|
.where(Category.list_id == lst.id, Category.deleted_at.is_(None))
|
||||||
|
.order_by(Category.sort_order, Category.name)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
return ListSnapshot(
|
||||||
|
list=_to_out(db, lst, member.role, member.may_share_public),
|
||||||
|
view=build_view(db, lst),
|
||||||
|
markets=[MarketOut.model_validate(m) for m in markets],
|
||||||
|
categories=[CategoryOut.model_validate(c) for c in categories],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{list_id}", response_model=ListOut)
|
||||||
|
def rename_list(payload: ListUpdateIn, lst: OwnedList, db: DbSession, user: VerifiedUser):
|
||||||
|
lst.name = payload.name.strip()
|
||||||
|
lst.rev += 1
|
||||||
|
db.commit()
|
||||||
|
return _to_out(db, lst, "owner")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{list_id}", response_model=MessageOut)
|
||||||
|
def delete_list(lst: OwnedList, db: DbSession):
|
||||||
|
# Soft Delete: offline-Clients sollen das Verschwinden noch mitbekommen.
|
||||||
|
lst.deleted_at = utcnow()
|
||||||
|
lst.rev += 1
|
||||||
|
db.commit()
|
||||||
|
return MessageOut(detail="Liste gelöscht.")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Mitgliedschaften
|
||||||
|
# Einladungen per Mail und QR-Code folgen in Phase 5. Hier lassen sich
|
||||||
|
# bestehende Mitgliedschaften nur ansehen, ändern und beenden.
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/{list_id}/members", response_model=list[MemberOut])
|
||||||
|
def list_members(lst: ReadableList, db: DbSession):
|
||||||
|
rows = db.execute(
|
||||||
|
select(ListMember, User)
|
||||||
|
.join(User, User.id == ListMember.user_id)
|
||||||
|
.where(ListMember.list_id == lst.id)
|
||||||
|
.order_by(ListMember.joined_at)
|
||||||
|
).all()
|
||||||
|
return [
|
||||||
|
MemberOut(
|
||||||
|
user_id=u.id, email=u.email, display_name=u.display_name,
|
||||||
|
role=m.role, may_share_public=m.may_share_public,
|
||||||
|
joined_at=m.joined_at,
|
||||||
|
)
|
||||||
|
for m, u in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{list_id}/members/{user_id}", response_model=MemberOut)
|
||||||
|
def set_member_role(
|
||||||
|
user_id: str, payload: MemberRoleIn, lst: OwnedList, db: DbSession,
|
||||||
|
user: VerifiedUser,
|
||||||
|
):
|
||||||
|
if user_id == lst.owner_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_409_CONFLICT,
|
||||||
|
"Die Rolle des Eigentümers lässt sich nicht ändern. "
|
||||||
|
"Übertrage stattdessen die Liste.",
|
||||||
|
)
|
||||||
|
member = db.get(ListMember, (lst.id, user_id))
|
||||||
|
if member is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Kein Mitglied dieser Liste")
|
||||||
|
|
||||||
|
member.role = payload.role
|
||||||
|
if payload.may_share_public is not None:
|
||||||
|
member.may_share_public = payload.may_share_public
|
||||||
|
lst.rev += 1
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
target = db.get(User, user_id)
|
||||||
|
return MemberOut(
|
||||||
|
user_id=target.id, email=target.email, display_name=target.display_name,
|
||||||
|
role=member.role, may_share_public=member.may_share_public,
|
||||||
|
joined_at=member.joined_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{list_id}/members/{user_id}", response_model=MessageOut)
|
||||||
|
def remove_member(user_id: str, list_id: str, db: DbSession, user: VerifiedUser):
|
||||||
|
"""Der Eigentümer entzieht anderen den Zugriff; jedes Mitglied kann
|
||||||
|
sich selbst entfernen (bequemer über POST /api/lists/{id}/leave).
|
||||||
|
|
||||||
|
Der Eigentümer kann sich nicht selbst entfernen - die Liste stünde
|
||||||
|
sonst ohne Verwaltung da. Dafür gibt es den Eigentümerwechsel."""
|
||||||
|
lst = db.scalar(
|
||||||
|
select(ShoppingList).where(
|
||||||
|
ShoppingList.id == list_id, ShoppingList.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
own = db.get(ListMember, (list_id, user.id))
|
||||||
|
if lst is None or own is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Liste nicht gefunden")
|
||||||
|
|
||||||
|
if user_id != user.id and own.role != "owner":
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_403_FORBIDDEN, "Nur der Eigentümer kann andere entfernen."
|
||||||
|
)
|
||||||
|
if user_id == lst.owner_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_409_CONFLICT,
|
||||||
|
"Der Eigentümer kann die Liste nicht verlassen. Lösche sie oder "
|
||||||
|
"übertrage sie zuvor.",
|
||||||
|
)
|
||||||
|
|
||||||
|
member = db.get(ListMember, (list_id, user_id))
|
||||||
|
if member is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Kein Mitglied dieser Liste")
|
||||||
|
|
||||||
|
db.delete(member)
|
||||||
|
lst.rev += 1
|
||||||
|
db.commit()
|
||||||
|
return MessageOut(detail="Mitgliedschaft beendet.")
|
||||||
39
backend/app/routers/prices.py
Normal file
39
backend/app/routers/prices.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""Preisvergleich: Übersicht, Verlauf je Artikel, Kurzhinweise."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.deps import DbSession
|
||||||
|
from app.models import Article
|
||||||
|
from app.permissions import ReadableList
|
||||||
|
from app.prices import article_prices, hints, overview
|
||||||
|
from app.schemas_shopping import ArticlePrices, PriceHint, PriceOverview
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/lists/{list_id}", tags=["prices"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/prices", response_model=PriceOverview)
|
||||||
|
def price_overview(lst: ReadableList, db: DbSession):
|
||||||
|
"""Vergleichstabelle: je Artikel der jüngste Preis in jedem Markt."""
|
||||||
|
return overview(db, lst.id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/price-hints", response_model=list[PriceHint])
|
||||||
|
def price_hints(lst: ReadableList, db: DbSession):
|
||||||
|
"""Kompakte Fassung für die Listenansicht - nur Artikel, bei denen
|
||||||
|
sich die Märkte tatsächlich unterscheiden."""
|
||||||
|
return hints(db, lst.id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/articles/{article_id}/prices", response_model=ArticlePrices)
|
||||||
|
def article_price_history(article_id: str, lst: ReadableList, db: DbSession):
|
||||||
|
article = db.scalar(
|
||||||
|
select(Article).where(
|
||||||
|
Article.id == article_id,
|
||||||
|
Article.list_id == lst.id,
|
||||||
|
Article.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if article is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Artikel nicht gefunden")
|
||||||
|
return article_prices(db, lst.id, article)
|
||||||
297
backend/app/routers/public.py
Normal file
297
backend/app/routers/public.py
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
"""Öffentliche Ansichtslinks.
|
||||||
|
|
||||||
|
Wer den Link hat, darf die Liste sehen und - sofern erlaubt - Artikel
|
||||||
|
abhaken. Sonst nichts: kein Anlegen, kein Löschen, keine Preise ändern,
|
||||||
|
keine Mitgliederliste, keine Namen.
|
||||||
|
|
||||||
|
Sicherheitsmodell: Der Link *ist* die Berechtigung. Wer ihn
|
||||||
|
weitergibt, gibt den Zugriff weiter. Deshalb ist ein Ablaufdatum Pflicht,
|
||||||
|
der Widerruf jederzeit möglich, und in der Datenbank steht nur der Hash
|
||||||
|
des Tokens.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.deps import DbSession, VerifiedUser, client_ip
|
||||||
|
from app.list_view import build_view
|
||||||
|
from app.print_view import render_print
|
||||||
|
from app.models import ListItem, ListMember, PublicShare, ShoppingList
|
||||||
|
from app.permissions import bump_rev
|
||||||
|
from app.schemas import MessageOut
|
||||||
|
from app.schemas_shopping import (
|
||||||
|
PublicShareCreatedOut,
|
||||||
|
PublicShareIn,
|
||||||
|
PublicShareOut,
|
||||||
|
PublicToggleIn,
|
||||||
|
PublicViewOut,
|
||||||
|
)
|
||||||
|
from app.security import check_rate_limit, hash_token, new_token, utcnow
|
||||||
|
|
||||||
|
router = APIRouter(tags=["public"])
|
||||||
|
|
||||||
|
MAX_DAYS = 365
|
||||||
|
MAX_ACTIVE_LINKS = 10
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Verwaltung (angemeldet)
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
def sharer(list_id: str, db: DbSession, user: VerifiedUser) -> ShoppingList:
|
||||||
|
"""Eigentümer oder Mitglied mit dem Zusatzrecht `may_share_public`."""
|
||||||
|
lst = db.scalar(
|
||||||
|
select(ShoppingList).where(
|
||||||
|
ShoppingList.id == list_id, ShoppingList.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
member = db.get(ListMember, (list_id, user.id))
|
||||||
|
if lst is None or member is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Liste nicht gefunden")
|
||||||
|
|
||||||
|
if member.role != "owner" and not member.may_share_public:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_403_FORBIDDEN,
|
||||||
|
"Für öffentliche Links fehlt dir die Berechtigung. Der Eigentümer "
|
||||||
|
"der Liste kann sie dir erteilen.",
|
||||||
|
)
|
||||||
|
return lst
|
||||||
|
|
||||||
|
|
||||||
|
SharableList = Annotated[ShoppingList, Depends(sharer)]
|
||||||
|
|
||||||
|
|
||||||
|
def _status(share: PublicShare) -> str:
|
||||||
|
if share.revoked_at:
|
||||||
|
return "revoked"
|
||||||
|
if share.expires_at <= utcnow():
|
||||||
|
return "expired"
|
||||||
|
return "active"
|
||||||
|
|
||||||
|
|
||||||
|
def _share_out(share: PublicShare) -> PublicShareOut:
|
||||||
|
creator = share.creator
|
||||||
|
return PublicShareOut(
|
||||||
|
id=share.id,
|
||||||
|
label=share.label,
|
||||||
|
allow_check=share.allow_check,
|
||||||
|
status=_status(share),
|
||||||
|
created_by_name=(
|
||||||
|
(creator.display_name or creator.email.split("@")[0]) if creator else None
|
||||||
|
),
|
||||||
|
created_at=share.created_at,
|
||||||
|
expires_at=share.expires_at,
|
||||||
|
last_access_at=share.last_access_at,
|
||||||
|
access_count=share.access_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/lists/{list_id}/public-links", response_model=list[PublicShareOut])
|
||||||
|
def get_public_links(lst: SharableList, db: DbSession):
|
||||||
|
rows = db.scalars(
|
||||||
|
select(PublicShare)
|
||||||
|
.where(PublicShare.list_id == lst.id)
|
||||||
|
.order_by(PublicShare.created_at.desc())
|
||||||
|
).all()
|
||||||
|
return [_share_out(s) for s in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/lists/{list_id}/public-links",
|
||||||
|
response_model=PublicShareCreatedOut,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def create_public_link(
|
||||||
|
payload: PublicShareIn, lst: SharableList, db: DbSession, user: VerifiedUser
|
||||||
|
):
|
||||||
|
expires = payload.expires_at.replace(tzinfo=None)
|
||||||
|
now = utcnow()
|
||||||
|
|
||||||
|
if expires <= now:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST, "Das Ablaufdatum muss in der Zukunft liegen."
|
||||||
|
)
|
||||||
|
if expires > now + timedelta(days=MAX_DAYS):
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
f"Ein Link darf höchstens {MAX_DAYS} Tage gültig sein.",
|
||||||
|
)
|
||||||
|
|
||||||
|
active = [
|
||||||
|
s
|
||||||
|
for s in db.scalars(
|
||||||
|
select(PublicShare).where(PublicShare.list_id == lst.id)
|
||||||
|
).all()
|
||||||
|
if _status(s) == "active"
|
||||||
|
]
|
||||||
|
if len(active) >= MAX_ACTIVE_LINKS:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_409_CONFLICT,
|
||||||
|
f"Es sind bereits {MAX_ACTIVE_LINKS} Links aktiv. Widerrufe zuerst "
|
||||||
|
"einen davon.",
|
||||||
|
)
|
||||||
|
|
||||||
|
raw = new_token()
|
||||||
|
share = PublicShare(
|
||||||
|
list_id=lst.id,
|
||||||
|
token_hash=hash_token(raw),
|
||||||
|
label=(payload.label or None),
|
||||||
|
allow_check=payload.allow_check,
|
||||||
|
created_by=user.id,
|
||||||
|
expires_at=expires,
|
||||||
|
)
|
||||||
|
db.add(share)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(share)
|
||||||
|
|
||||||
|
return PublicShareCreatedOut(
|
||||||
|
share=_share_out(share),
|
||||||
|
url=f"{settings.public_base_url.rstrip('/')}/s/{raw}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/lists/{list_id}/public-links/{share_id}", response_model=MessageOut)
|
||||||
|
def revoke_public_link(share_id: str, lst: SharableList, db: DbSession):
|
||||||
|
share = db.get(PublicShare, share_id)
|
||||||
|
if share is None or share.list_id != lst.id:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Link nicht gefunden")
|
||||||
|
if share.revoked_at is None:
|
||||||
|
share.revoked_at = utcnow()
|
||||||
|
db.commit()
|
||||||
|
return MessageOut(detail="Link widerrufen. Er funktioniert ab sofort nicht mehr.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/lists/{list_id}/public-links/revoke-all", response_model=MessageOut)
|
||||||
|
def revoke_all_public_links(lst: SharableList, db: DbSession):
|
||||||
|
count = 0
|
||||||
|
for share in db.scalars(
|
||||||
|
select(PublicShare).where(
|
||||||
|
PublicShare.list_id == lst.id, PublicShare.revoked_at.is_(None)
|
||||||
|
)
|
||||||
|
).all():
|
||||||
|
share.revoked_at = utcnow()
|
||||||
|
count += 1
|
||||||
|
db.commit()
|
||||||
|
return MessageOut(detail=f"{count} Link(s) widerrufen.")
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Zugriff ohne Konto
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
def _load_share(db: DbSession, token: str, request: Request) -> PublicShare:
|
||||||
|
# Ohne Anmeldung ist der Endpunkt für jeden erreichbar - deshalb
|
||||||
|
# bremsen, bevor jemand Token durchprobiert. Bei 256 Bit Zufall ist
|
||||||
|
# Raten aussichtslos, aber die Last soll trotzdem begrenzt bleiben.
|
||||||
|
if not check_rate_limit(
|
||||||
|
db, f"public:{client_ip(request)}", limit=120, window_minutes=15
|
||||||
|
):
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Anfragen. Bitte kurz warten."
|
||||||
|
)
|
||||||
|
|
||||||
|
share = db.scalar(
|
||||||
|
select(PublicShare).where(PublicShare.token_hash == hash_token(token))
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Gleiche Antwort für "gibt es nicht" und "abgelaufen"? Nein: Der
|
||||||
|
# Empfänger soll erfahren, warum der Link nicht mehr geht, sonst
|
||||||
|
# sucht er den Fehler bei sich.
|
||||||
|
if share is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Dieser Link ist unbekannt.")
|
||||||
|
state = _status(share)
|
||||||
|
if state == "revoked":
|
||||||
|
raise HTTPException(status.HTTP_410_GONE, "Dieser Link wurde widerrufen.")
|
||||||
|
if state == "expired":
|
||||||
|
raise HTTPException(status.HTTP_410_GONE, "Dieser Link ist abgelaufen.")
|
||||||
|
return share
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/public/{token}", response_model=PublicViewOut)
|
||||||
|
def public_view(token: str, request: Request, response: Response, db: DbSession):
|
||||||
|
share = _load_share(db, token, request)
|
||||||
|
|
||||||
|
lst = db.get(ShoppingList, share.list_id)
|
||||||
|
if lst is None or lst.deleted_at is not None:
|
||||||
|
raise HTTPException(status.HTTP_410_GONE, "Die Liste existiert nicht mehr.")
|
||||||
|
|
||||||
|
# Grobe Nutzungsanzeige für den Eigentümer: nur Zähler und Zeitpunkt,
|
||||||
|
# keine IP-Adresse, keine Zeitreihe. Erkennbar soll sein, DASS der
|
||||||
|
# Link benutzt wird, nicht von wem.
|
||||||
|
share.access_count += 1
|
||||||
|
share.last_access_at = utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response.headers["X-Robots-Tag"] = "noindex, nofollow"
|
||||||
|
|
||||||
|
view = build_view(db, lst, anonymous=True)
|
||||||
|
return PublicViewOut(
|
||||||
|
list_name=view.list_name,
|
||||||
|
rev=view.rev,
|
||||||
|
allow_check=share.allow_check,
|
||||||
|
expires_at=share.expires_at,
|
||||||
|
markets=view.markets,
|
||||||
|
grand_total_cents=view.grand_total_cents,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/public/{token}/print", response_class=HTMLResponse)
|
||||||
|
def public_print(token: str, request: Request, db: DbSession):
|
||||||
|
"""Druckansicht über den öffentlichen Link - ohne Konto.
|
||||||
|
|
||||||
|
Wie die Bildschirmansicht anonymisiert: Wer welchen Artikel
|
||||||
|
eingetragen hat, steht dort ohnehin nicht drin, und im Ausdruck
|
||||||
|
erst recht nicht.
|
||||||
|
"""
|
||||||
|
share = _load_share(db, token, request)
|
||||||
|
|
||||||
|
lst = db.get(ShoppingList, share.list_id)
|
||||||
|
if lst is None or lst.deleted_at is not None:
|
||||||
|
raise HTTPException(status.HTTP_410_GONE, "Die Liste existiert nicht mehr.")
|
||||||
|
|
||||||
|
share.access_count += 1
|
||||||
|
share.last_access_at = utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
view = build_view(db, lst, include_deferred=False, anonymous=True)
|
||||||
|
return HTMLResponse(
|
||||||
|
render_print(view),
|
||||||
|
headers={"X-Robots-Tag": "noindex, nofollow"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/public/{token}/items/{item_id}", response_model=MessageOut)
|
||||||
|
def public_toggle(
|
||||||
|
token: str,
|
||||||
|
item_id: str,
|
||||||
|
payload: PublicToggleIn,
|
||||||
|
request: Request,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Die einzige Änderung, die über einen öffentlichen Link möglich ist.
|
||||||
|
|
||||||
|
Absichtlich auf offen/gekauft begrenzt: Wer mit dem Link einkaufen
|
||||||
|
geht, soll abhaken können. Alles Weitere - anlegen, löschen, Preise,
|
||||||
|
Märkte - bleibt den Mitgliedern vorbehalten.
|
||||||
|
"""
|
||||||
|
share = _load_share(db, token, request)
|
||||||
|
if not share.allow_check:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_403_FORBIDDEN, "Über diesen Link ist nur Ansehen erlaubt."
|
||||||
|
)
|
||||||
|
|
||||||
|
item = db.get(ListItem, item_id)
|
||||||
|
if item is None or item.list_id != share.list_id or item.deleted_at is not None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Eintrag nicht gefunden")
|
||||||
|
|
||||||
|
item.status = payload.status
|
||||||
|
item.row_rev = bump_rev(db, share.list_id)
|
||||||
|
db.commit()
|
||||||
|
return MessageOut(detail="Gespeichert.")
|
||||||
160
backend/app/routers/push.py
Normal file
160
backend/app/routers/push.py
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
"""An- und Abmeldung von Geräten für Push-Benachrichtigungen."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.deps import DbSession, VerifiedUser
|
||||||
|
from app.models import PushSubscription
|
||||||
|
from app.push import send_to_user
|
||||||
|
from app.schemas import MessageOut
|
||||||
|
from app.schemas_push import (
|
||||||
|
PushConfigOut,
|
||||||
|
PushSubscribeIn,
|
||||||
|
PushSubscriptionOut,
|
||||||
|
PushUnsubscribeIn,
|
||||||
|
)
|
||||||
|
from app.security import utcnow
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/push", tags=["push"])
|
||||||
|
|
||||||
|
MAX_DEVICES_PER_USER = 20
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config", response_model=PushConfigOut)
|
||||||
|
def push_config():
|
||||||
|
"""Ohne Anmeldung erreichbar: Der öffentliche Schlüssel ist dafür da,
|
||||||
|
veröffentlicht zu werden."""
|
||||||
|
return PushConfigOut(
|
||||||
|
enabled=settings.push_enabled,
|
||||||
|
public_key=settings.vapid_public_key or None,
|
||||||
|
throttle_hours=settings.push_throttle_hours,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/subscriptions", response_model=list[PushSubscriptionOut])
|
||||||
|
def my_subscriptions(db: DbSession, user: VerifiedUser):
|
||||||
|
rows = db.scalars(
|
||||||
|
select(PushSubscription)
|
||||||
|
.where(PushSubscription.user_id == user.id)
|
||||||
|
.order_by(PushSubscription.created_at)
|
||||||
|
).all()
|
||||||
|
return [
|
||||||
|
PushSubscriptionOut(
|
||||||
|
id=s.id, label=s.label, created_at=s.created_at,
|
||||||
|
last_success_at=s.last_success_at,
|
||||||
|
)
|
||||||
|
for s in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/subscribe", response_model=PushSubscriptionOut,
|
||||||
|
status_code=status.HTTP_201_CREATED)
|
||||||
|
def subscribe(payload: PushSubscribeIn, db: DbSession, user: VerifiedUser):
|
||||||
|
if not settings.push_enabled:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
"Push-Benachrichtigungen sind auf diesem Server nicht eingerichtet.",
|
||||||
|
)
|
||||||
|
|
||||||
|
existing = db.scalar(
|
||||||
|
select(PushSubscription).where(PushSubscription.endpoint == payload.endpoint)
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
# Derselbe Endpunkt kann nach einem Kontowechsel auf demselben
|
||||||
|
# Geraet auftauchen - dann uebernehmen statt abweisen.
|
||||||
|
existing.user_id = user.id
|
||||||
|
existing.p256dh = payload.keys.p256dh
|
||||||
|
existing.auth = payload.keys.auth
|
||||||
|
existing.label = payload.label or existing.label
|
||||||
|
existing.failure_count = 0
|
||||||
|
db.commit()
|
||||||
|
db.refresh(existing)
|
||||||
|
return PushSubscriptionOut(
|
||||||
|
id=existing.id, label=existing.label, created_at=existing.created_at,
|
||||||
|
last_success_at=existing.last_success_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
count = len(db.scalars(
|
||||||
|
select(PushSubscription).where(PushSubscription.user_id == user.id)
|
||||||
|
).all())
|
||||||
|
if count >= MAX_DEVICES_PER_USER:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_409_CONFLICT,
|
||||||
|
f"Höchstens {MAX_DEVICES_PER_USER} Geräte je Konto.",
|
||||||
|
)
|
||||||
|
|
||||||
|
subscription = PushSubscription(
|
||||||
|
user_id=user.id,
|
||||||
|
endpoint=payload.endpoint,
|
||||||
|
p256dh=payload.keys.p256dh,
|
||||||
|
auth=payload.keys.auth,
|
||||||
|
label=(payload.label or None),
|
||||||
|
)
|
||||||
|
db.add(subscription)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(subscription)
|
||||||
|
return PushSubscriptionOut(
|
||||||
|
id=subscription.id, label=subscription.label,
|
||||||
|
created_at=subscription.created_at, last_success_at=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/unsubscribe", response_model=MessageOut)
|
||||||
|
def unsubscribe(payload: PushUnsubscribeIn, db: DbSession, user: VerifiedUser):
|
||||||
|
subscription = db.scalar(
|
||||||
|
select(PushSubscription).where(
|
||||||
|
PushSubscription.endpoint == payload.endpoint,
|
||||||
|
PushSubscription.user_id == user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if subscription is not None:
|
||||||
|
db.delete(subscription)
|
||||||
|
db.commit()
|
||||||
|
# Auch wenn nichts gefunden wurde: Fuer den Aufrufer ist das Ziel
|
||||||
|
# erreicht - dieses Geraet bekommt keine Benachrichtigungen mehr.
|
||||||
|
return MessageOut(detail="Benachrichtigungen für dieses Gerät abgeschaltet.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/subscriptions/{subscription_id}", response_model=MessageOut)
|
||||||
|
def remove_subscription(subscription_id: str, db: DbSession, user: VerifiedUser):
|
||||||
|
subscription = db.get(PushSubscription, subscription_id)
|
||||||
|
if subscription is None or subscription.user_id != user.id:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gerät nicht gefunden")
|
||||||
|
db.delete(subscription)
|
||||||
|
db.commit()
|
||||||
|
return MessageOut(detail="Gerät entfernt.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test", response_model=MessageOut, status_code=status.HTTP_202_ACCEPTED)
|
||||||
|
def send_test(background: BackgroundTasks, db: DbSession, user: VerifiedUser):
|
||||||
|
"""Testnachricht an die eigenen Geräte - umgeht die Drosselung."""
|
||||||
|
if not settings.push_enabled:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
"Push-Benachrichtigungen sind auf diesem Server nicht eingerichtet.",
|
||||||
|
)
|
||||||
|
|
||||||
|
count = len(db.scalars(
|
||||||
|
select(PushSubscription).where(PushSubscription.user_id == user.id)
|
||||||
|
).all())
|
||||||
|
if count == 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_409_CONFLICT,
|
||||||
|
"Für dieses Konto ist kein Gerät angemeldet.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def run() -> None:
|
||||||
|
from app.db import SessionLocal
|
||||||
|
with SessionLocal() as session:
|
||||||
|
send_to_user(session, user.id, {
|
||||||
|
"title": settings.app_name,
|
||||||
|
"body": "Testbenachrichtigung – die Zustellung funktioniert.",
|
||||||
|
"tag": "test",
|
||||||
|
})
|
||||||
|
|
||||||
|
background.add_task(run)
|
||||||
|
return MessageOut(
|
||||||
|
detail=f"Testnachricht an {count} Gerät(e) in Auftrag gegeben. "
|
||||||
|
"Ergebnis steht im Log des api-Containers."
|
||||||
|
)
|
||||||
412
backend/app/routers/sharing.py
Normal file
412
backend/app/routers/sharing.py
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
"""Teilen von Listen: Einladungen, Widerruf, Eigentümerwechsel.
|
||||||
|
|
||||||
|
Ablauf einer Einladung:
|
||||||
|
|
||||||
|
1. Der Eigentümer gibt eine E-Mail-Adresse ein.
|
||||||
|
2. Es entsteht ein Datensatz mit Zufallstoken; versendet wird ein Link
|
||||||
|
auf /invite?token=…
|
||||||
|
3. Der Empfänger meldet sich an oder registriert sich und nimmt an.
|
||||||
|
4. Beim Annehmen entsteht die Mitgliedschaft.
|
||||||
|
|
||||||
|
Bewusst unabhängig davon, ob unter der Adresse schon ein Konto besteht.
|
||||||
|
Damit verrät das Einladen nicht, wer registriert ist, und neue wie
|
||||||
|
bestehende Nutzer durchlaufen denselben Weg.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.deps import DbSession, VerifiedUser, client_ip
|
||||||
|
from app.mail import send_invitation
|
||||||
|
from app.models import ListInvite, ListMember, ShoppingList, User
|
||||||
|
from app.permissions import OwnedList, ReadableList
|
||||||
|
from app.schemas import MessageOut
|
||||||
|
from app.schemas_shopping import (
|
||||||
|
InviteCreateIn,
|
||||||
|
InviteOut,
|
||||||
|
InvitePreviewOut,
|
||||||
|
MemberOut,
|
||||||
|
TransferOwnershipIn,
|
||||||
|
)
|
||||||
|
from app.security import (
|
||||||
|
check_rate_limit,
|
||||||
|
hash_token,
|
||||||
|
new_token,
|
||||||
|
normalize_email,
|
||||||
|
utcnow,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(tags=["sharing"])
|
||||||
|
|
||||||
|
INVITE_DAYS = 14
|
||||||
|
MAX_OPEN_INVITES = 50
|
||||||
|
|
||||||
|
|
||||||
|
def _status(invite: ListInvite) -> str:
|
||||||
|
if invite.revoked_at:
|
||||||
|
return "revoked"
|
||||||
|
if invite.accepted_at:
|
||||||
|
return "accepted"
|
||||||
|
if invite.expires_at <= utcnow():
|
||||||
|
return "expired"
|
||||||
|
return "pending"
|
||||||
|
|
||||||
|
|
||||||
|
def _invite_out(invite: ListInvite) -> InviteOut:
|
||||||
|
inviter = invite.inviter
|
||||||
|
return InviteOut(
|
||||||
|
id=invite.id,
|
||||||
|
email=invite.email,
|
||||||
|
role=invite.role,
|
||||||
|
status=_status(invite),
|
||||||
|
invited_by_name=(
|
||||||
|
(inviter.display_name or inviter.email.split("@")[0]) if inviter else None
|
||||||
|
),
|
||||||
|
created_at=invite.created_at,
|
||||||
|
last_sent_at=invite.last_sent_at,
|
||||||
|
send_count=invite.send_count,
|
||||||
|
expires_at=invite.expires_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _guard_invite_rate(db: DbSession, request: Request, list_id: str) -> None:
|
||||||
|
"""Zwei Bremsen: pro Liste und pro absendender IP.
|
||||||
|
|
||||||
|
Ohne die zweite ließe sich die Anwendung als Versandhilfe für
|
||||||
|
unerwünschte Mails missbrauchen - jede Einladung erzeugt schließlich
|
||||||
|
eine Nachricht an eine frei wählbare Adresse.
|
||||||
|
"""
|
||||||
|
ok_list = check_rate_limit(db, f"invite-list:{list_id}", limit=20, window_minutes=60)
|
||||||
|
ok_ip = check_rate_limit(db, f"invite-ip:{client_ip(request)}", limit=30, window_minutes=60)
|
||||||
|
if not (ok_list and ok_ip):
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
"Zu viele Einladungen in kurzer Zeit. Bitte später erneut versuchen.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Einladungen verwalten (nur Eigentümer)
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
@router.get("/api/lists/{list_id}/invites", response_model=list[InviteOut])
|
||||||
|
def get_invites(lst: OwnedList, db: DbSession):
|
||||||
|
rows = db.scalars(
|
||||||
|
select(ListInvite)
|
||||||
|
.where(ListInvite.list_id == lst.id)
|
||||||
|
.order_by(ListInvite.created_at.desc())
|
||||||
|
).all()
|
||||||
|
return [_invite_out(i) for i in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/lists/{list_id}/invites",
|
||||||
|
response_model=InviteOut,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def create_invite(
|
||||||
|
payload: InviteCreateIn,
|
||||||
|
request: Request,
|
||||||
|
lst: OwnedList,
|
||||||
|
db: DbSession,
|
||||||
|
user: VerifiedUser,
|
||||||
|
background: BackgroundTasks,
|
||||||
|
):
|
||||||
|
email = normalize_email(payload.email)
|
||||||
|
|
||||||
|
if email == normalize_email(user.email):
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST, "Du bist bereits Eigentümer dieser Liste."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ist unter der Adresse jemand bereits Mitglied?
|
||||||
|
existing_user = db.scalar(select(User).where(User.email == email))
|
||||||
|
if existing_user is not None:
|
||||||
|
member = db.get(ListMember, (lst.id, existing_user.id))
|
||||||
|
if member is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_409_CONFLICT,
|
||||||
|
"Diese Person hat bereits Zugriff auf die Liste.",
|
||||||
|
)
|
||||||
|
|
||||||
|
open_count = sum(
|
||||||
|
1
|
||||||
|
for i in db.scalars(
|
||||||
|
select(ListInvite).where(ListInvite.list_id == lst.id)
|
||||||
|
).all()
|
||||||
|
if _status(i) == "pending"
|
||||||
|
)
|
||||||
|
if open_count >= MAX_OPEN_INVITES:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_409_CONFLICT,
|
||||||
|
f"Es sind bereits {MAX_OPEN_INVITES} Einladungen offen.",
|
||||||
|
)
|
||||||
|
|
||||||
|
_guard_invite_rate(db, request, lst.id)
|
||||||
|
|
||||||
|
# Eine noch offene Einladung an dieselbe Adresse ersetzen, statt eine
|
||||||
|
# zweite danebenzulegen.
|
||||||
|
for old in db.scalars(
|
||||||
|
select(ListInvite).where(
|
||||||
|
ListInvite.list_id == lst.id,
|
||||||
|
ListInvite.email == email,
|
||||||
|
ListInvite.accepted_at.is_(None),
|
||||||
|
ListInvite.revoked_at.is_(None),
|
||||||
|
)
|
||||||
|
).all():
|
||||||
|
old.revoked_at = utcnow()
|
||||||
|
|
||||||
|
raw = new_token()
|
||||||
|
invite = ListInvite(
|
||||||
|
list_id=lst.id,
|
||||||
|
email=email,
|
||||||
|
role=payload.role,
|
||||||
|
token_hash=hash_token(raw),
|
||||||
|
invited_by=user.id,
|
||||||
|
expires_at=utcnow() + timedelta(days=INVITE_DAYS),
|
||||||
|
)
|
||||||
|
db.add(invite)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(invite)
|
||||||
|
|
||||||
|
background.add_task(
|
||||||
|
send_invitation, email, raw,
|
||||||
|
lst.name, user.display_name or user.email.split("@")[0], INVITE_DAYS,
|
||||||
|
)
|
||||||
|
return _invite_out(invite)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/lists/{list_id}/invites/{invite_id}/resend", response_model=InviteOut)
|
||||||
|
def resend_invite(
|
||||||
|
invite_id: str,
|
||||||
|
request: Request,
|
||||||
|
lst: OwnedList,
|
||||||
|
db: DbSession,
|
||||||
|
user: VerifiedUser,
|
||||||
|
background: BackgroundTasks,
|
||||||
|
):
|
||||||
|
"""Erneuter Versand erzeugt ein NEUES Token und entwertet das alte.
|
||||||
|
|
||||||
|
In der Datenbank steht nur der Hash, der Klartext des ursprünglichen
|
||||||
|
Tokens lässt sich also nicht rekonstruieren. Nebeneffekt: Ein Link,
|
||||||
|
der versehentlich weitergeleitet wurde, verliert damit seine Gültigkeit.
|
||||||
|
"""
|
||||||
|
invite = db.get(ListInvite, invite_id)
|
||||||
|
if invite is None or invite.list_id != lst.id:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Einladung nicht gefunden")
|
||||||
|
if invite.accepted_at:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_409_CONFLICT, "Diese Einladung wurde bereits angenommen."
|
||||||
|
)
|
||||||
|
|
||||||
|
_guard_invite_rate(db, request, lst.id)
|
||||||
|
|
||||||
|
raw = new_token()
|
||||||
|
invite.token_hash = hash_token(raw)
|
||||||
|
invite.revoked_at = None
|
||||||
|
invite.expires_at = utcnow() + timedelta(days=INVITE_DAYS)
|
||||||
|
invite.last_sent_at = utcnow()
|
||||||
|
invite.send_count += 1
|
||||||
|
db.commit()
|
||||||
|
db.refresh(invite)
|
||||||
|
|
||||||
|
background.add_task(
|
||||||
|
send_invitation, invite.email, raw,
|
||||||
|
lst.name, user.display_name or user.email.split("@")[0], INVITE_DAYS,
|
||||||
|
)
|
||||||
|
return _invite_out(invite)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/lists/{list_id}/invites/{invite_id}", response_model=MessageOut)
|
||||||
|
def revoke_invite(invite_id: str, lst: OwnedList, db: DbSession):
|
||||||
|
invite = db.get(ListInvite, invite_id)
|
||||||
|
if invite is None or invite.list_id != lst.id:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Einladung nicht gefunden")
|
||||||
|
if invite.accepted_at:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_409_CONFLICT,
|
||||||
|
"Diese Einladung wurde bereits angenommen. Entziehe stattdessen "
|
||||||
|
"die Mitgliedschaft.",
|
||||||
|
)
|
||||||
|
invite.revoked_at = utcnow()
|
||||||
|
db.commit()
|
||||||
|
return MessageOut(detail="Einladung widerrufen. Der Link funktioniert nicht mehr.")
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Freigabe insgesamt aufheben
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
@router.post("/api/lists/{list_id}/unshare", response_model=MessageOut)
|
||||||
|
def unshare(lst: OwnedList, db: DbSession):
|
||||||
|
"""Entfernt alle Mitglieder außer dem Eigentümer und widerruft alle
|
||||||
|
offenen Einladungen."""
|
||||||
|
members = db.scalars(
|
||||||
|
select(ListMember).where(
|
||||||
|
ListMember.list_id == lst.id, ListMember.user_id != lst.owner_id
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for member in members:
|
||||||
|
db.delete(member)
|
||||||
|
|
||||||
|
revoked = 0
|
||||||
|
for invite in db.scalars(
|
||||||
|
select(ListInvite).where(
|
||||||
|
ListInvite.list_id == lst.id,
|
||||||
|
ListInvite.accepted_at.is_(None),
|
||||||
|
ListInvite.revoked_at.is_(None),
|
||||||
|
)
|
||||||
|
).all():
|
||||||
|
invite.revoked_at = utcnow()
|
||||||
|
revoked += 1
|
||||||
|
|
||||||
|
lst.rev += 1
|
||||||
|
db.commit()
|
||||||
|
return MessageOut(
|
||||||
|
detail=f"{len(members)} Zugriff(e) entzogen, {revoked} offene Einladung(en) "
|
||||||
|
"widerrufen."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Eigentümerwechsel
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
@router.post("/api/lists/{list_id}/transfer", response_model=list[MemberOut])
|
||||||
|
def transfer_ownership(
|
||||||
|
payload: TransferOwnershipIn, lst: OwnedList, db: DbSession, user: VerifiedUser
|
||||||
|
):
|
||||||
|
"""Der bisherige Eigentümer wird zum Bearbeiter - so verliert er nicht
|
||||||
|
versehentlich den Zugang zu einer Liste, an der er mitarbeitet."""
|
||||||
|
if payload.user_id == lst.owner_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST, "Diese Person ist bereits Eigentümer."
|
||||||
|
)
|
||||||
|
|
||||||
|
target = db.get(ListMember, (lst.id, payload.user_id))
|
||||||
|
if target is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_404_NOT_FOUND,
|
||||||
|
"Diese Person ist kein Mitglied der Liste. Lade sie zuerst ein.",
|
||||||
|
)
|
||||||
|
|
||||||
|
previous = db.get(ListMember, (lst.id, lst.owner_id))
|
||||||
|
if previous is not None:
|
||||||
|
previous.role = "editor"
|
||||||
|
|
||||||
|
target.role = "owner"
|
||||||
|
target.may_share_public = True
|
||||||
|
lst.owner_id = payload.user_id
|
||||||
|
lst.rev += 1
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
rows = db.execute(
|
||||||
|
select(ListMember, User)
|
||||||
|
.join(User, User.id == ListMember.user_id)
|
||||||
|
.where(ListMember.list_id == lst.id)
|
||||||
|
.order_by(ListMember.joined_at)
|
||||||
|
).all()
|
||||||
|
return [
|
||||||
|
MemberOut(
|
||||||
|
user_id=u.id, email=u.email, display_name=u.display_name,
|
||||||
|
role=m.role, may_share_public=m.may_share_public,
|
||||||
|
joined_at=m.joined_at,
|
||||||
|
)
|
||||||
|
for m, u in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Einladung ansehen und annehmen (Empfängerseite)
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
def _load_invite(db: DbSession, token: str) -> ListInvite:
|
||||||
|
invite = db.scalar(
|
||||||
|
select(ListInvite).where(ListInvite.token_hash == hash_token(token))
|
||||||
|
)
|
||||||
|
if invite is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_404_NOT_FOUND, "Diese Einladung ist unbekannt."
|
||||||
|
)
|
||||||
|
state = _status(invite)
|
||||||
|
if state == "revoked":
|
||||||
|
raise HTTPException(status.HTTP_410_GONE, "Diese Einladung wurde widerrufen.")
|
||||||
|
if state == "expired":
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_410_GONE,
|
||||||
|
"Diese Einladung ist abgelaufen. Bitte um eine neue.",
|
||||||
|
)
|
||||||
|
if state == "accepted":
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_409_CONFLICT, "Diese Einladung wurde bereits angenommen."
|
||||||
|
)
|
||||||
|
return invite
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/invites/{token}", response_model=InvitePreviewOut)
|
||||||
|
def preview_invite(token: str, db: DbSession, user: VerifiedUser):
|
||||||
|
invite = _load_invite(db, token)
|
||||||
|
lst = db.get(ShoppingList, invite.list_id)
|
||||||
|
if lst is None or lst.deleted_at is not None:
|
||||||
|
raise HTTPException(status.HTTP_410_GONE, "Die Liste existiert nicht mehr.")
|
||||||
|
|
||||||
|
inviter = invite.inviter
|
||||||
|
return InvitePreviewOut(
|
||||||
|
list_name=lst.name,
|
||||||
|
invited_by_name=(
|
||||||
|
(inviter.display_name or inviter.email.split("@")[0]) if inviter else None
|
||||||
|
),
|
||||||
|
role=invite.role,
|
||||||
|
email=invite.email,
|
||||||
|
matches_current_user=normalize_email(user.email) == invite.email,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/invites/{token}/accept", response_model=MessageOut)
|
||||||
|
def accept_invite(token: str, db: DbSession, user: VerifiedUser):
|
||||||
|
invite = _load_invite(db, token)
|
||||||
|
|
||||||
|
# Die Adresse muss zum angemeldeten Konto passen. Sonst würde ein
|
||||||
|
# weitergeleiteter Link jedem den Zugang öffnen.
|
||||||
|
if normalize_email(user.email) != invite.email:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_403_FORBIDDEN,
|
||||||
|
f"Diese Einladung ist an {invite.email} gerichtet. "
|
||||||
|
"Melde dich mit diesem Konto an oder bitte um eine neue Einladung.",
|
||||||
|
)
|
||||||
|
|
||||||
|
lst = db.get(ShoppingList, invite.list_id)
|
||||||
|
if lst is None or lst.deleted_at is not None:
|
||||||
|
raise HTTPException(status.HTTP_410_GONE, "Die Liste existiert nicht mehr.")
|
||||||
|
|
||||||
|
if db.get(ListMember, (lst.id, user.id)) is None:
|
||||||
|
db.add(ListMember(list_id=lst.id, user_id=user.id, role=invite.role))
|
||||||
|
lst.rev += 1
|
||||||
|
|
||||||
|
invite.accepted_at = utcnow()
|
||||||
|
invite.accepted_by = user.id
|
||||||
|
db.commit()
|
||||||
|
return MessageOut(detail=f"Du hast jetzt Zugriff auf „{lst.name}“.")
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Eigene Mitgliedschaft beenden
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
@router.post("/api/lists/{list_id}/leave", response_model=MessageOut)
|
||||||
|
def leave_list(lst: ReadableList, db: DbSession, user: VerifiedUser):
|
||||||
|
if lst.owner_id == user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_409_CONFLICT,
|
||||||
|
"Als Eigentümer kannst du die Liste nicht verlassen. Übertrage sie "
|
||||||
|
"zuvor oder lösche sie.",
|
||||||
|
)
|
||||||
|
member = db.get(ListMember, (lst.id, user.id))
|
||||||
|
if member is not None:
|
||||||
|
db.delete(member)
|
||||||
|
lst.rev += 1
|
||||||
|
db.commit()
|
||||||
|
return MessageOut(detail="Du hast die Liste verlassen.")
|
||||||
354
backend/app/routers/sync.py
Normal file
354
backend/app/routers/sync.py
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
"""Synchronisation: Stapelverarbeitung aus der Outbox und Ereigniskanal.
|
||||||
|
|
||||||
|
Zwei Endpunkte:
|
||||||
|
|
||||||
|
POST /api/lists/{id}/ops Der Client schickt seine gesammelten
|
||||||
|
Operationen. Jede trägt eine selbst
|
||||||
|
vergebene `op_id`; bereits verarbeitete
|
||||||
|
werden erkannt und übersprungen.
|
||||||
|
|
||||||
|
GET /api/lists/{id}/events Server-Sent Events. Meldet nur den neuen
|
||||||
|
Revisionsstand, keine Nutzdaten - der
|
||||||
|
Client holt die Ansicht dann selbst.
|
||||||
|
|
||||||
|
Warum kein Delta-Endpunkt: Eine Einkaufsliste hat Dutzende Einträge, keine
|
||||||
|
Zehntausende. Die vollständige Ansicht neu zu holen kostet ein paar
|
||||||
|
Kilobyte und spart eine ganze Klasse von Fehlern, die beim Zusammensetzen
|
||||||
|
von Teilständen entsteht. Der Revisionszähler sorgt dafür, dass das nur
|
||||||
|
passiert, wenn sich wirklich etwas geändert hat.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, status
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db import SessionLocal
|
||||||
|
from app.deps import DbSession, VerifiedUser, current_user
|
||||||
|
from app.list_view import build_view
|
||||||
|
from app.models import AppliedOp, Article, Category, ListItem, ListMember, Market, ShoppingList
|
||||||
|
from app.permissions import EditableList, ReadableList, bump_rev
|
||||||
|
from app.prices import record_price
|
||||||
|
from app.push import notify_list_changed
|
||||||
|
from app.schemas_sync import OpBatchIn, OpBatchOut, OpResult
|
||||||
|
from app.security import utcnow
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(tags=["sync"])
|
||||||
|
|
||||||
|
MAX_OPS_PER_BATCH = 200
|
||||||
|
POLL_SECONDS = 2
|
||||||
|
HEARTBEAT_SECONDS = 20
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Operationen anwenden
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
def _ref(db: Session, model, obj_id: str | None, list_id: str, label: str):
|
||||||
|
if obj_id is None:
|
||||||
|
return None
|
||||||
|
obj = db.get(model, obj_id)
|
||||||
|
if obj is None or obj.list_id != list_id or obj.deleted_at is not None:
|
||||||
|
raise ValueError(f"{label} nicht gefunden")
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def _apply(db: Session, lst: ShoppingList, user_id: str, op, rev: int) -> str | None:
|
||||||
|
"""Führt eine einzelne Operation aus und gibt bei item.create die
|
||||||
|
vergebene ID zurück. Wirft ValueError bei fachlichen Fehlern - die
|
||||||
|
lässt der Aufrufer als abgelehnte Operation durchgehen, ohne den
|
||||||
|
ganzen Stapel scheitern zu lassen."""
|
||||||
|
data = op.payload
|
||||||
|
kind = op.kind
|
||||||
|
|
||||||
|
if kind == "item.create":
|
||||||
|
article = None
|
||||||
|
|
||||||
|
# Bekannter Artikel, etwa nach einem Strichcode-Treffer.
|
||||||
|
if data.get("article_id"):
|
||||||
|
article = db.get(Article, data["article_id"])
|
||||||
|
if (article is None or article.list_id != lst.id
|
||||||
|
or article.deleted_at is not None):
|
||||||
|
raise ValueError("Artikel nicht gefunden")
|
||||||
|
|
||||||
|
# Sonst über den Barcode suchen ...
|
||||||
|
if article is None and data.get("barcode"):
|
||||||
|
article = db.scalar(
|
||||||
|
select(Article).where(
|
||||||
|
Article.list_id == lst.id,
|
||||||
|
Article.barcode == data["barcode"],
|
||||||
|
Article.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ... und zuletzt über den Namen.
|
||||||
|
if article is None:
|
||||||
|
name = (data.get("article_name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise ValueError("Artikelname fehlt")
|
||||||
|
|
||||||
|
article = db.scalar(
|
||||||
|
select(Article).where(
|
||||||
|
Article.list_id == lst.id,
|
||||||
|
Article.name == name,
|
||||||
|
Article.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if article is None:
|
||||||
|
article = Article(
|
||||||
|
list_id=lst.id,
|
||||||
|
name=name[:200],
|
||||||
|
barcode=(data.get("barcode") or None),
|
||||||
|
row_rev=rev,
|
||||||
|
)
|
||||||
|
db.add(article)
|
||||||
|
db.flush()
|
||||||
|
elif data.get("barcode") and not article.barcode:
|
||||||
|
# Der Artikel war schon da, hatte aber noch keinen Code -
|
||||||
|
# beim nächsten Scan wird er direkt gefunden.
|
||||||
|
article.barcode = data["barcode"]
|
||||||
|
article.row_rev = rev
|
||||||
|
|
||||||
|
market = _ref(db, Market, data.get("market_id"), lst.id, "Markt")
|
||||||
|
category = _ref(db, Category, data.get("category_id"), lst.id, "Warengruppe")
|
||||||
|
|
||||||
|
item = ListItem(
|
||||||
|
list_id=lst.id,
|
||||||
|
article_id=article.id,
|
||||||
|
market_id=(market.id if market else article.default_market_id),
|
||||||
|
category_id=(category.id if category else article.default_category_id),
|
||||||
|
count=int(data.get("count") or 1),
|
||||||
|
# "quantity"/"unit" sind die alten Feldnamen. Ein Geraet, das
|
||||||
|
# zum Zeitpunkt der Umstellung noch Operationen in der Outbox
|
||||||
|
# hatte, schickt sie weiterhin - die landen jetzt als Gebinde,
|
||||||
|
# was der bisherigen Bedeutung entspricht.
|
||||||
|
pack_size=data.get("pack_size", data.get("quantity")),
|
||||||
|
pack_unit=(data.get("pack_unit") or data.get("unit") or None),
|
||||||
|
variant=(data.get("variant") or None),
|
||||||
|
note=(data.get("note") or None),
|
||||||
|
status="open",
|
||||||
|
created_by=user_id,
|
||||||
|
row_rev=rev,
|
||||||
|
)
|
||||||
|
db.add(item)
|
||||||
|
db.flush()
|
||||||
|
return item.id
|
||||||
|
|
||||||
|
if kind == "item.update":
|
||||||
|
item = db.get(ListItem, data.get("item_id"))
|
||||||
|
if item is None or item.list_id != lst.id or item.deleted_at is not None:
|
||||||
|
raise ValueError("Eintrag nicht gefunden")
|
||||||
|
|
||||||
|
if data.get("clear_market"):
|
||||||
|
item.market_id = None
|
||||||
|
elif "market_id" in data:
|
||||||
|
market = _ref(db, Market, data["market_id"], lst.id, "Markt")
|
||||||
|
item.market_id = market.id if market else None
|
||||||
|
|
||||||
|
if data.get("clear_category"):
|
||||||
|
item.category_id = None
|
||||||
|
elif "category_id" in data:
|
||||||
|
category = _ref(db, Category, data["category_id"], lst.id, "Warengruppe")
|
||||||
|
item.category_id = category.id if category else None
|
||||||
|
|
||||||
|
# Alte Feldnamen aus einer Outbox von vor der Umstellung.
|
||||||
|
if "quantity" in data and "pack_size" not in data:
|
||||||
|
data["pack_size"] = data["quantity"]
|
||||||
|
if "unit" in data and "pack_unit" not in data:
|
||||||
|
data["pack_unit"] = data["unit"]
|
||||||
|
|
||||||
|
for field in ("count", "pack_size", "pack_unit", "variant", "note",
|
||||||
|
"status", "price_cents"):
|
||||||
|
if field not in data:
|
||||||
|
continue
|
||||||
|
# count und status sind nicht leerbar - ein Eintrag ohne
|
||||||
|
# Stückzahl oder Status waere unvollstaendig.
|
||||||
|
if field in ("count", "status") and data[field] is None:
|
||||||
|
continue
|
||||||
|
setattr(item, field, data[field])
|
||||||
|
|
||||||
|
item.row_rev = rev
|
||||||
|
record_price(db, item)
|
||||||
|
return item.id
|
||||||
|
|
||||||
|
if kind == "item.delete":
|
||||||
|
item = db.get(ListItem, data.get("item_id"))
|
||||||
|
if item is None or item.list_id != lst.id:
|
||||||
|
raise ValueError("Eintrag nicht gefunden")
|
||||||
|
if item.deleted_at is None:
|
||||||
|
item.deleted_at = utcnow()
|
||||||
|
item.row_rev = rev
|
||||||
|
return item.id
|
||||||
|
|
||||||
|
if kind == "items.clear_bought":
|
||||||
|
now = utcnow()
|
||||||
|
for item in db.scalars(
|
||||||
|
select(ListItem).where(
|
||||||
|
ListItem.list_id == lst.id,
|
||||||
|
ListItem.status == "bought",
|
||||||
|
ListItem.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
).all():
|
||||||
|
item.deleted_at = now
|
||||||
|
item.row_rev = rev
|
||||||
|
return None
|
||||||
|
|
||||||
|
raise ValueError(f"Unbekannte Operation: {kind}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/lists/{list_id}/ops", response_model=OpBatchOut)
|
||||||
|
def apply_ops(
|
||||||
|
payload: OpBatchIn,
|
||||||
|
lst: EditableList,
|
||||||
|
db: DbSession,
|
||||||
|
user: VerifiedUser,
|
||||||
|
background: BackgroundTasks,
|
||||||
|
):
|
||||||
|
"""Verarbeitet einen Stapel Operationen.
|
||||||
|
|
||||||
|
Der gesamte Stapel bekommt EINEN Revisionsschritt. So sieht ein
|
||||||
|
anderes Gerät eine geschlossene Änderung statt dutzender einzelner -
|
||||||
|
und die Anzeige flackert nicht.
|
||||||
|
|
||||||
|
Fachlich fehlgeschlagene Operationen werden einzeln als abgelehnt
|
||||||
|
zurückgemeldet, statt den ganzen Stapel scheitern zu lassen. Sonst
|
||||||
|
würde ein Eintrag, den jemand anders inzwischen gelöscht hat, alle
|
||||||
|
übrigen Änderungen des Geräts blockieren.
|
||||||
|
"""
|
||||||
|
if len(payload.ops) > MAX_OPS_PER_BATCH:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||||
|
f"Höchstens {MAX_OPS_PER_BATCH} Operationen je Aufruf.",
|
||||||
|
)
|
||||||
|
|
||||||
|
results: list[OpResult] = []
|
||||||
|
rev = lst.rev
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
for op in payload.ops:
|
||||||
|
known = db.get(AppliedOp, op.op_id)
|
||||||
|
if known is not None:
|
||||||
|
# Schon verarbeitet - Quittung wiederholen, nichts tun.
|
||||||
|
results.append(
|
||||||
|
OpResult(op_id=op.op_id, status="duplicate", result_id=known.result_id)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not changed:
|
||||||
|
rev = bump_rev(db, lst.id)
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
result_id = _apply(db, lst, user.id, op, rev)
|
||||||
|
except ValueError as exc:
|
||||||
|
db.rollback()
|
||||||
|
# Nach dem Rollback ist der Revisionsschritt weg - beim
|
||||||
|
# nächsten erfolgreichen Vorgang wird neu gezogen.
|
||||||
|
changed = False
|
||||||
|
results.append(OpResult(op_id=op.op_id, status="rejected", error=str(exc)))
|
||||||
|
continue
|
||||||
|
|
||||||
|
db.add(
|
||||||
|
AppliedOp(
|
||||||
|
op_id=op.op_id, list_id=lst.id, user_id=user.id,
|
||||||
|
kind=op.kind, result_id=result_id, rev=rev,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
results.append(OpResult(op_id=op.op_id, status="applied", result_id=result_id))
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(lst)
|
||||||
|
|
||||||
|
# Nur benachrichtigen, wenn wirklich etwas passiert ist. Ein Stapel,
|
||||||
|
# der nur aus Wiederholungen bestand, aendert nichts.
|
||||||
|
if any(r.status == "applied" for r in results):
|
||||||
|
background.add_task(notify_list_changed, lst.id, user.id)
|
||||||
|
|
||||||
|
return OpBatchOut(rev=lst.rev, results=results)
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Ereigniskanal
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
def _read_rev(list_id: str, user_id: str) -> int | None:
|
||||||
|
"""Läuft im Threadpool: SQLAlchemy ist hier synchron konfiguriert,
|
||||||
|
und ein blockierender Aufruf in der Ereignisschleife würde den
|
||||||
|
gesamten Server anhalten."""
|
||||||
|
with SessionLocal() as db:
|
||||||
|
if db.get(ListMember, (list_id, user_id)) is None:
|
||||||
|
return None
|
||||||
|
lst = db.scalar(
|
||||||
|
select(ShoppingList).where(
|
||||||
|
ShoppingList.id == list_id, ShoppingList.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return lst.rev if lst else None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/lists/{list_id}/events")
|
||||||
|
async def events(list_id: str, request: Request, lst: ReadableList, user: VerifiedUser):
|
||||||
|
"""Meldet Änderungen an der Liste als Server-Sent Events.
|
||||||
|
|
||||||
|
Ohne Redis wird der Revisionsstand abgefragt statt verteilt - bei
|
||||||
|
dieser Größenordnung völlig ausreichend. Wichtig ist nur, dass die
|
||||||
|
Abfrage im Threadpool läuft.
|
||||||
|
|
||||||
|
Achtung beim Reverse Proxy: `proxy_buffering off` ist Pflicht, sonst
|
||||||
|
sammelt nginx die Ereignisse und liefert sie gebündelt aus.
|
||||||
|
"""
|
||||||
|
user_id = user.id
|
||||||
|
last_rev = lst.rev
|
||||||
|
|
||||||
|
async def stream():
|
||||||
|
nonlocal last_rev
|
||||||
|
# Erstes Ereignis sofort, damit der Client seinen Stand abgleicht.
|
||||||
|
yield f"event: rev\ndata: {json.dumps({'rev': last_rev})}\n\n"
|
||||||
|
# Wiederverbindungsabstand für den Browser.
|
||||||
|
yield "retry: 5000\n\n"
|
||||||
|
|
||||||
|
since_heartbeat = 0.0
|
||||||
|
while True:
|
||||||
|
if await request.is_disconnected():
|
||||||
|
break
|
||||||
|
|
||||||
|
await asyncio.sleep(POLL_SECONDS)
|
||||||
|
since_heartbeat += POLL_SECONDS
|
||||||
|
|
||||||
|
try:
|
||||||
|
rev = await anyio.to_thread.run_sync(_read_rev, list_id, user_id)
|
||||||
|
except Exception:
|
||||||
|
log.exception("Ereigniskanal: Abfrage fehlgeschlagen")
|
||||||
|
break
|
||||||
|
|
||||||
|
if rev is None:
|
||||||
|
# Liste gelöscht oder Zugriff entzogen.
|
||||||
|
yield "event: gone\ndata: {}\n\n"
|
||||||
|
break
|
||||||
|
|
||||||
|
if rev != last_rev:
|
||||||
|
last_rev = rev
|
||||||
|
since_heartbeat = 0.0
|
||||||
|
yield f"event: rev\ndata: {json.dumps({'rev': rev})}\n\n"
|
||||||
|
elif since_heartbeat >= HEARTBEAT_SECONDS:
|
||||||
|
since_heartbeat = 0.0
|
||||||
|
# Kommentarzeile: hält die Verbindung durch Proxys offen,
|
||||||
|
# löst beim Client aber kein Ereignis aus.
|
||||||
|
yield ": ping\n\n"
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
stream(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache, no-transform",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
},
|
||||||
|
)
|
||||||
74
backend/app/schemas.py
Normal file
74
backend/app/schemas.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterIn(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
# Untergrenze nach BSI/NIST-Empfehlung: Laenge statt Zeichenklassen.
|
||||||
|
password: str = Field(min_length=12, max_length=256)
|
||||||
|
display_name: str | None = Field(default=None, max_length=80)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginIn(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str = Field(max_length=256)
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetRequestIn(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetIn(BaseModel):
|
||||||
|
token: str = Field(max_length=128)
|
||||||
|
password: str = Field(min_length=12, max_length=256)
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordChangeIn(BaseModel):
|
||||||
|
current_password: str = Field(max_length=256)
|
||||||
|
new_password: str = Field(min_length=12, max_length=256)
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileUpdateIn(BaseModel):
|
||||||
|
display_name: str | None = Field(default=None, max_length=80)
|
||||||
|
|
||||||
|
|
||||||
|
class UserOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
email: EmailStr
|
||||||
|
display_name: str | None
|
||||||
|
is_admin: bool
|
||||||
|
verified: bool
|
||||||
|
must_change_password: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def of(cls, user) -> "UserOut":
|
||||||
|
return cls(
|
||||||
|
id=user.id,
|
||||||
|
email=user.email,
|
||||||
|
display_name=user.display_name,
|
||||||
|
is_admin=user.is_admin,
|
||||||
|
verified=user.verified_at is not None,
|
||||||
|
must_change_password=user.must_change_password,
|
||||||
|
created_at=user.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MessageOut(BaseModel):
|
||||||
|
detail: str
|
||||||
|
|
||||||
|
|
||||||
|
class MailCheckOut(BaseModel):
|
||||||
|
ok: bool
|
||||||
|
detail: str
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
security: str
|
||||||
|
envelope_from: str
|
||||||
|
|
||||||
|
|
||||||
|
class MailTestIn(BaseModel):
|
||||||
|
to: EmailStr
|
||||||
83
backend/app/schemas_admin.py
Normal file
83
backend/app/schemas_admin.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
email: EmailStr
|
||||||
|
display_name: str | None
|
||||||
|
is_admin: bool
|
||||||
|
is_active: bool
|
||||||
|
verified: bool
|
||||||
|
# Nie angemeldet, oder seit wann nicht mehr
|
||||||
|
last_seen_at: datetime | None
|
||||||
|
deactivated_at: datetime | None
|
||||||
|
created_at: datetime
|
||||||
|
# Wie viele Listen gehoeren dem Konto, in wie vielen ist es Mitglied
|
||||||
|
owned_lists: int
|
||||||
|
memberships: int
|
||||||
|
# Laeuft gerade eine Adressaenderung?
|
||||||
|
pending_email: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AdminStatsOut(BaseModel):
|
||||||
|
total: int
|
||||||
|
active: int
|
||||||
|
inactive: int
|
||||||
|
unverified: int
|
||||||
|
admins: int
|
||||||
|
# Konten, die beim naechsten Durchlauf deaktiviert bzw. geloescht
|
||||||
|
# wuerden - damit die Automatik nicht ueberrascht.
|
||||||
|
due_deactivation: int
|
||||||
|
due_deletion: int
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreateIn(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
display_name: str | None = Field(default=None, max_length=80)
|
||||||
|
is_admin: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class EmailChangeIn(BaseModel):
|
||||||
|
new_email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteUserIn(BaseModel):
|
||||||
|
"""Wie mit den Listen des Kontos verfahren werden soll.
|
||||||
|
|
||||||
|
"refuse" bricht ab, wenn dem Konto Listen gehoeren - die
|
||||||
|
Voreinstellung, damit nichts unbeabsichtigt verschwindet.
|
||||||
|
"handover" uebertraegt geteilte Listen an das diensthaelteste andere
|
||||||
|
Mitglied und loescht nur die, die niemand sonst nutzt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
lists: Literal["refuse", "handover"] = "refuse"
|
||||||
|
# Sicherheitsabfrage: die Adresse muss zum Konto passen
|
||||||
|
confirm_email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class AdminSettingsOut(BaseModel):
|
||||||
|
allow_self_registration: bool
|
||||||
|
locked_by_env: bool
|
||||||
|
# 0 = abgeschaltet
|
||||||
|
auto_deactivate_months: int
|
||||||
|
auto_delete_months: int
|
||||||
|
|
||||||
|
|
||||||
|
class AdminSettingsIn(BaseModel):
|
||||||
|
allow_self_registration: bool | None = None
|
||||||
|
auto_deactivate_months: int | None = Field(default=None, ge=0, le=600)
|
||||||
|
auto_delete_months: int | None = Field(default=None, ge=0, le=600)
|
||||||
|
|
||||||
|
|
||||||
|
class WelcomeCompleteIn(BaseModel):
|
||||||
|
token: str = Field(max_length=128)
|
||||||
|
password: str = Field(min_length=12, max_length=256)
|
||||||
|
display_name: str | None = Field(default=None, max_length=80)
|
||||||
|
|
||||||
|
|
||||||
|
class WelcomePreviewOut(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
display_name: str | None
|
||||||
34
backend/app/schemas_push.py
Normal file
34
backend/app/schemas_push.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class PushConfigOut(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
# Der oeffentliche VAPID-Schluessel, base64url ohne Auffuellzeichen.
|
||||||
|
public_key: str | None = None
|
||||||
|
throttle_hours: int
|
||||||
|
|
||||||
|
|
||||||
|
class PushKeys(BaseModel):
|
||||||
|
p256dh: str = Field(max_length=200)
|
||||||
|
auth: str = Field(max_length=100)
|
||||||
|
|
||||||
|
|
||||||
|
class PushSubscribeIn(BaseModel):
|
||||||
|
# Vom Push-Dienst des Browserherstellers vergebene URL.
|
||||||
|
endpoint: str = Field(min_length=10, max_length=500)
|
||||||
|
keys: PushKeys
|
||||||
|
# Grobe Geraetebezeichnung zur Unterscheidung mehrerer Anmeldungen.
|
||||||
|
label: str | None = Field(default=None, max_length=80)
|
||||||
|
|
||||||
|
|
||||||
|
class PushUnsubscribeIn(BaseModel):
|
||||||
|
endpoint: str = Field(min_length=10, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class PushSubscriptionOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
label: str | None
|
||||||
|
created_at: datetime
|
||||||
|
last_success_at: datetime | None
|
||||||
407
backend/app/schemas_shopping.py
Normal file
407
backend/app/schemas_shopping.py
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||||
|
|
||||||
|
Role = Literal["viewer", "editor", "owner"]
|
||||||
|
ItemStatus = Literal["open", "bought", "deferred"]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Listen
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ListCreateIn(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=120)
|
||||||
|
|
||||||
|
|
||||||
|
class ListUpdateIn(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=120)
|
||||||
|
|
||||||
|
|
||||||
|
class ListOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
owner_id: str
|
||||||
|
rev: int
|
||||||
|
role: Role
|
||||||
|
may_share_public: bool
|
||||||
|
member_count: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class MemberOut(BaseModel):
|
||||||
|
user_id: str
|
||||||
|
email: str
|
||||||
|
display_name: str | None
|
||||||
|
role: Role
|
||||||
|
may_share_public: bool
|
||||||
|
joined_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class MemberRoleIn(BaseModel):
|
||||||
|
role: Role
|
||||||
|
# Zusatzrecht "darf öffentliche Links erzeugen". Nicht gesetzt =
|
||||||
|
# unverändert lassen.
|
||||||
|
may_share_public: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TransferOwnershipIn(BaseModel):
|
||||||
|
"""Der bisherige Eigentümer wird dabei zum Bearbeiter."""
|
||||||
|
|
||||||
|
user_id: str = Field(min_length=36, max_length=36)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Einladungen
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class InviteCreateIn(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
role: Literal["viewer", "editor"] = "editor"
|
||||||
|
|
||||||
|
|
||||||
|
class InviteOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
email: str
|
||||||
|
role: Role
|
||||||
|
status: Literal["pending", "accepted", "revoked", "expired"]
|
||||||
|
invited_by_name: str | None
|
||||||
|
created_at: datetime
|
||||||
|
last_sent_at: datetime
|
||||||
|
send_count: int
|
||||||
|
expires_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class InvitePreviewOut(BaseModel):
|
||||||
|
"""Was der Eingeladene vor dem Annehmen sieht."""
|
||||||
|
|
||||||
|
list_name: str
|
||||||
|
invited_by_name: str | None
|
||||||
|
role: Role
|
||||||
|
email: str
|
||||||
|
# Passt die Einladung zum angemeldeten Konto?
|
||||||
|
matches_current_user: bool
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Märkte und Warengruppen
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class MarketIn(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=120)
|
||||||
|
sort_order: int = Field(default=0, ge=-9999, le=9999)
|
||||||
|
|
||||||
|
|
||||||
|
class MarketOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
sort_order: int
|
||||||
|
row_rev: int
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryIn(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=120)
|
||||||
|
sort_order: int = Field(default=0, ge=-9999, le=9999)
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
sort_order: int
|
||||||
|
row_rev: int
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Artikel
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class AttributeIn(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=80)
|
||||||
|
value: str = Field(max_length=300)
|
||||||
|
|
||||||
|
|
||||||
|
class AttributeOut(BaseModel):
|
||||||
|
name: str
|
||||||
|
value: str
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleIn(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=200)
|
||||||
|
barcode: str | None = Field(default=None, max_length=64)
|
||||||
|
note: str | None = Field(default=None, max_length=500)
|
||||||
|
default_market_id: str | None = Field(default=None, max_length=36)
|
||||||
|
default_category_id: str | None = Field(default=None, max_length=36)
|
||||||
|
attributes: list[AttributeIn] = Field(default_factory=list, max_length=40)
|
||||||
|
# Märkte, in denen es den Artikel gibt.
|
||||||
|
available_market_ids: list[str] = Field(default_factory=list, max_length=60)
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleUpdateIn(BaseModel):
|
||||||
|
"""Alle Felder optional - nur Gesetztes wird geändert.
|
||||||
|
`attributes` und `available_market_ids` ersetzen jeweils die
|
||||||
|
komplette bisherige Menge, sie werden nicht zusammengeführt."""
|
||||||
|
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=200)
|
||||||
|
barcode: str | None = Field(default=None, max_length=64)
|
||||||
|
note: str | None = Field(default=None, max_length=500)
|
||||||
|
default_market_id: str | None = Field(default=None, max_length=36)
|
||||||
|
default_category_id: str | None = Field(default=None, max_length=36)
|
||||||
|
attributes: list[AttributeIn] | None = Field(default=None, max_length=40)
|
||||||
|
available_market_ids: list[str] | None = Field(default=None, max_length=60)
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
barcode: str | None
|
||||||
|
note: str | None
|
||||||
|
default_market_id: str | None
|
||||||
|
default_category_id: str | None
|
||||||
|
attributes: list[AttributeOut]
|
||||||
|
available_market_ids: list[str]
|
||||||
|
row_rev: int
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Listeneinträge
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ItemCreateIn(BaseModel):
|
||||||
|
"""Entweder `article_id` für einen bekannten Artikel, oder
|
||||||
|
`article_name` - dann wird der Artikel bei Bedarf angelegt."""
|
||||||
|
|
||||||
|
article_id: str | None = Field(default=None, max_length=36)
|
||||||
|
article_name: str | None = Field(default=None, min_length=1, max_length=200)
|
||||||
|
market_id: str | None = Field(default=None, max_length=36)
|
||||||
|
category_id: str | None = Field(default=None, max_length=36)
|
||||||
|
# Stückzahl - der Preis gilt für EIN Gebinde.
|
||||||
|
count: int = Field(default=1, ge=1, le=999)
|
||||||
|
# Gebinde, z. B. 500 ml
|
||||||
|
pack_size: Decimal | None = Field(default=None, ge=0, le=999999)
|
||||||
|
pack_unit: str | None = Field(default=None, max_length=32)
|
||||||
|
variant: str | None = Field(default=None, max_length=200)
|
||||||
|
note: str | None = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class ItemUpdateIn(BaseModel):
|
||||||
|
market_id: str | None = Field(default=None, max_length=36)
|
||||||
|
category_id: str | None = Field(default=None, max_length=36)
|
||||||
|
count: int | None = Field(default=None, ge=1, le=999)
|
||||||
|
pack_size: Decimal | None = Field(default=None, ge=0, le=999999)
|
||||||
|
pack_unit: str | None = Field(default=None, max_length=32)
|
||||||
|
variant: str | None = Field(default=None, max_length=200)
|
||||||
|
note: str | None = Field(default=None, max_length=500)
|
||||||
|
status: ItemStatus | None = None
|
||||||
|
# Preis in Cent. Die Preishistorie kommt in Phase 7.
|
||||||
|
price_cents: int | None = Field(default=None, ge=0, le=100_000_000)
|
||||||
|
# Explizit auf null setzen statt "unverändert lassen":
|
||||||
|
clear_market: bool = False
|
||||||
|
clear_category: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ItemOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
article_id: str
|
||||||
|
article_name: str
|
||||||
|
created_by: str | None
|
||||||
|
created_by_name: str | None
|
||||||
|
market_id: str | None
|
||||||
|
category_id: str | None
|
||||||
|
count: int
|
||||||
|
pack_size: Decimal | None
|
||||||
|
pack_unit: str | None
|
||||||
|
variant: str | None
|
||||||
|
note: str | None
|
||||||
|
status: ItemStatus
|
||||||
|
# Preis für EIN Gebinde
|
||||||
|
price_cents: int | None
|
||||||
|
# Preis mal Stückzahl - vom Server gerechnet, damit App, öffentliche
|
||||||
|
# Ansicht und Ausdruck nicht auseinanderlaufen.
|
||||||
|
total_cents: int | None
|
||||||
|
row_rev: int
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ClearBoughtOut(BaseModel):
|
||||||
|
removed: int
|
||||||
|
rev: int
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Gruppierte Ansicht (Markt -> Warengruppe -> Artikel)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ViewCategory(BaseModel):
|
||||||
|
category_id: str | None
|
||||||
|
category_name: str
|
||||||
|
items: list[ItemOut]
|
||||||
|
|
||||||
|
|
||||||
|
class ViewMarket(BaseModel):
|
||||||
|
market_id: str | None
|
||||||
|
market_name: str
|
||||||
|
categories: list[ViewCategory]
|
||||||
|
open_count: int
|
||||||
|
total_cents: int
|
||||||
|
|
||||||
|
|
||||||
|
class ListView(BaseModel):
|
||||||
|
list_id: str
|
||||||
|
list_name: str
|
||||||
|
rev: int
|
||||||
|
markets: list[ViewMarket]
|
||||||
|
grand_total_cents: int
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Öffentliche Ansichtslinks
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class PublicShareIn(BaseModel):
|
||||||
|
label: str | None = Field(default=None, max_length=120)
|
||||||
|
# Pflicht: ein unbefristeter Link wäre ein dauerhaft offenes Fenster.
|
||||||
|
expires_at: datetime
|
||||||
|
allow_check: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class PublicShareOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
label: str | None
|
||||||
|
allow_check: bool
|
||||||
|
status: Literal["active", "expired", "revoked"]
|
||||||
|
created_by_name: str | None
|
||||||
|
created_at: datetime
|
||||||
|
expires_at: datetime
|
||||||
|
last_access_at: datetime | None
|
||||||
|
access_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class PublicShareCreatedOut(BaseModel):
|
||||||
|
"""Antwort beim Erzeugen - die einzige Gelegenheit, den Link zu sehen.
|
||||||
|
|
||||||
|
In der Datenbank steht nur der Hash; später lässt sich die Adresse
|
||||||
|
nicht mehr anzeigen, nur ein neuer Link erzeugen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
share: PublicShareOut
|
||||||
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
class PublicViewOut(BaseModel):
|
||||||
|
"""Was ein Empfänger ohne Konto sieht. Bewusst ohne Angaben dazu, wer
|
||||||
|
was eingetragen hat, und ohne Mitgliederliste."""
|
||||||
|
|
||||||
|
list_name: str
|
||||||
|
rev: int
|
||||||
|
allow_check: bool
|
||||||
|
expires_at: datetime
|
||||||
|
markets: list["ViewMarket"]
|
||||||
|
grand_total_cents: int
|
||||||
|
|
||||||
|
|
||||||
|
class PublicToggleIn(BaseModel):
|
||||||
|
status: Literal["open", "bought"]
|
||||||
|
|
||||||
|
|
||||||
|
class ListSnapshot(BaseModel):
|
||||||
|
"""Alles, was die Detailansicht braucht, in einer Antwort.
|
||||||
|
|
||||||
|
Vorher waren das vier Aufrufe. Bei jeder Synchronisation - also nach
|
||||||
|
jedem Abhaken - summieren sich vier Rundreisen zu einer spürbaren
|
||||||
|
Verzögerung, besonders im Mobilfunknetz.
|
||||||
|
"""
|
||||||
|
|
||||||
|
list: "ListOut"
|
||||||
|
view: "ListView"
|
||||||
|
markets: list["MarketOut"]
|
||||||
|
categories: list["CategoryOut"]
|
||||||
|
|
||||||
|
|
||||||
|
class ProductLookupOut(BaseModel):
|
||||||
|
"""Ergebnis der Strichcode-Abfrage.
|
||||||
|
|
||||||
|
`source` unterscheidet, woher die Angaben stammen - der eigene
|
||||||
|
Artikelstamm ist verlässlicher als eine Fremdquelle, und der Nutzer
|
||||||
|
soll das sehen können.
|
||||||
|
"""
|
||||||
|
|
||||||
|
barcode: str
|
||||||
|
found: bool
|
||||||
|
source: Literal["catalog", "openfoodfacts", "none"]
|
||||||
|
# Bei einem Treffer im eigenen Bestand: die Artikel-ID
|
||||||
|
article_id: str | None = None
|
||||||
|
name: str | None = None
|
||||||
|
brand: str | None = None
|
||||||
|
package: str | None = None
|
||||||
|
# "6 x 33 cl" -> count 6, Gebinde 33 cl
|
||||||
|
count: int | None = None
|
||||||
|
pack_size: Decimal | None = None
|
||||||
|
pack_unit: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Preise
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class MarketPrice(BaseModel):
|
||||||
|
market_id: str
|
||||||
|
market_name: str
|
||||||
|
latest_cents: int
|
||||||
|
latest_at: datetime
|
||||||
|
latest_pack_size: Decimal | None = None
|
||||||
|
latest_pack_unit: str | None = None
|
||||||
|
# Preis je Mengeneinheit in Zehntelcent - bei kleinen Mengen gingen
|
||||||
|
# sonst zu viele Stellen verloren.
|
||||||
|
unit_price_deci: int | None = None
|
||||||
|
min_cents: int
|
||||||
|
max_cents: int
|
||||||
|
observations: int
|
||||||
|
|
||||||
|
|
||||||
|
class PriceEntry(BaseModel):
|
||||||
|
market_id: str
|
||||||
|
market_name: str
|
||||||
|
price_cents: int
|
||||||
|
pack_size: Decimal | None
|
||||||
|
pack_unit: str | None
|
||||||
|
recorded_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ArticlePrices(BaseModel):
|
||||||
|
article_id: str
|
||||||
|
article_name: str
|
||||||
|
markets: list[MarketPrice]
|
||||||
|
history: list[PriceEntry]
|
||||||
|
|
||||||
|
|
||||||
|
class PriceOverviewRow(BaseModel):
|
||||||
|
article_id: str
|
||||||
|
article_name: str
|
||||||
|
# market_id -> juengster Preis in Cent
|
||||||
|
prices: dict[str, int]
|
||||||
|
best_market_ids: list[str]
|
||||||
|
best_cents: int
|
||||||
|
# Abstand zwischen teuerstem und guenstigstem Markt
|
||||||
|
spread_cents: int
|
||||||
|
|
||||||
|
|
||||||
|
class PriceOverview(BaseModel):
|
||||||
|
markets: list[MarketPrice]
|
||||||
|
market_names: dict[str, str]
|
||||||
|
rows: list[PriceOverviewRow]
|
||||||
|
|
||||||
|
|
||||||
|
class PriceHint(BaseModel):
|
||||||
|
article_id: str
|
||||||
|
best_market_ids: list[str]
|
||||||
|
best_cents: int
|
||||||
|
spread_cents: int
|
||||||
|
prices: dict[str, int]
|
||||||
29
backend/app/schemas_sync.py
Normal file
29
backend/app/schemas_sync.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
OpKind = Literal["item.create", "item.update", "item.delete", "items.clear_bought"]
|
||||||
|
|
||||||
|
|
||||||
|
class OpIn(BaseModel):
|
||||||
|
# Vom Client vergeben, bevor gesendet wird. Grundlage der Idempotenz:
|
||||||
|
# dieselbe op_id wird nie zweimal ausgefuehrt.
|
||||||
|
op_id: str = Field(min_length=8, max_length=64)
|
||||||
|
kind: OpKind
|
||||||
|
payload: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class OpBatchIn(BaseModel):
|
||||||
|
ops: list[OpIn] = Field(default_factory=list, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
class OpResult(BaseModel):
|
||||||
|
op_id: str
|
||||||
|
status: Literal["applied", "duplicate", "rejected"]
|
||||||
|
result_id: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OpBatchOut(BaseModel):
|
||||||
|
rev: int
|
||||||
|
results: list[OpResult]
|
||||||
91
backend/app/security.py
Normal file
91
backend/app/security.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import secrets
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from argon2 import PasswordHasher
|
||||||
|
from argon2.exceptions import InvalidHashError, VerifyMismatchError
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import RateLimit
|
||||||
|
|
||||||
|
_hasher = PasswordHasher()
|
||||||
|
|
||||||
|
# Dummy-Hash gegen Timing-Angriffe: bei unbekanntem Konto wird trotzdem
|
||||||
|
# eine Verifikation durchgefuehrt, damit die Antwortzeit gleich bleibt.
|
||||||
|
_DUMMY_HASH = _hasher.hash("dummy-password-for-constant-time-comparison")
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return _hasher.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, password_hash: str | None) -> bool:
|
||||||
|
try:
|
||||||
|
_hasher.verify(password_hash or _DUMMY_HASH, password)
|
||||||
|
except (VerifyMismatchError, InvalidHashError):
|
||||||
|
return False
|
||||||
|
return password_hash is not None
|
||||||
|
|
||||||
|
|
||||||
|
def needs_rehash(password_hash: str) -> bool:
|
||||||
|
try:
|
||||||
|
return _hasher.check_needs_rehash(password_hash)
|
||||||
|
except InvalidHashError:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def new_token() -> str:
|
||||||
|
"""256 Bit Zufall, URL-tauglich."""
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_token(token: str) -> str:
|
||||||
|
"""Token landen nur als Hash in der Datenbank. Kein Salt noetig -
|
||||||
|
der Eingabewert ist bereits hochentropisch."""
|
||||||
|
return hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def tokens_equal(a: str, b: str) -> bool:
|
||||||
|
return hmac.compare_digest(a, b)
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow() -> datetime:
|
||||||
|
return datetime.now(UTC).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_email(email: str) -> str:
|
||||||
|
return email.strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def check_rate_limit(
|
||||||
|
db: Session, bucket: str, *, limit: int, window_minutes: int
|
||||||
|
) -> bool:
|
||||||
|
"""True = Anfrage erlaubt. Zaehlt hoch und gibt False zurueck,
|
||||||
|
sobald das Limit im aktuellen Fenster erreicht ist."""
|
||||||
|
now = utcnow()
|
||||||
|
window = now.replace(second=0, microsecond=0)
|
||||||
|
window = window - timedelta(minutes=window.minute % window_minutes)
|
||||||
|
|
||||||
|
row = db.get(RateLimit, (bucket[:160], window))
|
||||||
|
if row is None:
|
||||||
|
row = RateLimit(bucket=bucket[:160], window_start=window, count=1)
|
||||||
|
db.add(row)
|
||||||
|
db.flush()
|
||||||
|
return True
|
||||||
|
|
||||||
|
if row.count >= limit:
|
||||||
|
return False
|
||||||
|
|
||||||
|
row.count += 1
|
||||||
|
db.flush()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def purge_rate_limits(db: Session, older_than_hours: int = 24) -> None:
|
||||||
|
cutoff = utcnow() - timedelta(hours=older_than_hours)
|
||||||
|
for row in db.scalars(
|
||||||
|
select(RateLimit).where(RateLimit.window_start < cutoff)
|
||||||
|
).all():
|
||||||
|
db.delete(row)
|
||||||
285
backend/app/users.py
Normal file
285
backend/app/users.py
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
"""Benutzerverwaltung: Anlegen, Adressänderung, Deaktivieren, Löschen.
|
||||||
|
|
||||||
|
Liegt außerhalb der Router, weil dieselben Vorgänge an zwei Stellen
|
||||||
|
gebraucht werden: über die Administrationsoberfläche und über die
|
||||||
|
automatische Bereinigung.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.deps import get_setting
|
||||||
|
from app.models import (
|
||||||
|
EmailChange,
|
||||||
|
EmailToken,
|
||||||
|
ListMember,
|
||||||
|
ShoppingList,
|
||||||
|
User,
|
||||||
|
UserSession,
|
||||||
|
)
|
||||||
|
from app.security import hash_token, new_token, normalize_email, utcnow
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
WELCOME_DAYS = 14
|
||||||
|
EMAIL_CHANGE_HOURS = 48
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Einstellungen zur automatischen Bereinigung
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
def months_setting(db: Session, key: str, fallback: int) -> int:
|
||||||
|
"""Liest einen Monatswert aus der Laufzeitkonfiguration.
|
||||||
|
|
||||||
|
0 bedeutet ausdrücklich "abgeschaltet" - nicht "sofort". Ein
|
||||||
|
Tippfehler soll nicht dazu führen, dass beim nächsten Durchlauf alle
|
||||||
|
Konten verschwinden.
|
||||||
|
"""
|
||||||
|
raw = get_setting(db, key, "")
|
||||||
|
if not raw:
|
||||||
|
return fallback
|
||||||
|
try:
|
||||||
|
value = int(raw)
|
||||||
|
except ValueError:
|
||||||
|
log.warning("Einstellung %s ist keine Zahl (%r) - verwende %d", key, raw, fallback)
|
||||||
|
return fallback
|
||||||
|
return max(0, min(value, 600))
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Anlegen und Willkommensnachricht
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
def issue_token(db: Session, user: User, purpose: str, hours: int) -> str:
|
||||||
|
"""Erzeugt ein Einmal-Token und entwertet ältere desselben Zwecks."""
|
||||||
|
for old in db.scalars(
|
||||||
|
select(EmailToken).where(
|
||||||
|
EmailToken.user_id == user.id,
|
||||||
|
EmailToken.purpose == purpose,
|
||||||
|
EmailToken.used_at.is_(None),
|
||||||
|
)
|
||||||
|
).all():
|
||||||
|
old.used_at = utcnow()
|
||||||
|
|
||||||
|
raw = new_token()
|
||||||
|
db.add(
|
||||||
|
EmailToken(
|
||||||
|
token_hash=hash_token(raw),
|
||||||
|
user_id=user.id,
|
||||||
|
purpose=purpose,
|
||||||
|
expires_at=utcnow() + timedelta(hours=hours),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def create_user(
|
||||||
|
db: Session, *, email: str, display_name: str | None, is_admin: bool = False
|
||||||
|
) -> tuple[User, str]:
|
||||||
|
"""Legt ein Konto ohne Passwort an.
|
||||||
|
|
||||||
|
Das Passwort setzt die eingeladene Person selbst über den Link aus
|
||||||
|
der Willkommensnachricht. Ein vom Administrator vergebenes Passwort
|
||||||
|
wäre ihm bekannt - und würde per Mail verschickt.
|
||||||
|
|
||||||
|
@returns (Konto, Klartext-Token für die Willkommensnachricht)
|
||||||
|
"""
|
||||||
|
user = User(
|
||||||
|
email=normalize_email(email),
|
||||||
|
display_name=(display_name or None),
|
||||||
|
# Platzhalter: Mit diesem Wert lässt sich nicht anmelden, weil er
|
||||||
|
# kein gültiger Argon2-Hash ist und die Prüfung fehlschlägt.
|
||||||
|
password_hash="!",
|
||||||
|
is_admin=is_admin,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.flush()
|
||||||
|
token = issue_token(db, user, "welcome", hours=WELCOME_DAYS * 24)
|
||||||
|
return user, token
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Adressänderung
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
def start_email_change(
|
||||||
|
db: Session, user: User, new_email: str, requested_by: str | None
|
||||||
|
) -> tuple[EmailChange, str, str | None]:
|
||||||
|
"""Beginnt eine Adressänderung.
|
||||||
|
|
||||||
|
@returns (Vorgang, Token für die neue Adresse, Token für die alte
|
||||||
|
Adresse oder None)
|
||||||
|
|
||||||
|
Bei Administratorkonten wird auch von der alten Adresse eine
|
||||||
|
Bestätigung verlangt. Sonst könnte, wer Zugriff auf ein
|
||||||
|
Administratorkonto erlangt, die Adresse auf eine eigene umstellen und
|
||||||
|
sich dauerhaft einnisten - der rechtmäßige Inhaber verlöre den Weg
|
||||||
|
zurück über "Passwort vergessen".
|
||||||
|
"""
|
||||||
|
target = normalize_email(new_email)
|
||||||
|
|
||||||
|
# Offene Vorgänge desselben Kontos zurückziehen.
|
||||||
|
for pending in db.scalars(
|
||||||
|
select(EmailChange).where(
|
||||||
|
EmailChange.user_id == user.id,
|
||||||
|
EmailChange.applied_at.is_(None),
|
||||||
|
EmailChange.cancelled_at.is_(None),
|
||||||
|
)
|
||||||
|
).all():
|
||||||
|
pending.cancelled_at = utcnow()
|
||||||
|
|
||||||
|
raw_new = new_token()
|
||||||
|
raw_old = new_token() if user.is_admin else None
|
||||||
|
|
||||||
|
change = EmailChange(
|
||||||
|
user_id=user.id,
|
||||||
|
old_email=user.email,
|
||||||
|
new_email=target,
|
||||||
|
token_new_hash=hash_token(raw_new),
|
||||||
|
token_old_hash=hash_token(raw_old) if raw_old else None,
|
||||||
|
requires_old=user.is_admin,
|
||||||
|
requested_by=requested_by,
|
||||||
|
expires_at=utcnow() + timedelta(hours=EMAIL_CHANGE_HOURS),
|
||||||
|
)
|
||||||
|
db.add(change)
|
||||||
|
db.flush()
|
||||||
|
return change, raw_new, raw_old
|
||||||
|
|
||||||
|
|
||||||
|
def apply_if_complete(db: Session, change: EmailChange) -> bool:
|
||||||
|
"""Übernimmt die neue Adresse, sobald alle Bestätigungen vorliegen."""
|
||||||
|
if change.applied_at or change.cancelled_at:
|
||||||
|
return False
|
||||||
|
if change.confirmed_new_at is None:
|
||||||
|
return False
|
||||||
|
if change.requires_old and change.confirmed_old_at is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
user = db.get(User, change.user_id)
|
||||||
|
if user is None:
|
||||||
|
change.cancelled_at = utcnow()
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Zwischenzeitlich vergeben? Dann Vorgang verwerfen statt eine
|
||||||
|
# Eindeutigkeitsverletzung zu produzieren.
|
||||||
|
taken = db.scalar(
|
||||||
|
select(User).where(User.email == change.new_email, User.id != user.id)
|
||||||
|
)
|
||||||
|
if taken is not None:
|
||||||
|
change.cancelled_at = utcnow()
|
||||||
|
return False
|
||||||
|
|
||||||
|
user.email = change.new_email
|
||||||
|
# Die neue Adresse hat sich gerade selbst bestätigt.
|
||||||
|
user.verified_at = utcnow()
|
||||||
|
change.applied_at = utcnow()
|
||||||
|
|
||||||
|
# Alle Sitzungen beenden: Ein Adresswechsel ist ein guter Anlass,
|
||||||
|
# sich überall neu anzumelden - besonders, wenn er nicht vom
|
||||||
|
# Kontoinhaber ausging.
|
||||||
|
for session in db.scalars(
|
||||||
|
select(UserSession).where(UserSession.user_id == user.id)
|
||||||
|
).all():
|
||||||
|
db.delete(session)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Deaktivieren, Reaktivieren, Löschen
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
def deactivate(db: Session, user: User) -> None:
|
||||||
|
"""Sperrt ein Konto und beendet alle Sitzungen."""
|
||||||
|
if not user.is_active:
|
||||||
|
return
|
||||||
|
user.is_active = False
|
||||||
|
user.deactivated_at = utcnow()
|
||||||
|
for session in db.scalars(
|
||||||
|
select(UserSession).where(UserSession.user_id == user.id)
|
||||||
|
).all():
|
||||||
|
db.delete(session)
|
||||||
|
|
||||||
|
|
||||||
|
def reactivate(db: Session, user: User) -> None:
|
||||||
|
user.is_active = True
|
||||||
|
user.deactivated_at = None
|
||||||
|
# Zähler zurücksetzen, sonst wäre das Konto beim nächsten Durchlauf
|
||||||
|
# sofort wieder fällig.
|
||||||
|
user.last_seen_at = utcnow()
|
||||||
|
|
||||||
|
|
||||||
|
def dispose_lists(db: Session, user: User) -> tuple[int, int]:
|
||||||
|
"""Regelt die Listen eines zu löschenden Kontos.
|
||||||
|
|
||||||
|
`shopping_list.owner_id` steht auf RESTRICT - ohne diese Vorarbeit
|
||||||
|
schlüge das Löschen fehl.
|
||||||
|
|
||||||
|
Geteilte Listen gehen an das dienstälteste andere Mitglied über;
|
||||||
|
Listen ohne weitere Mitglieder werden gelöscht. Eine geteilte Liste
|
||||||
|
mitzulöschen würde anderen Leuten Daten wegnehmen, mit denen das
|
||||||
|
ausscheidende Konto nichts mehr zu tun hat.
|
||||||
|
|
||||||
|
@returns (übertragen, gelöscht)
|
||||||
|
"""
|
||||||
|
transferred = 0
|
||||||
|
removed = 0
|
||||||
|
|
||||||
|
lists = db.scalars(
|
||||||
|
select(ShoppingList).where(ShoppingList.owner_id == user.id)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for lst in lists:
|
||||||
|
successor = db.scalars(
|
||||||
|
select(ListMember)
|
||||||
|
.where(ListMember.list_id == lst.id, ListMember.user_id != user.id)
|
||||||
|
.order_by(ListMember.joined_at)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if successor is None:
|
||||||
|
db.delete(lst)
|
||||||
|
removed += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
successor.role = "owner"
|
||||||
|
successor.may_share_public = True
|
||||||
|
lst.owner_id = successor.user_id
|
||||||
|
lst.rev += 1
|
||||||
|
transferred += 1
|
||||||
|
|
||||||
|
# Eigene Mitgliedschaften enden ohnehin per CASCADE.
|
||||||
|
db.flush()
|
||||||
|
return transferred, removed
|
||||||
|
|
||||||
|
|
||||||
|
def delete_user(db: Session, user: User) -> dict[str, int]:
|
||||||
|
"""Löscht ein Konto samt Vorarbeit an seinen Listen."""
|
||||||
|
transferred, removed = dispose_lists(db, user)
|
||||||
|
db.delete(user)
|
||||||
|
db.flush()
|
||||||
|
return {"übertragene Listen": transferred, "gelöschte Listen": removed}
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Kennzahlen
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
def owned_list_counts(db: Session) -> dict[str, int]:
|
||||||
|
rows = db.execute(
|
||||||
|
select(ShoppingList.owner_id, func.count())
|
||||||
|
.where(ShoppingList.deleted_at.is_(None))
|
||||||
|
.group_by(ShoppingList.owner_id)
|
||||||
|
).all()
|
||||||
|
return {owner_id: count for owner_id, count in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def membership_counts(db: Session) -> dict[str, int]:
|
||||||
|
rows = db.execute(
|
||||||
|
select(ListMember.user_id, func.count()).group_by(ListMember.user_id)
|
||||||
|
).all()
|
||||||
|
return {user_id: count for user_id, count in rows}
|
||||||
31
backend/app/wait_for_db.py
Normal file
31
backend/app/wait_for_db.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""Wartet, bis MariaDB Verbindungen annimmt. Wird vom entrypoint.sh
|
||||||
|
aufgerufen, bevor Alembic laeuft."""
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.db import engine
|
||||||
|
|
||||||
|
DEADLINE_SECONDS = 90
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
start = time.monotonic()
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("SELECT 1"))
|
||||||
|
print("[wait_for_db] Datenbank erreichbar.")
|
||||||
|
return 0
|
||||||
|
except Exception as exc:
|
||||||
|
if time.monotonic() - start > DEADLINE_SECONDS:
|
||||||
|
print(f"[wait_for_db] Aufgegeben nach {DEADLINE_SECONDS}s: {exc}",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(f"[wait_for_db] noch nicht bereit: {type(exc).__name__}")
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
18
backend/entrypoint.sh
Normal file
18
backend/entrypoint.sh
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "[entrypoint] Warte auf Datenbank ${DB_HOST}:${DB_PORT} ..."
|
||||||
|
python -m app.wait_for_db
|
||||||
|
|
||||||
|
echo "[entrypoint] Migrationen einspielen ..."
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
echo "[entrypoint] Starte API."
|
||||||
|
# --proxy-headers: X-Forwarded-For auswerten, damit das Rate Limiting
|
||||||
|
# die echte Client-IP sieht und nicht die des nginx-Containers.
|
||||||
|
# --forwarded-allow-ips: nur diesen Absendern glauben. Der Wert ist
|
||||||
|
# unkritisch, solange der api-Container KEINEN oeffentlichen Port hat.
|
||||||
|
exec uvicorn app.main:app \
|
||||||
|
--host 0.0.0.0 --port 8000 \
|
||||||
|
--proxy-headers --forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-*}" \
|
||||||
|
--reload
|
||||||
17
backend/requirements.txt
Normal file
17
backend/requirements.txt
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.34.0
|
||||||
|
sqlalchemy==2.0.36
|
||||||
|
pymysql==1.1.1
|
||||||
|
alembic==1.14.0
|
||||||
|
pydantic==2.10.4
|
||||||
|
pydantic-settings==2.7.0
|
||||||
|
email-validator==2.2.0
|
||||||
|
argon2-cffi==23.1.0
|
||||||
|
# Transitiv über Starlette, hier explizit wegen to_thread.run_sync
|
||||||
|
anyio==4.7.0
|
||||||
|
# Ausgehende Abfragen bei Open Food Facts
|
||||||
|
httpx==0.28.1
|
||||||
|
# Web Push (VAPID, aes128gcm). Bringt cryptography und http-ece mit.
|
||||||
|
pywebpush==2.0.3
|
||||||
|
# Druckansicht - mit autoescape, weil Artikelnamen freie Eingaben sind
|
||||||
|
jinja2==3.1.5
|
||||||
509
db/schema.sql
Normal file
509
db/schema.sql
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
-- ==========================================================================
|
||||||
|
-- Einkaufsliste – vollständiges Datenbankschema
|
||||||
|
-- Entspricht dem Stand nach Alembic-Revision 0012.
|
||||||
|
-- ==========================================================================
|
||||||
|
--
|
||||||
|
-- Normalerweise wird diese Datei NICHT gebraucht: Der api-Container
|
||||||
|
-- führt beim Start `alembic upgrade head` aus und legt alles selbst an.
|
||||||
|
--
|
||||||
|
-- Sie ist da für:
|
||||||
|
-- * Nachvollziehbarkeit im Versionsverwaltungssystem – man sieht auf
|
||||||
|
-- einen Blick, wie das Schema aussieht, ohne elf Migrationen zu lesen
|
||||||
|
-- * Aufsetzen ohne Alembic, etwa auf einer verwalteten Datenbank
|
||||||
|
-- * Vergleich, wenn eine Migration irgendwo halb durchgelaufen ist
|
||||||
|
--
|
||||||
|
-- Einspielen:
|
||||||
|
-- docker compose up -d db
|
||||||
|
-- docker compose exec -T db mariadb -u root -p einkaufsapp < db/schema.sql
|
||||||
|
--
|
||||||
|
-- Der Eintrag in `alembic_version` am Ende ist wichtig: Ohne ihn würde
|
||||||
|
-- Alembic beim nächsten Start alle Migrationen erneut anwenden und an
|
||||||
|
-- den bereits vorhandenen Tabellen scheitern.
|
||||||
|
--
|
||||||
|
-- Alle Bezeichner in Backticks, weil `key` und `count` in MariaDB
|
||||||
|
-- Schlüsselwörter sind.
|
||||||
|
-- ==========================================================================
|
||||||
|
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
SET FOREIGN_KEY_CHECKS = 0;
|
||||||
|
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
-- Konten und Anmeldung
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE `user` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
-- Normalisiert (kleingeschrieben, getrimmt). Die Eindeutigkeit steht
|
||||||
|
-- auf diesem Wert, deshalb muss die Anwendung konsequent normalisieren.
|
||||||
|
`email` VARCHAR(255) NOT NULL,
|
||||||
|
`display_name` VARCHAR(80) NULL,
|
||||||
|
-- Argon2id
|
||||||
|
`password_hash` VARCHAR(255) NOT NULL,
|
||||||
|
`verified_at` DATETIME NULL,
|
||||||
|
`is_admin` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
-- Sperrt alles außer "Profil lesen" und "Passwort ändern"
|
||||||
|
`must_change_password` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
-- Grundlage der automatischen Deaktivierung. Wird bei der Anmeldung
|
||||||
|
-- gesetzt und danach höchstens stündlich nachgeführt.
|
||||||
|
`last_seen_at` DATETIME NULL,
|
||||||
|
-- Seit wann deaktiviert. Grundlage der automatischen Löschung;
|
||||||
|
-- is_active allein sagt nichts über die Dauer.
|
||||||
|
`deactivated_at` DATETIME NULL,
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_user_email` (`email`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE `user_session` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
-- Nur der SHA-256-Hash des Sitzungstokens. Der Klartext steht
|
||||||
|
-- ausschließlich im HttpOnly-Cookie des Browsers.
|
||||||
|
`token_hash` VARCHAR(64) NOT NULL,
|
||||||
|
`user_id` VARCHAR(36) NOT NULL,
|
||||||
|
-- Double-Submit-Verfahren: steht zusätzlich in einem lesbaren Cookie
|
||||||
|
-- und muss bei schreibenden Anfragen im Header wiederkommen.
|
||||||
|
`csrf_token` VARCHAR(64) NOT NULL,
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`last_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`expires_at` DATETIME NOT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_user_session_token` (`token_hash`),
|
||||||
|
KEY `ix_user_session_expires` (`expires_at`),
|
||||||
|
KEY `fk_user_session_user` (`user_id`),
|
||||||
|
CONSTRAINT `fk_user_session_user` FOREIGN KEY (`user_id`)
|
||||||
|
REFERENCES `user` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE `email_token` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`token_hash` VARCHAR(64) NOT NULL,
|
||||||
|
`user_id` VARCHAR(36) NOT NULL,
|
||||||
|
-- "verify" | "reset"
|
||||||
|
`purpose` VARCHAR(32) NOT NULL,
|
||||||
|
`expires_at` DATETIME NOT NULL,
|
||||||
|
`used_at` DATETIME NULL,
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_email_token_token` (`token_hash`),
|
||||||
|
KEY `ix_email_token_user_purpose` (`user_id`, `purpose`),
|
||||||
|
CONSTRAINT `fk_email_token_user` FOREIGN KEY (`user_id`)
|
||||||
|
REFERENCES `user` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Laufende Änderung einer E-Mail-Adresse.
|
||||||
|
--
|
||||||
|
-- Bei gewöhnlichen Konten muss nur die NEUE Adresse bestätigen; die alte
|
||||||
|
-- bekommt eine Benachrichtigung. Bei Administratorkonten müssen BEIDE
|
||||||
|
-- bestätigen: Wer Zugriff auf ein Administratorkonto erlangt, könnte
|
||||||
|
-- sonst die Adresse auf eine eigene umstellen und sich dauerhaft
|
||||||
|
-- einnisten - der rechtmäßige Inhaber verlöre den Weg zurück über
|
||||||
|
-- "Passwort vergessen".
|
||||||
|
CREATE TABLE `email_change` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`user_id` VARCHAR(36) NOT NULL,
|
||||||
|
`old_email` VARCHAR(255) NOT NULL,
|
||||||
|
`new_email` VARCHAR(255) NOT NULL,
|
||||||
|
`token_new_hash` VARCHAR(64) NOT NULL,
|
||||||
|
`token_old_hash` VARCHAR(64) NULL,
|
||||||
|
-- True bei Administratorkonten
|
||||||
|
`requires_old` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
`confirmed_new_at` DATETIME NULL,
|
||||||
|
`confirmed_old_at` DATETIME NULL,
|
||||||
|
`applied_at` DATETIME NULL,
|
||||||
|
`cancelled_at` DATETIME NULL,
|
||||||
|
`requested_by` VARCHAR(36) NULL,
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`expires_at` DATETIME NOT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_email_change_token_new` (`token_new_hash`),
|
||||||
|
UNIQUE KEY `uq_email_change_token_old` (`token_old_hash`),
|
||||||
|
KEY `ix_email_change_user` (`user_id`),
|
||||||
|
KEY `fk_email_change_requested_by` (`requested_by`),
|
||||||
|
CONSTRAINT `fk_email_change_user` FOREIGN KEY (`user_id`)
|
||||||
|
REFERENCES `user` (`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_email_change_requested_by` FOREIGN KEY (`requested_by`)
|
||||||
|
REFERENCES `user` (`id`) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
-- Betrieb
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE `setting` (
|
||||||
|
`key` VARCHAR(64) NOT NULL,
|
||||||
|
`value` VARCHAR(255) NOT NULL,
|
||||||
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`key`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Zählerbasiertes Rate Limiting ohne Redis: ein Datensatz je
|
||||||
|
-- (Schlüssel, Zeitfenster).
|
||||||
|
CREATE TABLE `rate_limit` (
|
||||||
|
`bucket` VARCHAR(160) NOT NULL,
|
||||||
|
`window_start` DATETIME NOT NULL,
|
||||||
|
`count` INT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (`bucket`, `window_start`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
-- Listen und Mitgliedschaften
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE `shopping_list` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`name` VARCHAR(120) NOT NULL,
|
||||||
|
`owner_id` VARCHAR(36) NOT NULL,
|
||||||
|
-- Monoton steigender Zähler je Liste. Jede Änderung erhöht ihn und
|
||||||
|
-- schreibt den neuen Wert in row_rev der geänderten Zeile.
|
||||||
|
`rev` BIGINT NOT NULL DEFAULT 0,
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
-- Soft Delete: Offline gebliebene Geräte sollen vom Löschen erfahren.
|
||||||
|
`deleted_at` DATETIME NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `fk_shopping_list_owner` (`owner_id`),
|
||||||
|
-- RESTRICT: Ein Konto lässt sich nicht löschen, solange ihm Listen
|
||||||
|
-- gehören. Sonst verwaisen sie.
|
||||||
|
CONSTRAINT `fk_shopping_list_owner` FOREIGN KEY (`owner_id`)
|
||||||
|
REFERENCES `user` (`id`) ON DELETE RESTRICT
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE `list_member` (
|
||||||
|
`list_id` VARCHAR(36) NOT NULL,
|
||||||
|
`user_id` VARCHAR(36) NOT NULL,
|
||||||
|
-- "owner" | "editor" | "viewer"
|
||||||
|
`role` VARCHAR(16) NOT NULL DEFAULT 'editor',
|
||||||
|
-- Zusatzrecht, unabhängig von der Rolle
|
||||||
|
`may_share_public` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
`joined_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`list_id`, `user_id`),
|
||||||
|
KEY `ix_list_member_user` (`user_id`),
|
||||||
|
CONSTRAINT `fk_list_member_list` FOREIGN KEY (`list_id`)
|
||||||
|
REFERENCES `shopping_list` (`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_list_member_user` FOREIGN KEY (`user_id`)
|
||||||
|
REFERENCES `user` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
-- Katalog: Märkte, Warengruppen, Artikel
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE `market` (
|
||||||
|
`list_id` VARCHAR(36) NOT NULL,
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`name` VARCHAR(120) NOT NULL,
|
||||||
|
-- Bestimmt die Reihenfolge in Liste und Ausdruck
|
||||||
|
`sort_order` INT NOT NULL DEFAULT 0,
|
||||||
|
`row_rev` BIGINT NOT NULL DEFAULT 0,
|
||||||
|
`deleted_at` DATETIME NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_market_list_name` (`list_id`, `name`),
|
||||||
|
KEY `ix_market_list_rev` (`list_id`, `row_rev`),
|
||||||
|
CONSTRAINT `fk_market_list` FOREIGN KEY (`list_id`)
|
||||||
|
REFERENCES `shopping_list` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE `category` (
|
||||||
|
`list_id` VARCHAR(36) NOT NULL,
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`name` VARCHAR(120) NOT NULL,
|
||||||
|
`sort_order` INT NOT NULL DEFAULT 0,
|
||||||
|
`row_rev` BIGINT NOT NULL DEFAULT 0,
|
||||||
|
`deleted_at` DATETIME NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_category_list_name` (`list_id`, `name`),
|
||||||
|
KEY `ix_category_list_rev` (`list_id`, `row_rev`),
|
||||||
|
CONSTRAINT `fk_category_list` FOREIGN KEY (`list_id`)
|
||||||
|
REFERENCES `shopping_list` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE `article` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`list_id` VARCHAR(36) NOT NULL,
|
||||||
|
`name` VARCHAR(200) NOT NULL,
|
||||||
|
`barcode` VARCHAR(64) NULL,
|
||||||
|
`note` VARCHAR(500) NULL,
|
||||||
|
-- Vorgaben für neue Listeneinträge
|
||||||
|
`default_market_id` VARCHAR(36) NULL,
|
||||||
|
`default_category_id` VARCHAR(36) NULL,
|
||||||
|
`row_rev` BIGINT NOT NULL DEFAULT 0,
|
||||||
|
`deleted_at` DATETIME NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_article_list_name` (`list_id`, `name`),
|
||||||
|
KEY `ix_article_list_rev` (`list_id`, `row_rev`),
|
||||||
|
KEY `ix_article_barcode` (`list_id`, `barcode`),
|
||||||
|
KEY `fk_article_market` (`default_market_id`),
|
||||||
|
KEY `fk_article_category` (`default_category_id`),
|
||||||
|
CONSTRAINT `fk_article_list` FOREIGN KEY (`list_id`)
|
||||||
|
REFERENCES `shopping_list` (`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_article_market` FOREIGN KEY (`default_market_id`)
|
||||||
|
REFERENCES `market` (`id`) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT `fk_article_category` FOREIGN KEY (`default_category_id`)
|
||||||
|
REFERENCES `category` (`id`) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Frei definierbare Eigenschaften: Verpackungseinheit, Farbe, Größe.
|
||||||
|
-- Spalte heißt attr_name, weil `key` reserviert ist.
|
||||||
|
CREATE TABLE `article_attribute` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`article_id` VARCHAR(36) NOT NULL,
|
||||||
|
`attr_name` VARCHAR(80) NOT NULL,
|
||||||
|
`attr_value` VARCHAR(300) NOT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_article_attr` (`article_id`, `attr_name`),
|
||||||
|
CONSTRAINT `fk_article_attribute_article` FOREIGN KEY (`article_id`)
|
||||||
|
REFERENCES `article` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- In welchen Märkten ist der Artikel erhältlich
|
||||||
|
CREATE TABLE `article_market` (
|
||||||
|
`article_id` VARCHAR(36) NOT NULL,
|
||||||
|
`market_id` VARCHAR(36) NOT NULL,
|
||||||
|
PRIMARY KEY (`article_id`, `market_id`),
|
||||||
|
KEY `fk_article_market_market` (`market_id`),
|
||||||
|
CONSTRAINT `fk_article_market_article` FOREIGN KEY (`article_id`)
|
||||||
|
REFERENCES `article` (`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_article_market_market` FOREIGN KEY (`market_id`)
|
||||||
|
REFERENCES `market` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
-- Listeneinträge
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE `list_item` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`list_id` VARCHAR(36) NOT NULL,
|
||||||
|
`article_id` VARCHAR(36) NOT NULL,
|
||||||
|
-- Überschreiben die Vorgaben aus dem Artikel, falls gesetzt
|
||||||
|
`market_id` VARCHAR(36) NULL,
|
||||||
|
`category_id` VARCHAR(36) NULL,
|
||||||
|
-- Stückzahl: wie viele Gebinde. price_cents gilt für EINES davon,
|
||||||
|
-- die Summe ist price_cents * count.
|
||||||
|
`count` INT NOT NULL DEFAULT 1,
|
||||||
|
-- Gebinde: wie groß eine Packung ist (500 ml, 250 g)
|
||||||
|
`pack_size` DECIMAL(10,3) NULL,
|
||||||
|
`pack_unit` VARCHAR(32) NULL,
|
||||||
|
-- Nähere Bestimmung, z. B. "bunt, ganz"
|
||||||
|
`variant` VARCHAR(200) NULL,
|
||||||
|
-- Bemerkung für den Einkaufenden
|
||||||
|
`note` VARCHAR(500) NULL,
|
||||||
|
-- "open" | "bought" | "deferred"
|
||||||
|
`status` VARCHAR(16) NOT NULL DEFAULT 'open',
|
||||||
|
-- Preis in Cent für EIN Gebinde. Niemals als Fließkommazahl rechnen.
|
||||||
|
`price_cents` INT NULL,
|
||||||
|
`created_by` VARCHAR(36) NULL,
|
||||||
|
`row_rev` BIGINT NOT NULL DEFAULT 0,
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
`deleted_at` DATETIME NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `ix_list_item_list_rev` (`list_id`, `row_rev`),
|
||||||
|
KEY `ix_list_item_list_status` (`list_id`, `status`),
|
||||||
|
KEY `fk_list_item_article` (`article_id`),
|
||||||
|
KEY `fk_list_item_market` (`market_id`),
|
||||||
|
KEY `fk_list_item_category` (`category_id`),
|
||||||
|
KEY `fk_list_item_created_by` (`created_by`),
|
||||||
|
CONSTRAINT `fk_list_item_list` FOREIGN KEY (`list_id`)
|
||||||
|
REFERENCES `shopping_list` (`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_list_item_article` FOREIGN KEY (`article_id`)
|
||||||
|
REFERENCES `article` (`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_list_item_market` FOREIGN KEY (`market_id`)
|
||||||
|
REFERENCES `market` (`id`) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT `fk_list_item_category` FOREIGN KEY (`category_id`)
|
||||||
|
REFERENCES `category` (`id`) ON DELETE SET NULL,
|
||||||
|
-- SET NULL: Der Eintrag bleibt, der Personenbezug verschwindet
|
||||||
|
CONSTRAINT `fk_list_item_created_by` FOREIGN KEY (`created_by`)
|
||||||
|
REFERENCES `user` (`id`) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
-- Teilen
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE `list_invite` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`list_id` VARCHAR(36) NOT NULL,
|
||||||
|
`email` VARCHAR(255) NOT NULL,
|
||||||
|
`role` VARCHAR(16) NOT NULL DEFAULT 'editor',
|
||||||
|
-- Nur der Hash. Erneutes Senden erzeugt deshalb ein neues Token.
|
||||||
|
`token_hash` VARCHAR(64) NOT NULL,
|
||||||
|
`invited_by` VARCHAR(36) NULL,
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`last_sent_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`send_count` INT NOT NULL DEFAULT 1,
|
||||||
|
`expires_at` DATETIME NOT NULL,
|
||||||
|
`accepted_at` DATETIME NULL,
|
||||||
|
`accepted_by` VARCHAR(36) NULL,
|
||||||
|
`revoked_at` DATETIME NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_list_invite_token` (`token_hash`),
|
||||||
|
KEY `ix_list_invite_list` (`list_id`),
|
||||||
|
KEY `ix_list_invite_email` (`email`),
|
||||||
|
KEY `fk_list_invite_invited_by` (`invited_by`),
|
||||||
|
KEY `fk_list_invite_accepted_by` (`accepted_by`),
|
||||||
|
CONSTRAINT `fk_list_invite_list` FOREIGN KEY (`list_id`)
|
||||||
|
REFERENCES `shopping_list` (`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_list_invite_invited_by` FOREIGN KEY (`invited_by`)
|
||||||
|
REFERENCES `user` (`id`) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT `fk_list_invite_accepted_by` FOREIGN KEY (`accepted_by`)
|
||||||
|
REFERENCES `user` (`id`) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE `public_share` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`list_id` VARCHAR(36) NOT NULL,
|
||||||
|
-- Nur der Hash: Ein Datenbankleck gibt keinen Zugriff auf die Listen.
|
||||||
|
`token_hash` VARCHAR(64) NOT NULL,
|
||||||
|
`label` VARCHAR(120) NULL,
|
||||||
|
-- Darf über diesen Link abgehakt werden?
|
||||||
|
`allow_check` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
`created_by` VARCHAR(36) NULL,
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
-- Pflicht: ein unbefristeter Link wäre ein dauerhaft offenes Fenster
|
||||||
|
`expires_at` DATETIME NOT NULL,
|
||||||
|
`revoked_at` DATETIME NULL,
|
||||||
|
-- Grobe Nutzungsanzeige ohne IP-Adressen und ohne Zeitreihe
|
||||||
|
`last_access_at` DATETIME NULL,
|
||||||
|
`access_count` INT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_public_share_token` (`token_hash`),
|
||||||
|
KEY `ix_public_share_list` (`list_id`),
|
||||||
|
KEY `fk_public_share_created_by` (`created_by`),
|
||||||
|
CONSTRAINT `fk_public_share_list` FOREIGN KEY (`list_id`)
|
||||||
|
REFERENCES `shopping_list` (`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_public_share_created_by` FOREIGN KEY (`created_by`)
|
||||||
|
REFERENCES `user` (`id`) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
-- Synchronisation
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Quittung für eine bereits verarbeitete Operation aus einer
|
||||||
|
-- Outbox-Warteschlange. Verhindert, dass aus einem verlorenen
|
||||||
|
-- "Butter hinzufügen" beim nächsten Versuch zweimal Butter wird.
|
||||||
|
CREATE TABLE `applied_op` (
|
||||||
|
`op_id` VARCHAR(64) NOT NULL,
|
||||||
|
`list_id` VARCHAR(36) NOT NULL,
|
||||||
|
`user_id` VARCHAR(36) NULL,
|
||||||
|
`kind` VARCHAR(32) NOT NULL,
|
||||||
|
-- Bei item.create: die vergebene ID, damit der Client seinen
|
||||||
|
-- vorläufigen Eintrag zuordnen kann
|
||||||
|
`result_id` VARCHAR(36) NULL,
|
||||||
|
`rev` BIGINT NOT NULL DEFAULT 0,
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`op_id`),
|
||||||
|
KEY `ix_applied_op_list` (`list_id`, `created_at`),
|
||||||
|
KEY `fk_applied_op_user` (`user_id`),
|
||||||
|
CONSTRAINT `fk_applied_op_list` FOREIGN KEY (`list_id`)
|
||||||
|
REFERENCES `shopping_list` (`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_applied_op_user` FOREIGN KEY (`user_id`)
|
||||||
|
REFERENCES `user` (`id`) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
-- Produktdaten und Preise
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Zwischenspeicher für Abfragen bei Open Food Facts. Sorgt dafür, dass
|
||||||
|
-- jeder Strichcode höchstens einmal je Gültigkeitszeitraum nach außen
|
||||||
|
-- geht – und zwar vom Server, nicht vom Gerät des Nutzers.
|
||||||
|
CREATE TABLE `product_cache` (
|
||||||
|
`barcode` VARCHAR(64) NOT NULL,
|
||||||
|
`found` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
`name` VARCHAR(300) NULL,
|
||||||
|
`brand` VARCHAR(200) NULL,
|
||||||
|
-- Rohtext wie "500 g" oder "6 x 33 cl"
|
||||||
|
`package` VARCHAR(120) NULL,
|
||||||
|
-- Daraus zerlegt
|
||||||
|
`count` INT NULL,
|
||||||
|
`pack_size` DECIMAL(10,3) NULL,
|
||||||
|
`pack_unit` VARCHAR(32) NULL,
|
||||||
|
`source` VARCHAR(32) NOT NULL DEFAULT 'openfoodfacts',
|
||||||
|
`fetched_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`barcode`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Beobachteter Preis. Bewusst OHNE Nutzerbezug: Aus "wer hat wann wo was
|
||||||
|
-- zu welchem Preis gekauft" ließe sich ein Bewegungs- und Konsumprofil
|
||||||
|
-- bilden. Für den Preisvergleich genügt Markt, Artikel und Zeitpunkt.
|
||||||
|
CREATE TABLE `price_point` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`list_id` VARCHAR(36) NOT NULL,
|
||||||
|
`article_id` VARCHAR(36) NOT NULL,
|
||||||
|
`market_id` VARCHAR(36) NOT NULL,
|
||||||
|
-- Preis für EIN Gebinde
|
||||||
|
`price_cents` INT NOT NULL,
|
||||||
|
`pack_size` DECIMAL(10,3) NULL,
|
||||||
|
`pack_unit` VARCHAR(32) NULL,
|
||||||
|
`recorded_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `ix_price_article_market` (`article_id`, `market_id`, `recorded_at`),
|
||||||
|
KEY `ix_price_list` (`list_id`, `recorded_at`),
|
||||||
|
KEY `fk_price_point_market` (`market_id`),
|
||||||
|
CONSTRAINT `fk_price_point_list` FOREIGN KEY (`list_id`)
|
||||||
|
REFERENCES `shopping_list` (`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_price_point_article` FOREIGN KEY (`article_id`)
|
||||||
|
REFERENCES `article` (`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_price_point_market` FOREIGN KEY (`market_id`)
|
||||||
|
REFERENCES `market` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
-- Push-Benachrichtigungen
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE `push_subscription` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`user_id` VARCHAR(36) NOT NULL,
|
||||||
|
-- Vom Push-Dienst des Browserherstellers vergebene URL
|
||||||
|
`endpoint` VARCHAR(500) NOT NULL,
|
||||||
|
-- Schlüsselmaterial für die Ende-zu-Ende-Verschlüsselung. Auch der
|
||||||
|
-- Push-Dienst kann den Inhalt der Nachricht nicht lesen.
|
||||||
|
`p256dh` VARCHAR(200) NOT NULL,
|
||||||
|
`auth` VARCHAR(100) NOT NULL,
|
||||||
|
`label` VARCHAR(80) NULL,
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`last_success_at` DATETIME NULL,
|
||||||
|
`failure_count` INT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_push_subscription_endpoint` (`endpoint`),
|
||||||
|
KEY `ix_push_subscription_user` (`user_id`),
|
||||||
|
CONSTRAINT `fk_push_subscription_user` FOREIGN KEY (`user_id`)
|
||||||
|
REFERENCES `user` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Grundlage der Zwei-Stunden-Drosselung
|
||||||
|
CREATE TABLE `notify_state` (
|
||||||
|
`list_id` VARCHAR(36) NOT NULL,
|
||||||
|
`user_id` VARCHAR(36) NOT NULL,
|
||||||
|
`last_notified_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`list_id`, `user_id`),
|
||||||
|
KEY `fk_notify_state_user` (`user_id`),
|
||||||
|
CONSTRAINT `fk_notify_state_list` FOREIGN KEY (`list_id`)
|
||||||
|
REFERENCES `shopping_list` (`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_notify_state_user` FOREIGN KEY (`user_id`)
|
||||||
|
REFERENCES `user` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
-- Migrationsstand
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
--
|
||||||
|
-- Ohne diesen Eintrag würde Alembic beim nächsten Start alle
|
||||||
|
-- Migrationen erneut anwenden und an den vorhandenen Tabellen
|
||||||
|
-- scheitern.
|
||||||
|
|
||||||
|
CREATE TABLE `alembic_version` (
|
||||||
|
`version_num` VARCHAR(32) NOT NULL,
|
||||||
|
PRIMARY KEY (`version_num`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
INSERT INTO `alembic_version` (`version_num`) VALUES ('0012');
|
||||||
|
|
||||||
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
70
docker-compose.yml
Normal file
70
docker-compose.yml
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: mariadb:11.4
|
||||||
|
container_name: einkaufsapp_db
|
||||||
|
environment:
|
||||||
|
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:?bitte in .env setzen}
|
||||||
|
MARIADB_DATABASE: ${DB_NAME:?bitte in .env setzen}
|
||||||
|
MARIADB_USER: ${DB_USER:?bitte in .env setzen}
|
||||||
|
MARIADB_PASSWORD: ${DB_PASSWORD:?bitte in .env setzen}
|
||||||
|
command:
|
||||||
|
- --character-set-server=utf8mb4
|
||||||
|
- --collation-server=utf8mb4_unicode_ci
|
||||||
|
volumes:
|
||||||
|
- db_data:/var/lib/mysql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
start_period: 30s
|
||||||
|
restart: unless-stopped
|
||||||
|
networks: [einkaufsapp]
|
||||||
|
|
||||||
|
api:
|
||||||
|
build: ./backend
|
||||||
|
container_name: einkaufsapp_api
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
DB_HOST: db
|
||||||
|
DB_PORT: "3306"
|
||||||
|
# KEIN ports:-Eintrag. Die API ist ausschliesslich ueber den
|
||||||
|
# web-Container erreichbar. Wer sie direkt braucht, nutzt
|
||||||
|
# docker compose exec oder bindet testweise an 127.0.0.1 (s. README).
|
||||||
|
expose:
|
||||||
|
- "8000"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
restart: "no"
|
||||||
|
networks: [einkaufsapp]
|
||||||
|
|
||||||
|
web:
|
||||||
|
build: ./web
|
||||||
|
container_name: einkaufsapp_web
|
||||||
|
ports:
|
||||||
|
- "${HTTP_PORT:?bitte in .env setzen}:8080"
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
restart: unless-stopped
|
||||||
|
networks: [einkaufsapp]
|
||||||
|
|
||||||
|
# Nur fuer die Entwicklung: faengt allen ausgehenden SMTP-Verkehr ab.
|
||||||
|
# Aktivieren mit: docker compose --profile dev up -d
|
||||||
|
mailpit:
|
||||||
|
image: axllent/mailpit:v1.21
|
||||||
|
container_name: einkaufsapp_mailpit
|
||||||
|
profiles: [dev]
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${MAILPIT_PORT:-8025}:8025"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks: [einkaufsapp]
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
einkaufsapp:
|
||||||
|
name: einkaufsapp
|
||||||
174
docs/betrieb.md
Normal file
174
docs/betrieb.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# Betrieb: Sicherung, Wiederherstellung, Aufräumen
|
||||||
|
|
||||||
|
## Was gesichert werden muss
|
||||||
|
|
||||||
|
| Was | Wo | Ohne das … |
|
||||||
|
|---|---|---|
|
||||||
|
| Datenbank | Volume `einkaufsapp_db_data` | ist alles weg |
|
||||||
|
| `.env` | Projektverzeichnis | Sessions ungültig, Push-Anmeldungen tot, DB-Zugang verloren |
|
||||||
|
| Projektverzeichnis | Quelltext | neu aus dem Archiv holen – kein Datenverlust |
|
||||||
|
|
||||||
|
Der Quelltext gehört ins Versionsverwaltungssystem, die `.env` ausdrücklich
|
||||||
|
**nicht**: Sie enthält Datenbankpasswort, `SECRET_KEY`, SMTP-Zugangsdaten und
|
||||||
|
den privaten VAPID-Schlüssel. Sie gehört in den Passwortmanager oder in eine
|
||||||
|
verschlüsselte Sicherung.
|
||||||
|
|
||||||
|
Warum die `.env` so wichtig ist: Ein verlorener `SECRET_KEY` ist verschmerzbar
|
||||||
|
(alle müssen sich neu anmelden), ein verlorener `VAPID_PRIVATE_KEY` auch (alle
|
||||||
|
müssen Push neu einschalten). Ein verlorenes `DB_PASSWORD` bei erhaltener
|
||||||
|
Datenbank ist dagegen ärgerlich – dann kommt man an die eigenen Daten nur noch
|
||||||
|
über das Root-Passwort heran.
|
||||||
|
|
||||||
|
## Datenbanksicherung
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/einkaufsapp
|
||||||
|
|
||||||
|
# Konsistenter Auszug ohne Anhalten der Anwendung
|
||||||
|
docker compose exec -T db mariadb-dump \
|
||||||
|
-u root -p"$(grep '^DB_ROOT_PASSWORD=' .env | cut -d= -f2-)" \
|
||||||
|
--single-transaction --quick --routines --events \
|
||||||
|
--default-character-set=utf8mb4 \
|
||||||
|
einkaufsapp | gzip > "einkaufsapp-$(date +%F).sql.gz"
|
||||||
|
```
|
||||||
|
|
||||||
|
`--single-transaction` ist der entscheidende Schalter: Er nimmt einen
|
||||||
|
konsistenten Stand aus einem Zeitpunkt, ohne die Tabellen zu sperren. Ohne ihn
|
||||||
|
kann eine Sicherung mitten in einer Änderung entstehen – zum Beispiel mit einem
|
||||||
|
Listeneintrag, dessen Artikel noch fehlt.
|
||||||
|
|
||||||
|
`--quick` verhindert, dass große Tabellen komplett in den Arbeitsspeicher
|
||||||
|
geladen werden.
|
||||||
|
|
||||||
|
### Als tägliche Aufgabe
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo tee /etc/cron.daily/einkaufsapp-backup >/dev/null <<'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
cd /opt/einkaufsapp
|
||||||
|
PASS=$(grep '^DB_ROOT_PASSWORD=' .env | cut -d= -f2-)
|
||||||
|
DEST=/var/backups/einkaufsapp
|
||||||
|
mkdir -p "$DEST"
|
||||||
|
docker compose exec -T db mariadb-dump -u root -p"$PASS" \
|
||||||
|
--single-transaction --quick --routines --events \
|
||||||
|
--default-character-set=utf8mb4 einkaufsapp \
|
||||||
|
| gzip > "$DEST/db-$(date +%F).sql.gz"
|
||||||
|
# Vierzehn Tage aufbewahren
|
||||||
|
find "$DEST" -name 'db-*.sql.gz' -mtime +14 -delete
|
||||||
|
EOF
|
||||||
|
sudo chmod +x /etc/cron.daily/einkaufsapp-backup
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Auszug landet damit im Dateisystem und wird von deiner vorhandenen
|
||||||
|
Borgmatic-Sicherung mit erfasst. Das ist der bessere Weg, als das Volume direkt
|
||||||
|
zu sichern: Ein Dateisystemabbild einer laufenden Datenbank ist nicht
|
||||||
|
zuverlässig wiederherstellbar.
|
||||||
|
|
||||||
|
**Prüfen, dass die Sicherung etwas taugt:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
zcat /var/backups/einkaufsapp/db-$(date +%F).sql.gz | tail -5
|
||||||
|
```
|
||||||
|
|
||||||
|
Am Ende muss `-- Dump completed on …` stehen. Fehlt die Zeile, ist der Auszug
|
||||||
|
abgebrochen – eine abgeschnittene Sicherung sieht sonst genauso aus wie eine
|
||||||
|
vollständige.
|
||||||
|
|
||||||
|
## Wiederherstellung
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/einkaufsapp
|
||||||
|
docker compose down
|
||||||
|
docker volume rm einkaufsapp_db_data # ACHTUNG: löscht den aktuellen Stand
|
||||||
|
docker compose up -d db
|
||||||
|
sleep 20 # MariaDB initialisiert sich
|
||||||
|
|
||||||
|
zcat einkaufsapp-2026-08-08.sql.gz | docker compose exec -T db \
|
||||||
|
mariadb -u root -p"$(grep '^DB_ROOT_PASSWORD=' .env | cut -d= -f2-)" einkaufsapp
|
||||||
|
|
||||||
|
docker compose up -d
|
||||||
|
docker compose logs --tail=20 api
|
||||||
|
```
|
||||||
|
|
||||||
|
Alembic bringt das Schema beim Start auf den neuesten Stand, falls die
|
||||||
|
Sicherung aus einer älteren Fassung stammt. Der umgekehrte Fall – neuere
|
||||||
|
Sicherung, ältere Anwendung – geht nicht; dann erst den Quelltext aktualisieren.
|
||||||
|
|
||||||
|
**Einmal im Jahr ausprobieren.** Eine Sicherung, die nie zurückgespielt wurde,
|
||||||
|
ist eine Vermutung, keine Sicherung.
|
||||||
|
|
||||||
|
## Aufräumen
|
||||||
|
|
||||||
|
Läuft von selbst: einmal 30 Sekunden nach dem Start des `api`-Containers,
|
||||||
|
danach alle `CLEANUP_INTERVAL_HOURS` (Voreinstellung 24).
|
||||||
|
|
||||||
|
| Was | Frist | Einstellung |
|
||||||
|
|---|---|---|
|
||||||
|
| Weich gelöschte Listen, Einträge, Artikel, Märkte, Warengruppen | 30 Tage | `CLEANUP_DELETED_DAYS` |
|
||||||
|
| Outbox-Quittungen | 7 Tage | `CLEANUP_OPS_DAYS` |
|
||||||
|
| Abgelaufene Sitzungen | sofort | – |
|
||||||
|
| Rate-Limit-Zähler | 1 Tag | – |
|
||||||
|
| Verbrauchte Mail-Token | 7 Tage nach Ablauf | – |
|
||||||
|
| Abgelaufene Einladungen und öffentliche Links | 30 Tage nach Ablauf | `CLEANUP_DELETED_DAYS` |
|
||||||
|
| Produktzwischenspeicher | mindestens 1 Jahr | – |
|
||||||
|
|
||||||
|
Manuell auslösen (als Administrator angemeldet):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -b cookies.txt -H "X-CSRF-Token: $CSRF" \
|
||||||
|
-X POST https://einkauf.example.de/api/admin/cleanup
|
||||||
|
```
|
||||||
|
|
||||||
|
Im Log erscheint nach jedem Durchlauf eine Zeile wie
|
||||||
|
`Aufräumen: 12 gelöschte Einträge, 340 Outbox-Quittungen`.
|
||||||
|
|
||||||
|
**Warum die 30 Tage nicht kürzer sein sollten:** So lange kann ein Gerät
|
||||||
|
offline bleiben und beim nächsten Abgleich noch erfahren, dass eine Liste
|
||||||
|
gelöscht wurde. Wird früher aufgeräumt, taucht die Liste auf dem Gerät weiter
|
||||||
|
auf, bis jemand sie von Hand entfernt.
|
||||||
|
|
||||||
|
## Aktualisieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/einkaufsapp
|
||||||
|
# Sicherung ZUERST - Migrationen lassen sich nicht immer zurücknehmen
|
||||||
|
/etc/cron.daily/einkaufsapp-backup
|
||||||
|
|
||||||
|
# Neuen Stand einspielen, dann:
|
||||||
|
./tools/check-env.sh .env
|
||||||
|
node tools/check-js.mjs
|
||||||
|
python3 tools/check-nginx.py web
|
||||||
|
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose logs --tail=30 api # Migrationen beobachten
|
||||||
|
```
|
||||||
|
|
||||||
|
Nach Änderungen an den Dateien unter `web/html/js/` muss `VERSION` in
|
||||||
|
`web/html/sw.js` hochgezählt werden – sonst behalten bereits installierte
|
||||||
|
Clients die alte Fassung.
|
||||||
|
|
||||||
|
**`docker compose up -d`, nicht `restart`:** Ein Neustart übernimmt keine
|
||||||
|
geänderten Werte aus der `.env`. Das hat in diesem Projekt schon mehrfach für
|
||||||
|
Verwirrung gesorgt.
|
||||||
|
|
||||||
|
## Speicherplatz im Blick behalten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec db mariadb -u root -p einkaufsapp -e "
|
||||||
|
SELECT table_name AS Tabelle,
|
||||||
|
ROUND(data_length/1024/1024, 1) AS 'Daten MB',
|
||||||
|
ROUND(index_length/1024/1024, 1) AS 'Index MB',
|
||||||
|
table_rows AS 'Zeilen (geschätzt)'
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'einkaufsapp'
|
||||||
|
ORDER BY data_length DESC;"
|
||||||
|
```
|
||||||
|
|
||||||
|
Erwartungsgemäß wachsen `price_point` (jeder erfasste Preis) und
|
||||||
|
`product_cache` (jeder gescannte Strichcode) am stärksten. Beide sind gewollt:
|
||||||
|
Die Preisdatenbank ist der Zweck, und der Zwischenspeicher verhindert, dass
|
||||||
|
jeder Scan nach draußen geht.
|
||||||
|
|
||||||
|
Wächst `applied_op` unerwartet, liegt ein Gerät mit einer festhängenden Outbox
|
||||||
|
vor – dann lohnt ein Blick ins Log des `api`-Containers.
|
||||||
115
docs/mail-zustellbarkeit.md
Normal file
115
docs/mail-zustellbarkeit.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# Zustellbarkeit ausgehender Mails
|
||||||
|
|
||||||
|
Die Anwendung setzt alle Kopfzeilen korrekt, aber ob eine Mail im Posteingang
|
||||||
|
oder im Spam landet, entscheidet sich überwiegend im DNS und beim Relay – nicht
|
||||||
|
im Anwendungscode. Diese Notiz trennt beides sauber.
|
||||||
|
|
||||||
|
## Was die Anwendung erledigt
|
||||||
|
|
||||||
|
| Kopfzeile | Warum |
|
||||||
|
|---|---|
|
||||||
|
| `Date` | Fehlt sie, werten praktisch alle Filter ab |
|
||||||
|
| `Message-ID` | Mit der Domain aus `SMTP_ENVELOPE_FROM`, damit sie zur DKIM-Signatur passt |
|
||||||
|
| `From` mit Anzeigename | RFC-konform kodiert über `email.headerregistry` |
|
||||||
|
| `Reply-To` | Optional, wenn Antworten woanders hin sollen |
|
||||||
|
| `Auto-Submitted: auto-generated` | RFC 3834 – unterdrückt Abwesenheitsschleifen |
|
||||||
|
| `X-Auto-Response-Suppress: All` | Dasselbe für Exchange/Outlook |
|
||||||
|
| EHLO-Hostname | Aus `SMTP_HELO_HOSTNAME` statt der Container-ID |
|
||||||
|
| Envelope-From getrennt vom `From` | SPF wird gegen den Envelope geprüft |
|
||||||
|
|
||||||
|
Der Versand wiederholt sich bei temporären Fehlern mit wachsendem Abstand
|
||||||
|
(2 s, 4 s, 8 s). Bei permanenter Ablehnung – Empfänger unbekannt, Absender
|
||||||
|
abgelehnt, Authentifizierung fehlgeschlagen – bricht er sofort ab, statt das
|
||||||
|
Relay weiter zu belasten.
|
||||||
|
|
||||||
|
## Was du im DNS einrichten musst
|
||||||
|
|
||||||
|
Ohne diese drei Einträge landen Mails auch bei perfektem Code im Spam. Alle
|
||||||
|
beziehen sich auf die Domain aus `SMTP_ENVELOPE_FROM`.
|
||||||
|
|
||||||
|
**SPF** – erlaubt deinem Relay, für die Domain zu senden:
|
||||||
|
|
||||||
|
```
|
||||||
|
example.de. IN TXT "v=spf1 mx a:mail.example.de -all"
|
||||||
|
```
|
||||||
|
|
||||||
|
`-all` (hard fail) ist strenger als `~all` und wird von Empfängern besser
|
||||||
|
bewertet. Setze es erst, wenn du sicher bist, dass alle legitimen Absender
|
||||||
|
erfasst sind.
|
||||||
|
|
||||||
|
**DKIM** – signiert ausgehende Mails. Die Signatur erzeugt das Relay, nicht
|
||||||
|
diese Anwendung. In Postfix über OpenDKIM oder rspamd, in mailcow ist es
|
||||||
|
eingebaut. Der öffentliche Schlüssel gehört ins DNS:
|
||||||
|
|
||||||
|
```
|
||||||
|
selector._domainkey.example.de. IN TXT "v=DKIM1; k=rsa; p=MIIBIjANBg..."
|
||||||
|
```
|
||||||
|
|
||||||
|
Wichtig: Die signierende Domain (`d=` in der Signatur) muss zur Domain im
|
||||||
|
`From`-Header passen, sonst schlägt die DMARC-Ausrichtung fehl.
|
||||||
|
|
||||||
|
**DMARC** – sagt Empfängern, was bei Fehlschlägen passieren soll:
|
||||||
|
|
||||||
|
```
|
||||||
|
_dmarc.example.de. IN TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.de; adkim=s; aspf=s"
|
||||||
|
```
|
||||||
|
|
||||||
|
Fang mit `p=none` an und wertet die Reports ein paar Wochen aus, bevor du auf
|
||||||
|
`quarantine` oder `reject` gehst.
|
||||||
|
|
||||||
|
**PTR (Reverse DNS)** – die IP deines Relays muss auf einen Namen auflösen, der
|
||||||
|
vorwärts wieder auf dieselbe IP zeigt. Bei Hetzner setzt du das im
|
||||||
|
Cloud-Console-Interface bzw. im Robot. Fehlt der PTR, weisen einige große
|
||||||
|
Anbieter direkt beim `MAIL FROM` ab.
|
||||||
|
|
||||||
|
## Ausrichtung von From und Envelope-From
|
||||||
|
|
||||||
|
DMARC verlangt, dass mindestens eine der beiden Prüfungen *ausgerichtet* ist:
|
||||||
|
|
||||||
|
- **SPF-Ausrichtung:** Domain in `SMTP_ENVELOPE_FROM` = Domain in `SMTP_FROM`
|
||||||
|
- **DKIM-Ausrichtung:** signierende Domain = Domain in `SMTP_FROM`
|
||||||
|
|
||||||
|
Mit `adkim=s; aspf=s` im DMARC-Record verlangst du exakte Übereinstimmung
|
||||||
|
(nicht nur die Organisationsdomain). Das ist strenger und sicherer, bedeutet
|
||||||
|
aber: `bounces@example.de` und `einkaufsapp@example.de` sind ausgerichtet,
|
||||||
|
`bounces@bounce.example.de` wäre es nicht mehr.
|
||||||
|
|
||||||
|
## Testen
|
||||||
|
|
||||||
|
Vor dem ersten echten Versand:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verbindung zum Relay prüfen, ohne etwas zu senden
|
||||||
|
curl -s -b cookies.txt localhost:8000/api/admin/mail/check | jq
|
||||||
|
|
||||||
|
# Testnachricht auslösen
|
||||||
|
CSRF=$(grep ea_csrf cookies.txt | awk '{print $7}')
|
||||||
|
curl -s -b cookies.txt -H "X-CSRF-Token: $CSRF" \
|
||||||
|
-X POST localhost:8000/api/admin/mail/test \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"to":"dein-postfach@example.de"}'
|
||||||
|
|
||||||
|
docker compose logs --tail=20 api
|
||||||
|
```
|
||||||
|
|
||||||
|
Für eine unabhängige Bewertung eignet sich ein Dienst, der dir eine
|
||||||
|
Wegwerf-Adresse gibt und die eingehende Mail auf SPF, DKIM, DMARC, PTR und
|
||||||
|
Inhaltsmerkmale prüft – etwa `mail-tester.com`. Alles unterhalb von 8/10 lohnt
|
||||||
|
das Nachbessern.
|
||||||
|
|
||||||
|
Achte in der Auswertung besonders auf:
|
||||||
|
|
||||||
|
- `SPF: pass` mit der Envelope-Domain
|
||||||
|
- `DKIM: pass` mit `d=` gleich der From-Domain
|
||||||
|
- `DMARC: pass`
|
||||||
|
- kein Eintrag auf Spamhaus/Barracuda für deine Relay-IP
|
||||||
|
|
||||||
|
## Häufige Ursachen für Spam-Einstufung
|
||||||
|
|
||||||
|
| Symptom | Ursache |
|
||||||
|
|---|---|
|
||||||
|
| Landet bei Gmail im Spam, sonst nirgends | Fehlender oder falscher PTR-Eintrag |
|
||||||
|
| DKIM `pass`, DMARC `fail` | Signierende Domain weicht von der From-Domain ab |
|
||||||
|
| SPF `softfail` | Relay-IP nicht im SPF-Record der Envelope-Domain |
|
||||||
|
| Alles `pass`, trotzdem Spam | Neue Domain ohne Sendereputation – das gibt sich nach einigen Wochen regelmäßigen Versands |
|
||||||
|
| Outlook/Hotmail blockt | Microsoft verlangt oft eine Anmeldung beim SNDS-Programm |
|
||||||
130
docs/reverse-proxy.md
Normal file
130
docs/reverse-proxy.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# Betrieb hinter dem Reverse Proxy (OPNsense)
|
||||||
|
|
||||||
|
Aufbau: Browser → HTTPS → nginx auf der OPNsense → HTTP → Port 46600 auf dem
|
||||||
|
Docker-Host → nginx im `web`-Container → `api`.
|
||||||
|
|
||||||
|
## Drei Werte in der `.env`
|
||||||
|
|
||||||
|
```
|
||||||
|
PUBLIC_BASE_URL=https://einkauf.example.de
|
||||||
|
COOKIE_SECURE=true
|
||||||
|
FORWARDED_ALLOW_IPS=<IP des Docker-Hosts oder *>
|
||||||
|
```
|
||||||
|
|
||||||
|
Nach jeder Änderung `docker compose up -d`, nicht `restart` – ein Neustart
|
||||||
|
übernimmt keine geänderten Umgebungswerte.
|
||||||
|
|
||||||
|
**`PUBLIC_BASE_URL`** landet in jeder Verifikations-, Einladungs- und
|
||||||
|
Passwort-Reset-Mail. Steht dort noch `http://…:46600`, führen die Links ins
|
||||||
|
Leere, sobald jemand von außen klickt.
|
||||||
|
|
||||||
|
**`COOKIE_SECURE=true`** ist ab jetzt Pflicht, aber auch erst ab jetzt möglich:
|
||||||
|
Ein `Secure`-Cookie über reines HTTP wird vom Browser verworfen, und die
|
||||||
|
Anmeldung scheitert dann ohne verwertbare Meldung. Beides muss gleichzeitig
|
||||||
|
umgestellt werden.
|
||||||
|
|
||||||
|
## Was die OPNsense weiterreichen muss
|
||||||
|
|
||||||
|
Im nginx-Plugin unter *Http(s) → Location* für den Upstream:
|
||||||
|
|
||||||
|
```
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
```
|
||||||
|
|
||||||
|
`X-Forwarded-Proto` ist der wichtigste Wert: Ohne ihn hält die Anwendung die
|
||||||
|
Verbindung für unverschlüsselt.
|
||||||
|
|
||||||
|
Zusätzlich für Server-Sent Events (ab Phase 4):
|
||||||
|
|
||||||
|
```
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
```
|
||||||
|
|
||||||
|
Ohne `proxy_buffering off` sammelt nginx die Ereignisse und gibt sie erst
|
||||||
|
gebündelt weiter – die Liste aktualisiert sich dann mit Verzögerung oder gar
|
||||||
|
nicht.
|
||||||
|
|
||||||
|
## Client-IP fürs Rate Limiting
|
||||||
|
|
||||||
|
Mit zwei Proxys davor sieht die Anwendung ohne Weiterreichung nur die IP des
|
||||||
|
`web`-Containers. Dann würde eine einzige IP das Limit für alle auslösen.
|
||||||
|
`X-Forwarded-For` wird durch beide Stufen durchgereicht; uvicorn wertet es
|
||||||
|
wegen `--proxy-headers` aus.
|
||||||
|
|
||||||
|
`FORWARDED_ALLOW_IPS=*` ist vertretbar, solange der `api`-Container keinen
|
||||||
|
veröffentlichten Port hat – dann kann den Header nur der `web`-Container
|
||||||
|
setzen. Sobald du den API-Port für Debugging freigibst, gehört dort die
|
||||||
|
konkrete IP hinein.
|
||||||
|
|
||||||
|
## PWA-Installation
|
||||||
|
|
||||||
|
Erst über HTTPS bietet der Browser „Zur Startseite hinzufügen" an und lässt den
|
||||||
|
Service Worker zu. Bei iOS ist die Installation zusätzlich Voraussetzung dafür,
|
||||||
|
dass Web Push in Phase 8 überhaupt funktioniert – im normalen Safari-Tab gibt
|
||||||
|
es keine Benachrichtigungen.
|
||||||
|
|
||||||
|
## Prüfen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sI https://einkauf.example.de/ | grep -i strict-transport
|
||||||
|
curl -s https://einkauf.example.de/readyz
|
||||||
|
```
|
||||||
|
|
||||||
|
Und nach der Anmeldung im Browser unter *Entwicklerwerkzeuge → Anwendung →
|
||||||
|
Cookies*: `ea_session` muss `Secure` und `HttpOnly` tragen, `ea_csrf` nur
|
||||||
|
`Secure`.
|
||||||
|
|
||||||
|
|
||||||
|
## Kopfzeilen: was aus dem Protokoll hervorgeht
|
||||||
|
|
||||||
|
Eine Auswertung der tatsächlich ausgelieferten Antworten hat drei Punkte
|
||||||
|
ergeben, die auf der OPNsense-Seite liegen.
|
||||||
|
|
||||||
|
### `X-XSS-Protection: 1` entfernen
|
||||||
|
|
||||||
|
Die OPNsense setzt diese Kopfzeile. Sie ist überholt: Chrome hat den
|
||||||
|
zugehörigen Filter entfernt, und in den Browsern, die ihn noch kennen, kann er
|
||||||
|
in Einzelfällen selbst Lücken aufreißen, indem er Teile der Seite unterdrückt.
|
||||||
|
Der Wert `1` ohne `mode=block` ist dabei die ungünstigste Variante.
|
||||||
|
|
||||||
|
Empfehlung: im nginx-Plugin abschalten oder auf `0` setzen. Der Schutz kommt
|
||||||
|
von der Content-Security-Policy, nicht von dieser Kopfzeile.
|
||||||
|
|
||||||
|
### HSTS: `preload` passt nicht zur Laufzeit
|
||||||
|
|
||||||
|
```
|
||||||
|
strict-transport-security: max-age=15768000; includeSubDomains; preload
|
||||||
|
```
|
||||||
|
|
||||||
|
Das sind gut sechs Monate. Für die Aufnahme in die Preload-Liste verlangen die
|
||||||
|
Browserhersteller mindestens ein Jahr (`31536000`); mit dem jetzigen Wert wird
|
||||||
|
das `preload` schlicht ignoriert.
|
||||||
|
|
||||||
|
Entweder auf `31536000` erhöhen – dann aber im Bewusstsein, dass ein Eintrag in
|
||||||
|
der Preload-Liste die Domain für alle Unterdomains dauerhaft auf HTTPS
|
||||||
|
festlegt und sich nur mit Monaten Vorlauf rückgängig machen lässt – oder das
|
||||||
|
`preload` weglassen.
|
||||||
|
|
||||||
|
### Doppelte Kopfzeilen
|
||||||
|
|
||||||
|
`X-Content-Type-Options`, `X-Frame-Options` und `Permissions-Policy` kommen
|
||||||
|
jeweils zweimal an: einmal vom `web`-Container, einmal von der OPNsense. Das
|
||||||
|
ist unschädlich, aber unnötig.
|
||||||
|
|
||||||
|
Wichtiger ist, was daraus folgt: **Eine doppelte
|
||||||
|
`Content-Security-Policy` wäre nicht unschädlich.** Browser wenden dann beide
|
||||||
|
an und lassen nur zu, was in *beiden* erlaubt ist. Eine zusätzliche CSP auf der
|
||||||
|
OPNsense könnte die Anwendung also lahmlegen, ohne dass eine Fehlermeldung
|
||||||
|
darauf hinweist – sichtbar wäre nur, dass Module oder Stile nicht laden.
|
||||||
|
Derzeit setzt die OPNsense keine, das sollte so bleiben.
|
||||||
|
|
||||||
|
Die OPNsense überschreibt außerdem `Referrer-Policy` mit `same-origin` statt
|
||||||
|
des strengeren `no-referrer` aus dem Container. Unkritisch, solange die
|
||||||
|
Anwendung nicht nach außen verlinkt – für die öffentlichen Listenlinks unter
|
||||||
|
`/s/<token>` bedeutet es, dass der Token nur an die eigene Herkunft übertragen
|
||||||
|
würde. Wer es strenger mag, stellt die OPNsense auf `no-referrer` um.
|
||||||
263
docs/sicherheit.md
Normal file
263
docs/sicherheit.md
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
# Sicherheit
|
||||||
|
|
||||||
|
Stand der Prüfung: nach Phase 9, Revision `0011`, 75 Endpunkte.
|
||||||
|
|
||||||
|
## Prüfungen, die sich wiederholen lassen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/check-routes.py # Endpunkte ohne Berechtigungsprüfung
|
||||||
|
python3 tools/check-schema.py # schema.sql gegen die Modelle
|
||||||
|
./tools/check-env.sh .env # doppelte oder fehlerhafte Einträge
|
||||||
|
node tools/check-js.mjs # undefinierte Bezeichner
|
||||||
|
python3 tools/check-nginx.py web # Kopfzeilen, doppelte Direktiven
|
||||||
|
node tools/test-barcode.mjs # Strichcode-Decoder
|
||||||
|
cd backend && python3 -m compileall -q app alembic
|
||||||
|
```
|
||||||
|
|
||||||
|
## SQL-Injection
|
||||||
|
|
||||||
|
**Nicht möglich, soweit prüfbar.** Alle Abfragen laufen über
|
||||||
|
SQLAlchemy-Ausdrücke; Werte werden als gebundene Parameter übergeben, nie in
|
||||||
|
SQL-Text eingesetzt.
|
||||||
|
|
||||||
|
Zwei Stellen verdienen eine Erklärung:
|
||||||
|
|
||||||
|
**Rohes SQL** gibt es an genau zwei Stellen, beide ohne Nutzereingaben:
|
||||||
|
|
||||||
|
```python
|
||||||
|
db.execute(text("SELECT 1")) # Readiness
|
||||||
|
db.execute(text("DELETE FROM user_session WHERE expires_at <= :now"),
|
||||||
|
{"now": utcnow()}) # gebunden
|
||||||
|
```
|
||||||
|
|
||||||
|
**Der einzige f-String in einer Abfrage** steht in der Artikelsuche:
|
||||||
|
|
||||||
|
```python
|
||||||
|
needle = q.strip().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
stmt = stmt.where(Article.name.like(f"%{needle}%", escape="\\"))
|
||||||
|
```
|
||||||
|
|
||||||
|
Der f-String baut den *Suchwert*, nicht das SQL – `.like()` bindet ihn als
|
||||||
|
Parameter. Die Maskierung ist trotzdem nötig, sonst wäre die Eingabe `%` ein
|
||||||
|
Platzhalter und `_` ein Jokerzeichen. Kein Sicherheitsproblem, aber ein
|
||||||
|
Verhaltensproblem: Ohne sie fände die Suche nach „50%" den gesamten Bestand.
|
||||||
|
|
||||||
|
**Keine dynamischen Sortier- oder Filterspalten.** Alle `order_by`-Ausdrücke
|
||||||
|
verweisen auf feste Modellattribute. Ein `?sort=` mit Spaltennamen aus der
|
||||||
|
Anfrage gibt es nirgends – das ist der übliche Weg, wie SQL-Injection trotz ORM
|
||||||
|
zurückkommt.
|
||||||
|
|
||||||
|
## Cross-Site-Scripting
|
||||||
|
|
||||||
|
**Im Browser:** Die Oberfläche setzt Text ausschließlich über `textContent`
|
||||||
|
(Funktion `el()` in `dom.js`). Es gibt kein `innerHTML`, kein
|
||||||
|
`insertAdjacentHTML`, kein `document.write`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rn "innerHTML\|outerHTML\|insertAdjacentHTML\|document.write" web/html/js/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Serverseitig gerenderte Druckansicht:** Jinja2 mit `autoescape`. Geprüft mit
|
||||||
|
eingeschleusten Artikelnamen wie `<img src=x onerror=alert(1)>` – sie erscheinen
|
||||||
|
als Text, nicht als Markup.
|
||||||
|
|
||||||
|
**Content-Security-Policy:** `default-src 'self'` ohne `unsafe-inline` und ohne
|
||||||
|
`unsafe-eval`. Das war der ausschlaggebende Grund gegen einen Browser-Babel und
|
||||||
|
für ES-Module ohne Bundler.
|
||||||
|
|
||||||
|
Die Kopfzeilen liegen in `web/security_headers.conf` und werden in **jede**
|
||||||
|
`location` eingebunden, die eigene `add_header` setzt. Das ist keine
|
||||||
|
Doppelmoppelei: nginx vererbt `add_header` nur, wenn die untergeordnete Ebene
|
||||||
|
gar keines setzt – ein einzelnes `Cache-Control` in einer `location` lässt sonst
|
||||||
|
alle Kopfzeilen des `server`-Blocks verschwinden, ohne Fehlermeldung.
|
||||||
|
`tools/check-nginx.py` prüft das.
|
||||||
|
|
||||||
|
## Sitzungen und CSRF
|
||||||
|
|
||||||
|
- Sitzungstoken: 32 Byte aus `secrets.token_urlsafe`, in der Datenbank nur als
|
||||||
|
SHA-256-Hash. Der Klartext steht ausschließlich im `HttpOnly`-Cookie.
|
||||||
|
- CSRF: Double-Submit. Ein zweites, lesbares Cookie muss bei jeder
|
||||||
|
schreibenden Anfrage im Header `X-CSRF-Token` wiederkommen. Zusätzlich
|
||||||
|
`SameSite=Lax`.
|
||||||
|
- **Kein `SECRET_KEY`.** Er war bis zu dieser Prüfung als Pflichtfeld
|
||||||
|
konfiguriert und wurde nirgends verwendet – Sitzungen und CSRF arbeiten mit
|
||||||
|
serverseitig gespeicherten Zufallswerten, nicht mit signierten. Entfernt:
|
||||||
|
Eine Einstellung, die Wichtigkeit vortäuscht, ist schlechter als keine.
|
||||||
|
- Passwortwechsel verwirft alle bestehenden Sitzungen und stellt eine neue aus.
|
||||||
|
|
||||||
|
## Berechtigungen
|
||||||
|
|
||||||
|
`tools/check-routes.py` listet alle Endpunkte mit ihren
|
||||||
|
Berechtigungsabhängigkeiten. Aktuell: 75 Endpunkte, keiner ohne Schutz.
|
||||||
|
|
||||||
|
Rollen: `viewer` liest, `editor` ändert, `owner` verwaltet Mitgliedschaften.
|
||||||
|
Dazu das unabhängige Zusatzrecht `may_share_public`.
|
||||||
|
|
||||||
|
**Wer kein Mitglied ist, bekommt `404` statt `403`.** Sonst ließe sich über die
|
||||||
|
Statuscodes herausfinden, welche Listen-IDs existieren.
|
||||||
|
|
||||||
|
**Objekte werden gegen die Liste geprüft.** `_check_belongs()` in `catalog.py`
|
||||||
|
stellt sicher, dass ein übergebener Markt, eine Warengruppe oder ein Artikel
|
||||||
|
wirklich zu der Liste gehört, um die es geht. Ohne das könnte ein Mitglied durch
|
||||||
|
Angabe einer fremden ID Rückschlüsse auf andere Listen ziehen.
|
||||||
|
|
||||||
|
Zwei Fälle prüfen im Rumpf statt über eine Abhängigkeit und sind deshalb im
|
||||||
|
Skript als Ausnahme vermerkt:
|
||||||
|
|
||||||
|
- `DELETE /api/lists/{id}/members/{uid}` – der Eigentümer darf andere
|
||||||
|
entfernen, jedes Mitglied sich selbst
|
||||||
|
- `/api/public/…` – geschützt durch den Token im Pfad
|
||||||
|
|
||||||
|
## Token in Adressen
|
||||||
|
|
||||||
|
Öffentliche Links, Einladungen und Passwort-Zurücksetzen tragen ihren Token in
|
||||||
|
der URL. Das ist üblich und unvermeidbar, hat aber eine Nebenwirkung, die leicht
|
||||||
|
übersehen wird: **nginx schreibt die vollständige URL ins Zugriffsprotokoll.**
|
||||||
|
|
||||||
|
Ein Protokoll wandert in Sicherungen, wird an Auswertungswerkzeuge gereicht und
|
||||||
|
lebt länger als der Link. Wer Leserecht darauf hat, käme an alle geteilten
|
||||||
|
Listen.
|
||||||
|
|
||||||
|
Deshalb ist `access_log off` gesetzt für:
|
||||||
|
|
||||||
|
```
|
||||||
|
/s/ öffentliche Listenlinks
|
||||||
|
/invite, /reset Links aus Einladungs- und Reset-Mails
|
||||||
|
/api/public/, /api/invites/
|
||||||
|
/api/auth/verify
|
||||||
|
```
|
||||||
|
|
||||||
|
`Referrer-Policy: no-referrer` verhindert zusätzlich, dass der Token beim
|
||||||
|
Weiterklicken an fremde Seiten gelangt.
|
||||||
|
|
||||||
|
## Passwörter
|
||||||
|
|
||||||
|
- Argon2id (`argon2-cffi`), Standardparameter der Bibliothek
|
||||||
|
- Mindestens 12 Zeichen, keine Zeichenklassenpflicht – Länge schlägt
|
||||||
|
Sonderzeichen
|
||||||
|
- Beim Anmelden läuft die Prüfung auch bei unbekanntem Konto gegen einen
|
||||||
|
Dummy-Hash, damit die Antwortzeit keinen Rückschluss zulässt
|
||||||
|
- `check_needs_rehash` bei jeder Anmeldung: Werden die Parameter später
|
||||||
|
verschärft, wandern bestehende Konten von selbst mit
|
||||||
|
|
||||||
|
## Kontenaufzählung
|
||||||
|
|
||||||
|
Registrierung und Passwort-Zurücksetzen antworten immer gleich, unabhängig
|
||||||
|
davon, ob die Adresse existiert. Der Preis ist Diagnosekomfort: „Ich habe mich
|
||||||
|
registriert und bekomme keine Mail" kann auch heißen, dass das Konto längst da
|
||||||
|
ist.
|
||||||
|
|
||||||
|
Beim Einladen gibt es bewusst eine Ausnahme (`409` bei bereits vorhandenem
|
||||||
|
Zugriff) – das darf nur der Eigentümer, und der sieht die Mitgliederliste
|
||||||
|
ohnehin.
|
||||||
|
|
||||||
|
## Missbrauchsbremsen
|
||||||
|
|
||||||
|
| Wo | Grenze | Warum |
|
||||||
|
|---|---|---|
|
||||||
|
| Anmeldung | 20/15 min je IP **und** 8/15 min je Konto | Nur je IP hilft nicht gegen verteilte Angriffe; nur je Konto macht das Aussperren fremder Nutzer trivial |
|
||||||
|
| Registrierung, Reset | 5/Stunde je IP | – |
|
||||||
|
| Einladungen | 20/Stunde je Liste, 30/Stunde je IP | Jede Einladung erzeugt eine Mail an eine frei wählbare Adresse – sonst wäre die App eine Versandhilfe für unerwünschte Nachrichten |
|
||||||
|
| Öffentliche Links | 120/15 min je IP | Begrenzt die Last, nicht das Raten (256 Bit sind nicht ratbar) |
|
||||||
|
| nginx | 10/min auf `/api/auth/` | Zweite Verteidigungslinie |
|
||||||
|
|
||||||
|
Zusätzlich: höchstens 100 Listen je Konto, 50 offene Einladungen je Liste,
|
||||||
|
10 aktive öffentliche Links je Liste, 20 Push-Geräte je Konto, 200 Operationen
|
||||||
|
je Stapel.
|
||||||
|
|
||||||
|
## Datensparsamkeit
|
||||||
|
|
||||||
|
- **Preisdaten ohne Nutzerbezug.** `price_point` führt Markt, Artikel, Betrag
|
||||||
|
und Zeitpunkt – kein `user_id`. Aus „wer hat wann wo was gekauft" ließe sich
|
||||||
|
ein Bewegungs- und Konsumprofil bilden.
|
||||||
|
- **Öffentliche Ansicht anonymisiert serverseitig.** Wer welchen Artikel
|
||||||
|
eingetragen hat, wird nicht ausgeliefert – nicht erst im Browser
|
||||||
|
ausgeblendet.
|
||||||
|
- **Push-Meldungen ohne Inhalt.** Listenname und wer geändert hat, keine
|
||||||
|
Artikelnamen. Eine Benachrichtigung erscheint auf dem gesperrten Bildschirm.
|
||||||
|
- **Zugriffszähler ohne Personenbezug.** Bei öffentlichen Links sieht der
|
||||||
|
Eigentümer, *dass* der Link benutzt wird, nicht von wem.
|
||||||
|
- **Produktabfragen über den Server.** Open Food Facts erfährt die IP-Adressen
|
||||||
|
der Nutzer nicht.
|
||||||
|
- **Nur die E-Mail-Adresse ist Pflichtangabe.** Anzeigename freiwillig, auch
|
||||||
|
als Pseudonym.
|
||||||
|
- Zwischen Mitgliedern wird der Anzeigename ausgeliefert, nicht die
|
||||||
|
vollständige E-Mail-Adresse.
|
||||||
|
|
||||||
|
## Benutzerverwaltung
|
||||||
|
|
||||||
|
**Administratorkonten sind vor Deaktivierung und Löschung geschützt**, auch
|
||||||
|
gegenüber anderen Administratoren und dem eigenen Konto. Die Verwaltung soll
|
||||||
|
sich nicht aussperren können – weder versehentlich noch durch jemanden, der
|
||||||
|
kurzzeitig Zugriff erlangt hat.
|
||||||
|
|
||||||
|
**Adressänderung bei Administratorkonten verlangt beide Bestätigungen.** Ohne
|
||||||
|
diese Regel wäre der Ablauf ein Übernahmewerkzeug: Wer kurz Zugriff auf ein
|
||||||
|
Administratorkonto hat, stellt die Adresse um und hat es dauerhaft – der
|
||||||
|
rechtmäßige Inhaber kommt über „Passwort vergessen" nicht mehr hinein. Mit der
|
||||||
|
Zustimmung von der alten Adresse geht das nur, wenn auch das Postfach
|
||||||
|
übernommen wurde.
|
||||||
|
|
||||||
|
**Kein vom Administrator vergebenes Passwort.** Neue Konten haben zunächst
|
||||||
|
keinen gültigen Passwort-Hash; die Person setzt ihn über den Link aus der
|
||||||
|
Willkommensnachricht. Ein vergebenes Passwort wäre dem Administrator bekannt
|
||||||
|
und ginge im Klartext per Mail.
|
||||||
|
|
||||||
|
**Willkommensnachricht nur vor der Einrichtung erneut versendbar.** Sonst hätte
|
||||||
|
ein Administrator einen stillen Weg, sich Zugang zu fremden Konten zu
|
||||||
|
verschaffen: Link anfordern, Postfach ist unbeteiligt, Passwort neu setzen.
|
||||||
|
|
||||||
|
**Deaktivierungsmeldung erst nach erfolgreicher Passwortprüfung.** Vorher wäre
|
||||||
|
sie ein Hinweis darauf, dass das Konto existiert.
|
||||||
|
|
||||||
|
**Löschen verlangt die Eingabe der Adresse** als Bestätigung, und regelt die
|
||||||
|
Listen des Kontos, statt sie mitzureißen.
|
||||||
|
|
||||||
|
## Bekannte Grenzen
|
||||||
|
|
||||||
|
Ehrlichkeitshalber, keine dieser Punkte ist ein Fehler – aber jeder ist eine
|
||||||
|
bewusste Abwägung, die man kennen sollte.
|
||||||
|
|
||||||
|
**E-Mail-Adressen liegen im Klartext.** Verschlüsselung brächte wenig: Die
|
||||||
|
Anwendung muss Mails versenden, der Schlüssel läge also neben der Datenbank.
|
||||||
|
Der Schutz griffe nur gegen ein gestohlenes Backup ohne Serverzugriff.
|
||||||
|
|
||||||
|
**Ein öffentlicher Link ist die Berechtigung.** Wer ihn weitergibt, gibt den
|
||||||
|
Zugriff weiter. Abgefedert durch Pflicht-Ablaufdatum (höchstens 365 Tage),
|
||||||
|
jederzeitigen Widerruf und Speicherung nur als Hash.
|
||||||
|
|
||||||
|
**Preise sind nachträglich nicht zuordenbar.** Folge des fehlenden `user_id` –
|
||||||
|
wer einen falschen Preis eingetragen hat, lässt sich nicht ermitteln.
|
||||||
|
|
||||||
|
**Administratoren sehen alle E-Mail-Adressen.** Für die Verwaltung
|
||||||
|
unumgänglich. Es gibt kein Protokoll darüber, wer wann in die Benutzerliste
|
||||||
|
gesehen hat – bei der vorgesehenen Zahl von Administratoren wäre das mehr
|
||||||
|
Aufwand als Nutzen.
|
||||||
|
|
||||||
|
**Administratorrechte lassen sich nur in der Datenbank vergeben und
|
||||||
|
entziehen.** Bewusst: Ein Endpunkt dafür wäre der direkteste Weg zur
|
||||||
|
Rechteausweitung, wenn irgendwo anders ein Fehler steckt. Der Preis ist ein
|
||||||
|
umständlicher Vorgang für einen seltenen Fall.
|
||||||
|
|
||||||
|
**Kein Zwei-Faktor-Verfahren.** Für den vorgesehenen Nutzerkreis vertretbar;
|
||||||
|
bei mehr Konten wäre TOTP der nächste Schritt.
|
||||||
|
|
||||||
|
**Keine Ende-zu-Ende-Verschlüsselung der Listeninhalte.** Wer Zugriff auf die
|
||||||
|
Datenbank hat, liest die Listen. Ende-zu-Ende würde die serverseitige
|
||||||
|
Gruppierung, den Ausdruck und die öffentlichen Links unmöglich machen.
|
||||||
|
|
||||||
|
**Der `api`-Container bindet `./backend` als Volume ein.** Praktisch beim
|
||||||
|
Entwickeln, aber im Dauerbetrieb läuft damit Code vom Host statt aus dem
|
||||||
|
geprüften Abbild. Für den Produktivbetrieb: Zeile entfernen und `--reload` aus
|
||||||
|
`entrypoint.sh` nehmen.
|
||||||
|
|
||||||
|
**`FORWARDED_ALLOW_IPS=*`** ist vertretbar, solange der `api`-Container keinen
|
||||||
|
veröffentlichten Port hat – dann kann nur der `web`-Container den Header
|
||||||
|
setzen. Sobald du den API-Port zum Debuggen freigibst, gehört dort die konkrete
|
||||||
|
IP hinein, sonst lässt sich das Rate Limiting durch einen gefälschten
|
||||||
|
`X-Forwarded-For` umgehen.
|
||||||
|
|
||||||
|
**Sicherheitskopfzeilen kommen doppelt** (Container und OPNsense). Unschädlich,
|
||||||
|
solange die OPNsense **keine eigene CSP** setzt: Browser wenden dann beide an
|
||||||
|
und erlauben nur die Schnittmenge – die Anwendung würde stillschweigend
|
||||||
|
aufhören zu funktionieren. Siehe `docs/reverse-proxy.md`.
|
||||||
25
render_view.py
Normal file
25
render_view.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
view = json.load(sys.stdin)
|
||||||
|
print(f'Liste: {view["list_name"]} (rev {view["rev"]})')
|
||||||
|
for market in view["markets"]:
|
||||||
|
print(f' [{market["market_name"]}] offen: {market["open_count"]} '
|
||||||
|
f'Summe: {market["total_cents"]} ct')
|
||||||
|
for cat in market["categories"]:
|
||||||
|
print(f' <{cat["category_name"]}>')
|
||||||
|
for item in cat["items"]:
|
||||||
|
menge = ""
|
||||||
|
if item.get("pack_size") or (item.get("count") or 1) > 1:
|
||||||
|
pack = (f'{item["pack_size"]} {item.get("pack_unit") or ""}'.strip()
|
||||||
|
if item.get("pack_size") else "")
|
||||||
|
count = item.get("count") or 1
|
||||||
|
menge = f' {count} × {pack}'.rstrip() if count > 1 and pack \
|
||||||
|
else (f' {pack}' if pack else f' {count} Stück')
|
||||||
|
preis = (f' [{item["price_cents"]} ct je Gebinde'
|
||||||
|
f', gesamt {item.get("total_cents")} ct]'
|
||||||
|
if item["price_cents"] else "")
|
||||||
|
eigenschaft = f' ({item.get("variant")})' if item.get("variant") else ""
|
||||||
|
print(f' - {item["article_name"]}{eigenschaft}{menge}{preis} '
|
||||||
|
f'[{item["status"]}]')
|
||||||
|
print(f' Gesamtsumme: {view["grand_total_cents"]} ct')
|
||||||
176
smoke-phase2.sh
Normal file
176
smoke-phase2.sh
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Durchlauf durch die Phase-2-Endpunkte. Legt eine Testliste an, füllt
|
||||||
|
# sie und prüft die gruppierte Ansicht.
|
||||||
|
#
|
||||||
|
# ./smoke-phase2.sh http://localhost:46600 admin@example.de
|
||||||
|
#
|
||||||
|
# Das Passwort wird abgefragt, nicht als Argument übergeben. Damit landet
|
||||||
|
# es nicht in der Shell-History, und Sonderzeichen wie ! machen keinen
|
||||||
|
# Ärger: die History-Expansion der Bash greift nur bei interaktiver
|
||||||
|
# Eingabe auf der Kommandozeile, nicht bei "read".
|
||||||
|
#
|
||||||
|
# Alternativ per Umgebungsvariable, z.B. aus einem Passwortmanager:
|
||||||
|
# EA_PASSWORD="$(pass show einkaufsapp)" ./smoke-phase2.sh URL MAIL
|
||||||
|
#
|
||||||
|
# Benötigt curl sowie jq oder python3.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE="${1:-http://localhost:46600}"
|
||||||
|
EMAIL="${2:?E-Mail angeben}"
|
||||||
|
|
||||||
|
if [ -n "${EA_PASSWORD:-}" ]; then
|
||||||
|
PASSWORD="$EA_PASSWORD"
|
||||||
|
else
|
||||||
|
printf 'Passwort für %s: ' "$EMAIL" >&2
|
||||||
|
IFS= read -rs PASSWORD
|
||||||
|
printf '\n' >&2
|
||||||
|
fi
|
||||||
|
[ -n "$PASSWORD" ] || { echo "Kein Passwort angegeben." >&2; exit 1; }
|
||||||
|
|
||||||
|
# jq ist bequem, aber nicht zwingend. Ohne jq springt python3 ein -
|
||||||
|
# beides kann JSON korrekt erzeugen und lesen, was hier der Punkt ist:
|
||||||
|
# Passwoerter und Umlaute duerfen nicht per String-Bastelei in die
|
||||||
|
# Nutzlast wandern.
|
||||||
|
if command -v jq >/dev/null 2>&1; then
|
||||||
|
JSON_TOOL=jq
|
||||||
|
elif command -v python3 >/dev/null 2>&1; then
|
||||||
|
JSON_TOOL=python3
|
||||||
|
else
|
||||||
|
echo "Benötigt jq oder python3. Installieren mit: apt install jq" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# json_obj key value [key value ...] -> JSON-Objekt auf stdout
|
||||||
|
json_obj() {
|
||||||
|
if [ "$JSON_TOOL" = jq ]; then
|
||||||
|
local args=() filter="{" first=1 i=1
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
args+=(--arg "k$i" "$1" --arg "v$i" "$2")
|
||||||
|
[ $first -eq 1 ] || filter="$filter,"
|
||||||
|
filter="$filter(\$k$i): \$v$i"
|
||||||
|
first=0; i=$((i+1)); shift 2
|
||||||
|
done
|
||||||
|
jq -n "${args[@]}" "$filter}"
|
||||||
|
else
|
||||||
|
python3 -c 'import json,sys; a=sys.argv[1:]; print(json.dumps(dict(zip(a[::2], a[1::2]))))' "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# json_get <schluessel> - liest einen Wert aus JSON auf stdin
|
||||||
|
json_get() {
|
||||||
|
if [ "$JSON_TOOL" = jq ]; then
|
||||||
|
jq -r ".$1 // empty"
|
||||||
|
else
|
||||||
|
python3 -c 'import json,sys
|
||||||
|
try:
|
||||||
|
d = json.load(sys.stdin)
|
||||||
|
except Exception:
|
||||||
|
sys.exit(0)
|
||||||
|
v = d.get(sys.argv[1]) if isinstance(d, dict) else None
|
||||||
|
print("" if v is None else v)' "$1"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
JAR="$(mktemp)"
|
||||||
|
chmod 600 "$JAR"
|
||||||
|
trap 'rm -f "$JAR"' EXIT
|
||||||
|
|
||||||
|
say() { printf '\n\033[1m== %s\033[0m\n' "$1"; }
|
||||||
|
|
||||||
|
api() {
|
||||||
|
local method="$1" path="$2" body="${3:-}"
|
||||||
|
local csrf
|
||||||
|
csrf="$(awk '/ea_csrf/ {print $7}' "$JAR")"
|
||||||
|
if [ -n "$body" ]; then
|
||||||
|
curl -sS -b "$JAR" -c "$JAR" -X "$method" "$BASE$path" \
|
||||||
|
-H 'Content-Type: application/json' -H "X-CSRF-Token: $csrf" \
|
||||||
|
--data-binary "$body"
|
||||||
|
else
|
||||||
|
curl -sS -b "$JAR" -c "$JAR" -X "$method" "$BASE$path" \
|
||||||
|
-H "X-CSRF-Token: $csrf"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
say "Anmelden"
|
||||||
|
# JSON strukturiert erzeugen, nicht per String-Interpolation: sonst
|
||||||
|
# zerbricht jedes " oder \ im Passwort die Nutzlast.
|
||||||
|
LOGIN_JSON="$(json_obj email "$EMAIL" password "$PASSWORD")"
|
||||||
|
curl -sS -c "$JAR" -X POST "$BASE/api/auth/login" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data-binary "$LOGIN_JSON" > /tmp/ea_login.$$ 2>&1
|
||||||
|
WHO="$(json_get email < /tmp/ea_login.$$)"
|
||||||
|
if [ -z "$WHO" ]; then
|
||||||
|
echo " Anmeldung fehlgeschlagen: $(json_get detail < /tmp/ea_login.$$)" >&2
|
||||||
|
rm -f /tmp/ea_login.$$
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " angemeldet als $WHO"
|
||||||
|
rm -f /tmp/ea_login.$$
|
||||||
|
unset PASSWORD LOGIN_JSON
|
||||||
|
|
||||||
|
say "CSRF-Schutz muss greifen (erwartet 403)"
|
||||||
|
code=$(curl -sS -o /dev/null -w '%{http_code}' -b "$JAR" -X POST "$BASE/api/lists" \
|
||||||
|
-H 'Content-Type: application/json' --data-binary '{"name":"Ohne CSRF"}')
|
||||||
|
[ "$code" = "403" ] && echo " ok: $code" || { echo " FEHLER: $code statt 403"; exit 1; }
|
||||||
|
|
||||||
|
say "Liste anlegen"
|
||||||
|
LIST=$(api POST /api/lists "$(json_obj name Wocheneinkauf)" | json_get id)
|
||||||
|
echo " list_id=$LIST"
|
||||||
|
|
||||||
|
say "Märkte anlegen"
|
||||||
|
EDEKA=$(api POST "/api/lists/$LIST/markets" '{"name":"Edeka","sort_order":1}' | json_get id)
|
||||||
|
DM=$(api POST "/api/lists/$LIST/markets" '{"name":"dm","sort_order":2}' | json_get id)
|
||||||
|
echo " Edeka=$EDEKA dm=$DM"
|
||||||
|
|
||||||
|
say "Doppelter Marktname muss abgelehnt werden (erwartet 409)"
|
||||||
|
code=$(curl -sS -o /dev/null -w '%{http_code}' -b "$JAR" -X POST "$BASE/api/lists/$LIST/markets" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H "X-CSRF-Token: $(awk '/ea_csrf/ {print $7}' "$JAR")" \
|
||||||
|
--data-binary '{"name":"Edeka"}')
|
||||||
|
[ "$code" = "409" ] && echo " ok: $code" || { echo " FEHLER: $code statt 409"; exit 1; }
|
||||||
|
|
||||||
|
say "Warengruppen anlegen"
|
||||||
|
MOLK=$(api POST "/api/lists/$LIST/categories" '{"name":"Molkerei","sort_order":1}' | json_get id)
|
||||||
|
OBST=$(api POST "/api/lists/$LIST/categories" '{"name":"Obst und Gemüse","sort_order":2}' | json_get id)
|
||||||
|
|
||||||
|
say "Artikel mit Attributen anlegen"
|
||||||
|
api POST "/api/lists/$LIST/articles" "$(cat <<JSON
|
||||||
|
{"name":"Vollmilch","barcode":"4001234567890",
|
||||||
|
"default_market_id":"$EDEKA","default_category_id":"$MOLK",
|
||||||
|
"attributes":[{"name":"Verpackungseinheit","value":"1 Liter"},
|
||||||
|
{"name":"Fettgehalt","value":"3,5 %"}],
|
||||||
|
"available_market_ids":["$EDEKA"]}
|
||||||
|
JSON
|
||||||
|
)" | json_get name | sed 's/^/ Artikel angelegt: /'
|
||||||
|
|
||||||
|
say "Einträge anlegen (Artikel wird bei Bedarf erzeugt)"
|
||||||
|
for a in Butter Äpfel Bananen Zahnpasta; do
|
||||||
|
api POST "/api/lists/$LIST/items" "$(json_obj article_name "$a")" > /dev/null
|
||||||
|
done
|
||||||
|
MILCH=$(api POST "/api/lists/$LIST/items" \
|
||||||
|
'{"article_name":"Vollmilch","quantity":2,"unit":"l"}' | json_get id)
|
||||||
|
|
||||||
|
say "Zuordnungen setzen"
|
||||||
|
api PATCH "/api/lists/$LIST/items/$MILCH" \
|
||||||
|
"{\"market_id\":\"$EDEKA\",\"category_id\":\"$MOLK\",\"price_cents\":129}" \
|
||||||
|
| json_get article_name | sed 's/^/ Preis gesetzt für: /'
|
||||||
|
|
||||||
|
say "Barcode-Suche"
|
||||||
|
api GET "/api/lists/$LIST/articles/by-barcode/4001234567890" | json_get name | sed 's/^/ gefunden: /'
|
||||||
|
|
||||||
|
say "Gruppierte Ansicht"
|
||||||
|
api GET "/api/lists/$LIST/view" | python3 "$(dirname "$0")/render_view.py"
|
||||||
|
|
||||||
|
say "Abhaken und aufräumen"
|
||||||
|
api PATCH "/api/lists/$LIST/items/$MILCH" '{"status":"bought"}' | json_get status | sed 's/^/ neuer Status: /'
|
||||||
|
api POST "/api/lists/$LIST/items/clear-bought" | json_get removed | sed 's/^/ entfernte Einträge: /'
|
||||||
|
|
||||||
|
say "Fremde Liste darf nicht sichtbar sein (erwartet 404)"
|
||||||
|
code=$(curl -sS -o /dev/null -w '%{http_code}' -b "$JAR" \
|
||||||
|
"$BASE/api/lists/00000000-0000-0000-0000-000000000000")
|
||||||
|
[ "$code" = "404" ] && echo " ok: $code" || { echo " FEHLER: $code statt 404"; exit 1; }
|
||||||
|
|
||||||
|
say "Fertig"
|
||||||
|
echo "Testliste $LIST bleibt bestehen. Löschen mit:"
|
||||||
|
echo " curl -b <cookiejar> -X DELETE $BASE/api/lists/$LIST -H 'X-CSRF-Token: ...'"
|
||||||
43
tools/check-all.sh
Normal file
43
tools/check-all.sh
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Alle Prüfungen in einem Durchgang.
|
||||||
|
#
|
||||||
|
# bash tools/check-all.sh
|
||||||
|
#
|
||||||
|
# Falls das Ausführungsbit fehlt (etwa nach dem Entpacken eines Archivs):
|
||||||
|
# chmod +x tools/*.sh
|
||||||
|
#
|
||||||
|
# Vor jedem docker compose build sinnvoll. Bricht beim ersten Problem ab
|
||||||
|
# und gibt Rückgabewert 1 zurück - damit lässt es sich als Git-Hook oder
|
||||||
|
# in einer CI verwenden.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
failed=0
|
||||||
|
run() {
|
||||||
|
local label="$1"; shift
|
||||||
|
printf '\n\033[1m== %s\033[0m\n' "$label"
|
||||||
|
if "$@"; then
|
||||||
|
:
|
||||||
|
else
|
||||||
|
echo " -> fehlgeschlagen"
|
||||||
|
failed=$((failed + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
[ -f .env ] && run "Konfiguration (.env)" bash tools/check-env.sh .env
|
||||||
|
run "Konfigurationsvorlage" bash tools/check-env.sh .env.example
|
||||||
|
run "Berechtigungen" python3 tools/check-routes.py
|
||||||
|
run "Datenbankschema" python3 tools/check-schema.py
|
||||||
|
run "nginx-Konfiguration" python3 tools/check-nginx.py web
|
||||||
|
run "JavaScript-Module" node tools/check-js.mjs
|
||||||
|
run "Strichcode-Decoder" node tools/test-barcode.mjs
|
||||||
|
run "Python-Syntax" sh -c 'cd backend && python3 -m compileall -q app alembic && echo "in Ordnung"'
|
||||||
|
|
||||||
|
printf '\n'
|
||||||
|
if [ "$failed" -eq 0 ]; then
|
||||||
|
echo "Alle Prüfungen bestanden."
|
||||||
|
else
|
||||||
|
echo "$failed Prüfung(en) fehlgeschlagen."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
52
tools/check-env.sh
Normal file
52
tools/check-env.sh
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Sucht doppelte Variablen in der .env.
|
||||||
|
#
|
||||||
|
# ./tools/check-env.sh [.env]
|
||||||
|
#
|
||||||
|
# Hintergrund: Docker Compose nimmt bei mehrfach definierten Variablen
|
||||||
|
# die LETZTE. Eine vergessene leere Vorlagenzeile unterhalb des
|
||||||
|
# eingefügten Werts schaltet die betroffene Funktion damit still wieder
|
||||||
|
# ab - ohne Fehlermeldung, nur mit einem Verhalten, das niemand erklären
|
||||||
|
# kann.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
FILE="${1:-.env}"
|
||||||
|
|
||||||
|
[ -f "$FILE" ] || { echo "$FILE nicht gefunden." >&2; exit 2; }
|
||||||
|
|
||||||
|
problems=0
|
||||||
|
|
||||||
|
# Doppelte Schlüssel
|
||||||
|
dupes=$(grep -E '^[A-Za-z_][A-Za-z0-9_]*=' "$FILE" \
|
||||||
|
| cut -d= -f1 | sort | uniq -d || true)
|
||||||
|
if [ -n "$dupes" ]; then
|
||||||
|
echo "Mehrfach definiert (Docker Compose nimmt jeweils die letzte Zeile):"
|
||||||
|
while read -r key; do
|
||||||
|
[ -z "$key" ] && continue
|
||||||
|
echo " $key"
|
||||||
|
grep -n "^$key=" "$FILE" | sed 's/^/ Zeile /'
|
||||||
|
problems=$((problems + 1))
|
||||||
|
done <<< "$dupes"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Leerzeichen vor dem Gleichheitszeichen - solche Zeilen ignoriert Compose
|
||||||
|
spaced=$(grep -nE '^[A-Za-z_][A-Za-z0-9_]* +=' "$FILE" || true)
|
||||||
|
if [ -n "$spaced" ]; then
|
||||||
|
echo "Leerzeichen vor dem '=' (wird von Docker Compose ignoriert):"
|
||||||
|
echo "$spaced" | sed 's/^/ Zeile /'
|
||||||
|
problems=$((problems + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Anführungszeichen - die landen wörtlich im Wert
|
||||||
|
quoted=$(grep -nE '^[A-Za-z_][A-Za-z0-9_]*="' "$FILE" || true)
|
||||||
|
if [ -n "$quoted" ]; then
|
||||||
|
echo "Anführungszeichen im Wert (Compose übernimmt sie wörtlich):"
|
||||||
|
echo "$quoted" | sed 's/^/ Zeile /'
|
||||||
|
problems=$((problems + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$problems" -eq 0 ]; then
|
||||||
|
echo "$FILE: keine Auffälligkeiten"
|
||||||
|
else
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
243
tools/check-js.mjs
Normal file
243
tools/check-js.mjs
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Sucht Bezeichner, die verwendet, aber nirgends deklariert oder
|
||||||
|
* importiert werden.
|
||||||
|
*
|
||||||
|
* node tools/check-js.mjs
|
||||||
|
*
|
||||||
|
* Hintergrund: Ohne Build-Schritt gibt es keinen Linter, und der Browser
|
||||||
|
* meldet "x is not defined" erst, wenn die betroffene Zeile tatsächlich
|
||||||
|
* ausgeführt wird. Eine Funktion, die nur im Menü eines Eintrags
|
||||||
|
* gebraucht wird, kann so lange fehlen, ohne dass es auffällt.
|
||||||
|
*
|
||||||
|
* Das ist bewusst eine grobe Prüfung ohne echten Parser: Sie kennt keine
|
||||||
|
* Blockgeltungsbereiche und meldet daher nichts, was irgendwo in der
|
||||||
|
* Datei deklariert ist. Für den Zweck - "ganz vergessen" statt "am
|
||||||
|
* falschen Ort" - reicht das.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, readdirSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
const ROOT = process.argv[2] || "web/html/js";
|
||||||
|
|
||||||
|
/** Was der Browser mitbringt. Bewusst knapp gehalten - je kürzer, desto
|
||||||
|
* mehr findet die Prüfung. */
|
||||||
|
const GLOBALS = new Set([
|
||||||
|
// Sprache
|
||||||
|
"Array", "Boolean", "Date", "Error", "Infinity", "Intl", "JSON", "Map",
|
||||||
|
"Math", "NaN", "Number", "Object", "Promise", "RegExp", "Set", "String",
|
||||||
|
"Symbol", "TypeError", "Uint8Array", "Uint8ClampedArray", "WeakMap",
|
||||||
|
"globalThis", "isNaN", "parseFloat", "parseInt", "structuredClone",
|
||||||
|
"undefined", "queueMicrotask", "console",
|
||||||
|
// Browser
|
||||||
|
"AbortController", "Blob", "CustomEvent", "DOMException", "Event",
|
||||||
|
"EventSource", "FileReader", "FormData", "Headers", "IDBKeyRange",
|
||||||
|
"Image", "ImageData", "Node", "Request", "Response", "URL",
|
||||||
|
"URLSearchParams", "alert", "clearInterval", "clearTimeout", "confirm",
|
||||||
|
"crypto", "document", "fetch", "history", "indexedDB", "localStorage",
|
||||||
|
"location", "navigator", "performance", "prompt", "requestAnimationFrame",
|
||||||
|
"Notification", "PushManager", "ServiceWorkerRegistration",
|
||||||
|
"self", "sessionStorage", "setInterval", "setTimeout", "window",
|
||||||
|
"BarcodeDetector", "Element", "HTMLElement", "MediaStream", "TextDecoder",
|
||||||
|
"TextEncoder", "atob", "btoa", "decodeURIComponent", "encodeURIComponent",
|
||||||
|
// Service Worker
|
||||||
|
"caches", "clients", "registration", "skipWaiting",
|
||||||
|
// Schlüsselwörter, die als Wort auftauchen
|
||||||
|
"arguments", "as", "async", "await", "break", "case", "catch", "class", "const",
|
||||||
|
"continue", "default", "delete", "do", "else", "export", "extends",
|
||||||
|
"false", "finally", "for", "from", "function", "get", "if", "import",
|
||||||
|
"in", "instanceof", "let", "new", "null", "of", "return", "set", "static",
|
||||||
|
"super", "switch", "this", "throw", "true", "try", "typeof", "var", "void",
|
||||||
|
"while", "yield",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Entfernt Kommentare und Zeichenkettenliterale. Der Inhalt von
|
||||||
|
* Template-Ausdrücken ${...} bleibt erhalten - dort steht echter Code. */
|
||||||
|
function stripNoise(source) {
|
||||||
|
let out = "";
|
||||||
|
let i = 0;
|
||||||
|
const n = source.length;
|
||||||
|
|
||||||
|
while (i < n) {
|
||||||
|
const two = source.slice(i, i + 2);
|
||||||
|
|
||||||
|
if (two === "//") {
|
||||||
|
while (i < n && source[i] !== "\n") i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (two === "/*") {
|
||||||
|
i += 2;
|
||||||
|
while (i < n && source.slice(i, i + 2) !== "*/") i++;
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (source[i] === '"' || source[i] === "'") {
|
||||||
|
const quote = source[i++];
|
||||||
|
while (i < n && source[i] !== quote) {
|
||||||
|
if (source[i] === "\\") i++;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
out += '""';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (source[i] === "`") {
|
||||||
|
i++;
|
||||||
|
while (i < n && source[i] !== "`") {
|
||||||
|
if (source[i] === "\\") { i += 2; continue; }
|
||||||
|
if (source.slice(i, i + 2) === "${") {
|
||||||
|
// Ausdruck übernehmen, Klammern zählen - und rekursiv säubern,
|
||||||
|
// denn darin können wieder Zeichenketten stehen.
|
||||||
|
i += 2;
|
||||||
|
let depth = 1;
|
||||||
|
let inner = "";
|
||||||
|
while (i < n && depth > 0) {
|
||||||
|
if (source[i] === "{") depth++;
|
||||||
|
else if (source[i] === "}") depth--;
|
||||||
|
if (depth > 0) inner += source[i];
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
out += " " + stripNoise(inner) + " ";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
out += '""';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Regulärer Ausdruck? Ein / ist nur dann Beginn eines Literals, wenn
|
||||||
|
// davor ein Operator oder Klammeranfang steht - sonst ist es eine
|
||||||
|
// Division. Ohne diese Unterscheidung landen die Flags (g, i, s) und
|
||||||
|
// Zeichenklassen (\D, \w) in der Bezeichnerliste.
|
||||||
|
if (source[i] === "/") {
|
||||||
|
const head = out.replace(/\s+$/, "");
|
||||||
|
const before = head.slice(-1);
|
||||||
|
// Nach einem Schlüsselwort steht ebenfalls ein Literal, keine
|
||||||
|
// Division: `return /x/.test(s)` ist gültiges JavaScript.
|
||||||
|
const keyword = /(?:^|[^\w$])(return|typeof|instanceof|in|of|case|do|else|void|delete|await|yield|new|throw)$/.test(head);
|
||||||
|
if (before === "" || keyword || "(,=:[!&|?{};+-*%<>~^".includes(before)) {
|
||||||
|
i++;
|
||||||
|
let inClass = false;
|
||||||
|
while (i < n) {
|
||||||
|
if (source[i] === "\\") { i += 2; continue; }
|
||||||
|
if (source[i] === "[") inClass = true;
|
||||||
|
else if (source[i] === "]") inClass = false;
|
||||||
|
else if (source[i] === "/" && !inClass) break;
|
||||||
|
else if (source[i] === "\n") break;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
while (i < n && /[a-z]/.test(source[i])) i++; // Flags
|
||||||
|
out += "0";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out += source[i++];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function declaredNames(code) {
|
||||||
|
const names = new Set();
|
||||||
|
const add = (raw) => {
|
||||||
|
// Destrukturierung entfernen, dann jeden Teil einzeln betrachten.
|
||||||
|
for (const part of raw.replace(/[{}[\]]/g, "").split(",")) {
|
||||||
|
// "a = 1" -> a, "a: b" -> b (der zweite Name ist die Bindung)
|
||||||
|
const pieces = part.split("=")[0].split(":");
|
||||||
|
const name = pieces[pieces.length - 1].trim().replace(/^\.\.\./, "");
|
||||||
|
if (/^[A-Za-z_$][\w$]*$/.test(name)) names.add(name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Importe
|
||||||
|
for (const m of code.matchAll(/import\s+\*\s+as\s+([\w$]+)/g)) names.add(m[1]);
|
||||||
|
for (const m of code.matchAll(/import\s+\{([^}]*)\}/g)) {
|
||||||
|
for (const part of m[1].split(",")) {
|
||||||
|
const name = part.trim().split(/\s+as\s+/).pop().trim();
|
||||||
|
if (name) names.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const m of code.matchAll(/import\s+([\w$]+)\s+from/g)) names.add(m[1]);
|
||||||
|
|
||||||
|
// Funktionen und Klassen
|
||||||
|
for (const m of code.matchAll(/(?:function|class)\s+([\w$]+)/g)) names.add(m[1]);
|
||||||
|
|
||||||
|
// Variablen, auch destrukturiert
|
||||||
|
for (const m of code.matchAll(/(?:const|let|var)\s+(\{[^}]*\}|\[[^\]]*\]|[\w$]+)/g)) {
|
||||||
|
add(m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parameterlisten: alles zwischen ( ) vor einem => oder {
|
||||||
|
for (const m of code.matchAll(/\(([^()]*)\)\s*(?:=>|\{)/g)) add(m[1]);
|
||||||
|
// Einzelner Pfeilparameter ohne Klammern
|
||||||
|
for (const m of code.matchAll(/(?:^|[^\w$.])([\w$]+)\s*=>/gm)) names.add(m[1]);
|
||||||
|
// catch (e)
|
||||||
|
for (const m of code.matchAll(/catch\s*\(\s*([\w$]+)/g)) names.add(m[1]);
|
||||||
|
// for (const x of ...) ist oben abgedeckt; benannte Objektmethoden
|
||||||
|
for (const m of code.matchAll(/([\w$]+)\s*\([^()]*\)\s*\{/g)) names.add(m[1]);
|
||||||
|
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
function usedNames(code) {
|
||||||
|
const names = new Set();
|
||||||
|
// Kein Punkt davor (sonst wäre es ein Eigenschaftszugriff), kein
|
||||||
|
// Doppelpunkt danach (sonst wäre es ein Objektschlüssel oder Label).
|
||||||
|
for (const m of code.matchAll(/(?<![\w$.])([A-Za-z_$][\w$]*)/g)) {
|
||||||
|
const name = m[1];
|
||||||
|
const after = code.slice(m.index + name.length, m.index + name.length + 40);
|
||||||
|
if (/^\s*:/.test(after)) continue;
|
||||||
|
names.add(name);
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
function walk(dir, files = []) {
|
||||||
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const path = join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) walk(path, files);
|
||||||
|
else if (entry.name.endsWith(".js")) files.push(path);
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
let problems = 0;
|
||||||
|
const targets = walk(ROOT);
|
||||||
|
targets.push("web/html/sw.js");
|
||||||
|
|
||||||
|
for (const file of targets) {
|
||||||
|
let source;
|
||||||
|
try {
|
||||||
|
source = readFileSync(file, "utf8");
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const code = stripNoise(source);
|
||||||
|
const declared = declaredNames(code);
|
||||||
|
const used = usedNames(code);
|
||||||
|
|
||||||
|
const unknown = [...used].filter(
|
||||||
|
(name) => !declared.has(name) && !GLOBALS.has(name)
|
||||||
|
).sort();
|
||||||
|
|
||||||
|
if (unknown.length) {
|
||||||
|
console.log(`\n ${file}`);
|
||||||
|
for (const name of unknown) {
|
||||||
|
// Zeilennummer des ersten Vorkommens für die Fehlersuche
|
||||||
|
const line = source.split("\n").findIndex((l) =>
|
||||||
|
new RegExp(`(^|[^\\w$.])${name}\\b`).test(l)) + 1;
|
||||||
|
console.log(` Zeile ${line}: ${name} wird verwendet, aber nirgends deklariert`);
|
||||||
|
problems++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
problems
|
||||||
|
? `\n${problems} möglicherweise undefinierte(r) Bezeichner`
|
||||||
|
: `${targets.length} Dateien geprüft, keine undefinierten Bezeichner`
|
||||||
|
);
|
||||||
|
process.exit(problems ? 1 : 0);
|
||||||
139
tools/check-nginx.py
Normal file
139
tools/check-nginx.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Statische Prüfung der nginx-Konfiguration im web-Container.
|
||||||
|
|
||||||
|
Findet drei Fehlerklassen, die uns bereits begegnet sind und die nginx
|
||||||
|
erst beim Start bemerkt - oder gar nicht:
|
||||||
|
|
||||||
|
1. Doppelte Direktiven im selben Kontext.
|
||||||
|
nginx bricht mit "directive is duplicate" ab. Passiert leicht, wenn
|
||||||
|
eine location proxy_common.conf einbindet und etwas wiederholt, das
|
||||||
|
dort schon steht.
|
||||||
|
|
||||||
|
2. Fehlende Sicherheitskopfzeilen.
|
||||||
|
nginx vererbt add_header NUR, wenn die untergeordnete Ebene gar kein
|
||||||
|
add_header setzt. Ein einziges Cache-Control in einer location lässt
|
||||||
|
alle Kopfzeilen des server-Blocks verschwinden - ohne Fehlermeldung.
|
||||||
|
|
||||||
|
3. Ein types-Block im server-Kontext.
|
||||||
|
Der ersetzt die geerbte MIME-Tabelle vollständig, statt sie zu
|
||||||
|
ergänzen. CSS und JavaScript kommen dann als
|
||||||
|
application/octet-stream an und der Browser lehnt ES-Module ab.
|
||||||
|
|
||||||
|
Aufruf:
|
||||||
|
python3 tools/check-nginx.py # prüft web/
|
||||||
|
python3 tools/check-nginx.py pfad/zu/web
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# add_header darf mehrfach auftreten - jedes weitere hängt an, statt zu
|
||||||
|
# ersetzen. Alle anderen hier geprüften Direktiven dürfen es nicht.
|
||||||
|
REPEATABLE = {"add_header", "proxy_set_header", "try_files", "include",
|
||||||
|
"limit_req", "gzip_types", "error_page", "set"}
|
||||||
|
|
||||||
|
|
||||||
|
def directive_names(text: str) -> list[str]:
|
||||||
|
out = []
|
||||||
|
for line in text.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
out.append(line.split()[0])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def check(web_dir: Path) -> int:
|
||||||
|
conf_path = web_dir / "nginx.conf"
|
||||||
|
if not conf_path.is_file():
|
||||||
|
print(f"nginx.conf nicht gefunden unter {conf_path}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
conf = conf_path.read_text(encoding="utf-8")
|
||||||
|
problems: list[str] = []
|
||||||
|
|
||||||
|
includes = {}
|
||||||
|
for name in ("proxy_common.conf", "security_headers.conf"):
|
||||||
|
path = web_dir / name
|
||||||
|
if path.is_file():
|
||||||
|
includes[name] = set(directive_names(path.read_text(encoding="utf-8")))
|
||||||
|
else:
|
||||||
|
problems.append(f"{name} fehlt, wird aber eingebunden")
|
||||||
|
includes[name] = set()
|
||||||
|
|
||||||
|
# ---- 3. types-Block ----
|
||||||
|
if re.search(r"^\s*types\s*\{", conf, re.M):
|
||||||
|
problems.append(
|
||||||
|
"types-Block gefunden: ersetzt im server-Kontext die gesamte "
|
||||||
|
"MIME-Tabelle. Stattdessen default_type in der betroffenen "
|
||||||
|
"location setzen."
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- Klammernbilanz ----
|
||||||
|
if conf.count("{") != conf.count("}"):
|
||||||
|
problems.append(
|
||||||
|
f"Geschweifte Klammern unausgeglichen: "
|
||||||
|
f"{conf.count('{')} auf, {conf.count('}')} zu"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- pro location ----
|
||||||
|
for match in re.finditer(r"(location[^{]*)\{(.*?\n )\}", conf, re.S):
|
||||||
|
header = match.group(1).strip()
|
||||||
|
body = match.group(2)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
line.strip()
|
||||||
|
for line in body.splitlines()
|
||||||
|
if line.strip() and not line.strip().startswith("#")
|
||||||
|
]
|
||||||
|
included = {name for name in includes if any(name in line for line in lines)}
|
||||||
|
own = [line.split()[0] for line in lines if not line.startswith("include")]
|
||||||
|
|
||||||
|
for name in sorted(set(own)):
|
||||||
|
if own.count(name) > 1 and name not in REPEATABLE:
|
||||||
|
problems.append(f"{header}: {name} steht {own.count(name)}× im Block")
|
||||||
|
for inc in included:
|
||||||
|
if name in includes[inc] and name not in REPEATABLE:
|
||||||
|
problems.append(
|
||||||
|
f"{header}: {name} steht auch in {inc} – nginx bricht "
|
||||||
|
"mit \"directive is duplicate\" ab"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "add_header" in own and "security_headers.conf" not in included:
|
||||||
|
problems.append(
|
||||||
|
f"{header}: setzt add_header, bindet aber security_headers.conf "
|
||||||
|
"nicht ein – die Kopfzeilen des server-Blocks gehen hier verloren"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- Ereigniskanal ----
|
||||||
|
sse = re.search(r"location[^{]*events[^{]*\{(.*?\n )\}", conf, re.S)
|
||||||
|
if sse:
|
||||||
|
body = sse.group(1)
|
||||||
|
if "gzip off" not in body:
|
||||||
|
problems.append(
|
||||||
|
"Ereigniskanal: gzip off fehlt – Komprimierung sammelt die "
|
||||||
|
"Ereignisse, statt sie einzeln durchzureichen"
|
||||||
|
)
|
||||||
|
common = (web_dir / "proxy_common.conf")
|
||||||
|
if common.is_file() and "proxy_buffering off" not in common.read_text():
|
||||||
|
problems.append(
|
||||||
|
"proxy_common.conf: proxy_buffering off fehlt – ohne das "
|
||||||
|
"puffert nginx die Server-Sent Events"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
problems.append("Kein location-Block für den Ereigniskanal gefunden")
|
||||||
|
|
||||||
|
if problems:
|
||||||
|
print(f"{len(problems)} Problem(e) in {conf_path}:\n")
|
||||||
|
for p in problems:
|
||||||
|
print(f" - {p}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"{conf_path}: keine Probleme gefunden")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("web")
|
||||||
|
raise SystemExit(check(target))
|
||||||
88
tools/check-routes.py
Normal file
88
tools/check-routes.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Listet alle Endpunkte samt ihrer Berechtigungsabhängigkeiten.
|
||||||
|
|
||||||
|
python3 tools/check-routes.py
|
||||||
|
|
||||||
|
Findet Endpunkte, die weder eine Abhängigkeit mit Berechtigungsprüfung
|
||||||
|
haben noch ausdrücklich als offen eingetragen sind. Damit fällt auf, wenn
|
||||||
|
beim Hinzufügen einer Route die Absicherung vergessen wurde - was beim
|
||||||
|
Schreiben leicht passiert, weil FastAPI das nicht anmahnt.
|
||||||
|
|
||||||
|
Rein statisch: Zwei Fälle kann das Skript nicht sehen und sie sind unten
|
||||||
|
als Ausnahmen vermerkt - Endpunkte, die ihre Prüfung im Rumpf machen
|
||||||
|
(DELETE auf Mitgliedschaften: Eigentümer oder man selbst) und solche,
|
||||||
|
die über einen Token im Pfad geschützt sind.
|
||||||
|
"""
|
||||||
|
import ast, pathlib, re
|
||||||
|
|
||||||
|
GUARDS = {
|
||||||
|
"CurrentUser": "angemeldet",
|
||||||
|
"VerifiedUser": "bestätigt",
|
||||||
|
"AdminUser": "ADMIN",
|
||||||
|
"ReadableList": "Listen-Leser",
|
||||||
|
"EditableList": "Listen-Bearbeiter",
|
||||||
|
"OwnedList": "Listen-EIGENTÜMER",
|
||||||
|
"SharableList": "Teilen-Berechtigt",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Endpunkte, die bewusst ohne Anmeldung erreichbar sind
|
||||||
|
INTENDED_PUBLIC = {
|
||||||
|
"/api/auth/register", "/api/auth/login", "/api/auth/verify",
|
||||||
|
"/api/auth/password/reset-request", "/api/auth/password/reset",
|
||||||
|
# Willkommensstrecke: Wer den Link oeffnet, HAT noch kein Passwort
|
||||||
|
# und kann sich deshalb nicht anmelden. Der Token ist der Nachweis.
|
||||||
|
"/api/auth/welcome/{token}", "/api/auth/welcome/complete",
|
||||||
|
# Bestaetigung einer Adressaenderung - ebenfalls per Token, weil der
|
||||||
|
# Klick aus dem Postfach kommt.
|
||||||
|
"/api/auth/email-change/{token}",
|
||||||
|
"/api/config", "/manifest.webmanifest", "/api/push/config",
|
||||||
|
"/healthz", "/readyz",
|
||||||
|
}
|
||||||
|
# Öffentlich, aber durch einen Token im Pfad geschützt
|
||||||
|
TOKEN_GUARDED = ("/api/public/",)
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for path in sorted(pathlib.Path("backend/app/routers").glob("*.py")):
|
||||||
|
src = path.read_text()
|
||||||
|
prefix = ""
|
||||||
|
m = re.search(r'APIRouter\((?:[^)]*?)prefix="([^"]*)"', src, re.S)
|
||||||
|
if m:
|
||||||
|
prefix = m.group(1)
|
||||||
|
|
||||||
|
tree = ast.parse(src)
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||||
|
continue
|
||||||
|
for dec in node.decorator_list:
|
||||||
|
if not (isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute)):
|
||||||
|
continue
|
||||||
|
if dec.func.attr.upper() not in {"GET","POST","PUT","PATCH","DELETE"}:
|
||||||
|
continue
|
||||||
|
method = dec.func.attr.upper()
|
||||||
|
route = prefix + (dec.args[0].value if dec.args else "?")
|
||||||
|
|
||||||
|
guards = []
|
||||||
|
for arg in node.args.args + node.args.kwonlyargs:
|
||||||
|
ann = ast.unparse(arg.annotation) if arg.annotation else ""
|
||||||
|
for name, label in GUARDS.items():
|
||||||
|
if ann == name:
|
||||||
|
guards.append(label)
|
||||||
|
rows.append((route, method, guards, path.name))
|
||||||
|
|
||||||
|
print(f"{'Pfad':52} {'Methode':7} Schutz")
|
||||||
|
print("-" * 100)
|
||||||
|
problems = 0
|
||||||
|
for route, method, guards, file in sorted(rows):
|
||||||
|
if guards:
|
||||||
|
mark = ", ".join(guards)
|
||||||
|
elif route in INTENDED_PUBLIC:
|
||||||
|
mark = "offen (gewollt)"
|
||||||
|
elif route.startswith(TOKEN_GUARDED):
|
||||||
|
mark = "Token im Pfad"
|
||||||
|
else:
|
||||||
|
mark = ">>> KEIN SCHUTZ <<<"
|
||||||
|
problems += 1
|
||||||
|
print(f"{route:52} {method:7} {mark}")
|
||||||
|
|
||||||
|
print("-" * 100)
|
||||||
|
print(f"{len(rows)} Endpunkte, {problems} ohne erkennbaren Schutz")
|
||||||
134
tools/check-schema.py
Normal file
134
tools/check-schema.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Vergleicht db/schema.sql mit den SQLAlchemy-Modellen.
|
||||||
|
|
||||||
|
python3 tools/check-schema.py
|
||||||
|
|
||||||
|
Die SQL-Datei ist von Hand geschrieben und kann beim nächsten
|
||||||
|
Modell-Umbau vergessen werden. Dieser Vergleich findet fehlende oder
|
||||||
|
überzählige Tabellen und Spalten - bevor jemand die Datei zum Aufsetzen
|
||||||
|
einer neuen Instanz benutzt und sich wundert.
|
||||||
|
|
||||||
|
Arbeitet rein textlich, ohne SQLAlchemy zu laden: Damit läuft die
|
||||||
|
Prüfung auch außerhalb des Containers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
MODELS = ROOT / "backend" / "app" / "models.py"
|
||||||
|
SCHEMA = ROOT / "db" / "schema.sql"
|
||||||
|
|
||||||
|
# Tabellen, die nicht aus Modellen stammen
|
||||||
|
EXTRA_TABLES = {"alembic_version"}
|
||||||
|
|
||||||
|
|
||||||
|
def from_models() -> dict[str, set[str]]:
|
||||||
|
"""__tablename__ und mapped_column-Namen je Klasse einsammeln."""
|
||||||
|
source = MODELS.read_text(encoding="utf-8")
|
||||||
|
tables: dict[str, set[str]] = {}
|
||||||
|
current: str | None = None
|
||||||
|
|
||||||
|
for line in source.splitlines():
|
||||||
|
table = re.match(r'\s*__tablename__\s*=\s*"([^"]+)"', line)
|
||||||
|
if table:
|
||||||
|
current = table.group(1)
|
||||||
|
tables[current] = set()
|
||||||
|
continue
|
||||||
|
|
||||||
|
if current is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Beziehungen sind keine Spalten
|
||||||
|
if "relationship(" in line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
column = re.match(r"\s*(\w+):\s*Mapped\[", line)
|
||||||
|
if column:
|
||||||
|
tables[current].add(column.group(1))
|
||||||
|
|
||||||
|
return tables
|
||||||
|
|
||||||
|
|
||||||
|
def from_schema() -> dict[str, set[str]]:
|
||||||
|
"""CREATE TABLE ... ( ... ) auswerten."""
|
||||||
|
source = SCHEMA.read_text(encoding="utf-8")
|
||||||
|
tables: dict[str, set[str]] = {}
|
||||||
|
|
||||||
|
for match in re.finditer(
|
||||||
|
r"CREATE TABLE `(\w+)` \((.*?)\n\) ENGINE", source, re.S
|
||||||
|
):
|
||||||
|
name, body = match.group(1), match.group(2)
|
||||||
|
columns = set()
|
||||||
|
for line in body.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith("--") or not line:
|
||||||
|
continue
|
||||||
|
# Spaltendefinitionen beginnen mit `name` gefolgt von einem Typ
|
||||||
|
col = re.match(r"`(\w+)`\s+[A-Z]", line)
|
||||||
|
if col:
|
||||||
|
columns.add(col.group(1))
|
||||||
|
tables[name] = columns
|
||||||
|
|
||||||
|
return tables
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
models = from_models()
|
||||||
|
schema = from_schema()
|
||||||
|
problems = 0
|
||||||
|
|
||||||
|
missing = set(models) - set(schema)
|
||||||
|
surplus = set(schema) - set(models) - EXTRA_TABLES
|
||||||
|
|
||||||
|
for name in sorted(missing):
|
||||||
|
print(f" FEHLT in schema.sql: Tabelle {name}")
|
||||||
|
problems += 1
|
||||||
|
for name in sorted(surplus):
|
||||||
|
print(f" ÜBERZÄHLIG in schema.sql: Tabelle {name}")
|
||||||
|
problems += 1
|
||||||
|
|
||||||
|
for name in sorted(set(models) & set(schema)):
|
||||||
|
only_model = models[name] - schema[name]
|
||||||
|
only_schema = schema[name] - models[name]
|
||||||
|
for column in sorted(only_model):
|
||||||
|
print(f" FEHLT in schema.sql: {name}.{column}")
|
||||||
|
problems += 1
|
||||||
|
for column in sorted(only_schema):
|
||||||
|
print(f" ÜBERZÄHLIG in schema.sql: {name}.{column}")
|
||||||
|
problems += 1
|
||||||
|
|
||||||
|
# Migrationsstand muss zur höchsten Revision passen
|
||||||
|
versions = ROOT / "backend" / "alembic" / "versions"
|
||||||
|
revisions = sorted(
|
||||||
|
m.group(1)
|
||||||
|
for f in versions.glob("*.py")
|
||||||
|
if (m := re.search(r'^revision:\s*str\s*=\s*"(\w+)"', f.read_text(), re.M))
|
||||||
|
)
|
||||||
|
head = revisions[-1] if revisions else None
|
||||||
|
stamped = re.search(
|
||||||
|
r"INSERT INTO `alembic_version`.*VALUES \('(\w+)'\)", SCHEMA.read_text()
|
||||||
|
)
|
||||||
|
if head and (not stamped or stamped.group(1) != head):
|
||||||
|
print(
|
||||||
|
f" Migrationsstand passt nicht: schema.sql trägt "
|
||||||
|
f"{stamped.group(1) if stamped else 'nichts'}, "
|
||||||
|
f"höchste Revision ist {head}"
|
||||||
|
)
|
||||||
|
problems += 1
|
||||||
|
|
||||||
|
if problems:
|
||||||
|
print(f"\n{problems} Abweichung(en) zwischen Modellen und schema.sql")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"{len(models)} Tabellen, "
|
||||||
|
f"{sum(len(c) for c in models.values())} Spalten – "
|
||||||
|
f"schema.sql stimmt mit den Modellen überein (Revision {head})"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
210
tools/test-barcode.mjs
Normal file
210
tools/test-barcode.mjs
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
// Test des Strichcode-Decoders ohne Kamera.
|
||||||
|
//
|
||||||
|
// node tools/test-barcode.mjs
|
||||||
|
//
|
||||||
|
// Erzeugt synthetische EAN-13-, EAN-8- und UPC-A-Signale, verzerrt sie
|
||||||
|
// (Unschärfe, Rauschen, unterschiedliche Skalierung, umgedreht) und
|
||||||
|
// prüft, ob der Decoder sie zurückliest - und ob er auf leeren Flächen
|
||||||
|
// und Rauschen schweigt.
|
||||||
|
|
||||||
|
import {
|
||||||
|
checksumValid,
|
||||||
|
decodeImage,
|
||||||
|
decodeLine,
|
||||||
|
} from "../web/html/js/barcode.js";
|
||||||
|
|
||||||
|
// Deterministischer Zufallszahlengenerator (xorshift32).
|
||||||
|
//
|
||||||
|
// Math.random() hätte hier einen Test ergeben, der mal durchgeht und mal
|
||||||
|
// nicht - genau das ist beim ersten Gesamtdurchlauf passiert. Ein Test
|
||||||
|
// mit wechselndem Ergebnis ist schlimmer als keiner: Man gewöhnt sich an,
|
||||||
|
// ihn zu ignorieren.
|
||||||
|
let _seed = 0x2f6f4e01;
|
||||||
|
function rnd() {
|
||||||
|
_seed ^= _seed << 13; _seed >>>= 0;
|
||||||
|
_seed ^= _seed >> 17;
|
||||||
|
_seed ^= _seed << 5; _seed >>>= 0;
|
||||||
|
return _seed / 0x100000000;
|
||||||
|
}
|
||||||
|
function reseed(value) { _seed = value >>> 0 || 1; }
|
||||||
|
|
||||||
|
// --- Kodierer zum Gegentesten (nur im Test, nicht in der App) ---
|
||||||
|
const L = ["0001101","0011001","0010011","0111101","0100011",
|
||||||
|
"0110001","0101111","0111011","0110111","0001011"];
|
||||||
|
const R_ = L.map((s) => [...s].map((b) => (b === "0" ? "1" : "0")).join(""));
|
||||||
|
const G = R_.map((s) => [...s].reverse().join(""));
|
||||||
|
const R = L.map((s) => [...s].map((b) => (b === "0" ? "1" : "0")).join(""));
|
||||||
|
const PARITY = ["LLLLLL","LLGLGG","LLGGLG","LLGGGL","LGLLGG",
|
||||||
|
"LGGLLG","LGGGLL","LGLGLG","LGLGGL","LGGLGL"];
|
||||||
|
|
||||||
|
function checkDigit(body) {
|
||||||
|
const d = [...body].map(Number);
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = d.length - 1, w = 3; i >= 0; i--, w = 4 - w) sum += d[i] * w;
|
||||||
|
return (10 - (sum % 10)) % 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeEAN13(body12) {
|
||||||
|
const code = body12 + checkDigit(body12);
|
||||||
|
const first = Number(code[0]);
|
||||||
|
const parity = PARITY[first];
|
||||||
|
let bits = "101";
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const d = Number(code[i + 1]);
|
||||||
|
bits += parity[i] === "L" ? L[d] : G[d];
|
||||||
|
}
|
||||||
|
bits += "01010";
|
||||||
|
for (let i = 0; i < 6; i++) bits += R[Number(code[i + 7])];
|
||||||
|
bits += "101";
|
||||||
|
return { code, bits };
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeEAN8(body7) {
|
||||||
|
const code = body7 + checkDigit(body7);
|
||||||
|
let bits = "101";
|
||||||
|
for (let i = 0; i < 4; i++) bits += L[Number(code[i])];
|
||||||
|
bits += "01010";
|
||||||
|
for (let i = 4; i < 8; i++) bits += R[Number(code[i])];
|
||||||
|
bits += "101";
|
||||||
|
return { code, bits };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bitmuster -> Helligkeitszeile, mit Rand, Skalierung und Rauschen. */
|
||||||
|
function toLine(bits, { scale = 3, quiet = null, noise = 0, blur = 0 } = {}) {
|
||||||
|
// Ruhezone in MODULEN, nicht in Pixeln: Die Norm verlangt 7 bis 11
|
||||||
|
// Module. Ein fester Pixelwert wäre bei großer Skalierung zu schmal
|
||||||
|
// und würde einen Fehler vortäuschen, den es in der Wirklichkeit
|
||||||
|
// nicht gibt.
|
||||||
|
const margin = quiet ?? scale * 8;
|
||||||
|
const px = [];
|
||||||
|
for (let i = 0; i < margin; i++) px.push(255);
|
||||||
|
for (const b of bits) {
|
||||||
|
for (let s = 0; s < scale; s++) px.push(b === "1" ? 20 : 235);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < margin; i++) px.push(255);
|
||||||
|
|
||||||
|
let out = px;
|
||||||
|
if (blur) {
|
||||||
|
out = px.map((_, i) => {
|
||||||
|
let sum = 0, n = 0;
|
||||||
|
for (let k = -blur; k <= blur; k++) {
|
||||||
|
if (px[i + k] !== undefined) { sum += px[i + k]; n++; }
|
||||||
|
}
|
||||||
|
return sum / n;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (noise) {
|
||||||
|
out = out.map((v) => Math.max(0, Math.min(255, v + (rnd() - 0.5) * noise)));
|
||||||
|
}
|
||||||
|
return Uint8Array.from(out.map(Math.round));
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineToImageData(line, height = 30) {
|
||||||
|
const width = line.length;
|
||||||
|
const data = new Uint8ClampedArray(width * height * 4);
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const p = (y * width + x) * 4;
|
||||||
|
data[p] = data[p + 1] = data[p + 2] = line[x];
|
||||||
|
data[p + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { width, height, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
const check = (name, ok, extra = "") => {
|
||||||
|
if (ok) { pass++; console.log(` ok ${name}`); }
|
||||||
|
else { fail++; console.log(` FEHL ${name} ${extra}`); }
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("--- Prüfziffer ---");
|
||||||
|
check("EAN-13 gültig", checksumValid("4001234567890") === (checkDigit("400123456789") === 0));
|
||||||
|
check("bekannte EAN-13", checksumValid("4006381333931"));
|
||||||
|
check("verfälschte EAN-13 abgelehnt", !checksumValid("4006381333932"));
|
||||||
|
check("EAN-8", checksumValid("96385074"));
|
||||||
|
|
||||||
|
console.log("\n--- Sauberes Signal ---");
|
||||||
|
for (const body of ["400123456789", "978020137", "012345678", "590123412345".slice(0,12)]) {
|
||||||
|
const b12 = body.padEnd(12, "0").slice(0, 12);
|
||||||
|
const { code, bits } = encodeEAN13(b12);
|
||||||
|
check(`EAN-13 ${code}`, decodeLine(toLine(bits)) === code, `-> ${decodeLine(toLine(bits))}`);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const { code, bits } = encodeEAN8("9638507");
|
||||||
|
check(`EAN-8 ${code}`, decodeLine(toLine(bits)) === code, `-> ${decodeLine(toLine(bits))}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n--- Umgedreht gehalten ---");
|
||||||
|
{
|
||||||
|
const { code, bits } = encodeEAN13("400123456789");
|
||||||
|
const line = toLine(bits);
|
||||||
|
check(`rückwärts ${code}`, decodeLine(Uint8Array.from([...line].reverse())) === code);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n--- Unscharf und verrauscht ---");
|
||||||
|
for (const [blur, noise, scale] of [[1, 20, 3], [2, 30, 4], [1, 40, 5], [3, 25, 6]]) {
|
||||||
|
const { code, bits } = encodeEAN13("426000123456");
|
||||||
|
const got = decodeLine(toLine(bits, { blur, noise, scale }));
|
||||||
|
check(`blur=${blur} noise=${noise} scale=${scale}`, got === code, `-> ${got}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n--- Knappe Ruhezone ---");
|
||||||
|
{
|
||||||
|
const { code, bits } = encodeEAN13("426000123456");
|
||||||
|
// 6 Module Rand: unterschreitet die Norm, wird aber noch gelesen
|
||||||
|
const ok6 = decodeLine(toLine(bits, { scale: 4, quiet: 24 }));
|
||||||
|
check("6 Module Rand wird gelesen", ok6 === code, `-> ${ok6}`);
|
||||||
|
// 2 Module Rand: wird abgelehnt. Das ist der Preis dafür, dass
|
||||||
|
// Rauschen keine Fehltreffer erzeugt.
|
||||||
|
const ok2 = decodeLine(toLine(bits, { scale: 4, quiet: 8 }));
|
||||||
|
check("2 Module Rand wird abgelehnt", ok2 === null, `-> ${ok2}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n--- Ganzes Bild ---");
|
||||||
|
{
|
||||||
|
const { code, bits } = encodeEAN13("400123456789");
|
||||||
|
const line = toLine(bits, { blur: 1, noise: 25, scale: 4 });
|
||||||
|
check("decodeImage", decodeImage(lineToImageData(line)) === code);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n--- Kein Strichcode im Bild ---");
|
||||||
|
{
|
||||||
|
const flat = Uint8Array.from({ length: 400 }, () => 200);
|
||||||
|
check("gleichmäßige Fläche -> null", decodeLine(flat) === null);
|
||||||
|
|
||||||
|
// Fehltreffer statistisch messen statt einmal zu würfeln.
|
||||||
|
//
|
||||||
|
// Eine einzelne Zeile kann in reinem Rauschen zufällig eine gültige
|
||||||
|
// Kombination ergeben - Startzeichen, zwölf Ziffern, passende
|
||||||
|
// Prüfziffer. Die Ruhezonenprüfung drückt das stark, ganz ausschließen
|
||||||
|
// lässt es sich nicht.
|
||||||
|
//
|
||||||
|
// Entscheidend ist die Ebene darüber: decodeImage verlangt, dass ZWEI
|
||||||
|
// Abtastlinien dasselbe Ergebnis liefern. Genau das ist im Sucher der
|
||||||
|
// Anwendung im Einsatz.
|
||||||
|
let lineHits = 0;
|
||||||
|
const LINE_TRIES = 500;
|
||||||
|
for (let i = 0; i < LINE_TRIES; i++) {
|
||||||
|
reseed(0x1000 + i);
|
||||||
|
const noise = Uint8Array.from({ length: 400 }, () => (rnd() * 255) | 0);
|
||||||
|
if (decodeLine(noise)) lineHits++;
|
||||||
|
}
|
||||||
|
console.log(` info Einzelzeile auf Rauschen: ${lineHits}/${LINE_TRIES} Fehltreffer`);
|
||||||
|
check(`Einzelzeile: Fehltrefferquote unter 2 %`, lineHits / LINE_TRIES < 0.02,
|
||||||
|
`(${lineHits}/${LINE_TRIES})`);
|
||||||
|
|
||||||
|
let imageHits = 0;
|
||||||
|
const IMAGE_TRIES = 200;
|
||||||
|
for (let i = 0; i < IMAGE_TRIES; i++) {
|
||||||
|
reseed(0x9000 + i);
|
||||||
|
const noise = Uint8Array.from({ length: 400 }, () => (rnd() * 255) | 0);
|
||||||
|
if (decodeImage(lineToImageData(noise))) imageHits++;
|
||||||
|
}
|
||||||
|
console.log(` info Ganzes Bild auf Rauschen: ${imageHits}/${IMAGE_TRIES} Fehltreffer`);
|
||||||
|
check("Ganzes Bild: keine Fehltreffer auf Rauschen", imageHits === 0,
|
||||||
|
`(${imageHits}/${IMAGE_TRIES})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${pass} bestanden, ${fail} fehlgeschlagen`);
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
71
tools/vapid-keys.py
Normal file
71
tools/vapid-keys.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Erzeugt das VAPID-Schlüsselpaar für Web Push.
|
||||||
|
|
||||||
|
python3 tools/vapid-keys.py
|
||||||
|
|
||||||
|
VAPID ("Voluntary Application Server Identification") ist die Art, wie
|
||||||
|
sich dieser Server gegenüber den Push-Diensten der Browserhersteller
|
||||||
|
ausweist. Das Schlüsselpaar gehört dir - es ist kein Konto bei Google
|
||||||
|
oder Apple nötig und es fließen keine Daten an Dritte, außer der
|
||||||
|
verschlüsselten Nachricht selbst.
|
||||||
|
|
||||||
|
Der öffentliche Schlüssel geht an den Browser, der private bleibt auf dem
|
||||||
|
Server. Wird der private Schlüssel getauscht, verlieren alle bestehenden
|
||||||
|
Anmeldungen ihre Gültigkeit und müssen erneuert werden - deshalb einmal
|
||||||
|
erzeugen und dann in Ruhe lassen.
|
||||||
|
|
||||||
|
Läuft ohne Zusatzpakete: `cryptography` ist bereits eine Abhängigkeit
|
||||||
|
des Backends.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ec
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
except ImportError:
|
||||||
|
print(
|
||||||
|
"Das Paket 'cryptography' fehlt. Entweder im Container ausführen:\n"
|
||||||
|
" docker compose exec api python3 tools/vapid-keys.py\n"
|
||||||
|
"oder lokal installieren: pip install cryptography",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def b64(data: bytes) -> str:
|
||||||
|
"""base64url ohne Auffüllzeichen - so verlangen es Web Push und JWT."""
|
||||||
|
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
key = ec.generate_private_key(ec.SECP256R1())
|
||||||
|
|
||||||
|
# Privater Schlüssel: die 32 Byte des Skalars, nicht das PEM-Format.
|
||||||
|
# py_vapid nimmt diese Form direkt an.
|
||||||
|
private_value = key.private_numbers().private_value
|
||||||
|
private_bytes = private_value.to_bytes(32, "big")
|
||||||
|
|
||||||
|
# Öffentlicher Schlüssel: unkomprimierter Punkt, 65 Byte (0x04 + X + Y).
|
||||||
|
# Genau diese Form erwartet der Browser als applicationServerKey.
|
||||||
|
public_bytes = key.public_key().public_bytes(
|
||||||
|
encoding=serialization.Encoding.X962,
|
||||||
|
format=serialization.PublicFormat.UncompressedPoint,
|
||||||
|
)
|
||||||
|
|
||||||
|
print("# In die .env übernehmen. Vorhandene Zeilen ERSETZEN, nicht")
|
||||||
|
print("# ergänzen: Bei doppelten Einträgen nimmt Docker Compose den")
|
||||||
|
print("# letzten - und das ist oft die leere Vorlagenzeile.\n")
|
||||||
|
print(f"VAPID_PRIVATE_KEY={b64(private_bytes)}")
|
||||||
|
print(f"VAPID_PUBLIC_KEY={b64(public_bytes)}")
|
||||||
|
print("# Kontaktadresse für die Push-Dienste - bei Problemen melden sie sich dort.")
|
||||||
|
print("VAPID_SUBJECT=mailto:admin@example.de")
|
||||||
|
print()
|
||||||
|
print("# Der private Schlüssel gehört NICHT ins Versionsverwaltungssystem.")
|
||||||
|
print("# Ein Wechsel entwertet alle bestehenden Anmeldungen.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
12
web/Dockerfile
Normal file
12
web/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Konfiguration ausserhalb des Web-Roots
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY proxy_common.conf /etc/nginx/proxy_common.conf
|
||||||
|
COPY security_headers.conf /etc/nginx/security_headers.conf
|
||||||
|
|
||||||
|
# Statische Dateien. Ab Phase 3 kommt hier das gebaute Vite-Ergebnis
|
||||||
|
# aus einer vorgelagerten Build-Stufe hinein.
|
||||||
|
COPY html/ /usr/share/nginx/html/
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
1245
web/html/app.css
Normal file
1245
web/html/app.css
Normal file
File diff suppressed because it is too large
Load Diff
BIN
web/html/icons/apple-touch-icon.png
Normal file
BIN
web/html/icons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.7 KiB |
BIN
web/html/icons/favicon.ico
Normal file
BIN
web/html/icons/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
BIN
web/html/icons/icon-192.png
Normal file
BIN
web/html/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
web/html/icons/icon-512.png
Normal file
BIN
web/html/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
web/html/icons/icon-maskable-512.png
Normal file
BIN
web/html/icons/icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
13
web/html/icons/icon.svg
Normal file
13
web/html/icons/icon.svg
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Einkaufsliste">
|
||||||
|
<rect width="512" height="512" rx="96" fill="#2f6f4e"/>
|
||||||
|
<!-- Korb -->
|
||||||
|
<path d="M112 200h288l-30 200a28 28 0 0 1-28 24H170a28 28 0 0 1-28-24z"
|
||||||
|
fill="none" stroke="#ffffff" stroke-width="26" stroke-linejoin="round"/>
|
||||||
|
<!-- Henkel -->
|
||||||
|
<path d="M188 200v-24a68 68 0 0 1 136 0v24"
|
||||||
|
fill="none" stroke="#ffffff" stroke-width="26" stroke-linecap="round"/>
|
||||||
|
<!-- Haken -->
|
||||||
|
<path d="M198 300l38 40 82-88"
|
||||||
|
fill="none" stroke="#ffffff" stroke-width="30"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 643 B |
33
web/html/index.html
Normal file
33
web/html/index.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<meta name="color-scheme" content="light dark">
|
||||||
|
<meta name="theme-color" content="#2f6f4e">
|
||||||
|
<title>Einkaufsliste</title>
|
||||||
|
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest">
|
||||||
|
<link rel="icon" href="/icons/favicon.ico" sizes="any">
|
||||||
|
<link rel="icon" href="/icons/icon.svg" type="image/svg+xml">
|
||||||
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
|
||||||
|
<link rel="stylesheet" href="/app.css">
|
||||||
|
|
||||||
|
<!-- Ohne diese Zeile öffnet iOS die App vom Startbildschirm im Safari-Rahmen -->
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<!-- Wird beim Start aus /api/config überschrieben. -->
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Einkaufsliste">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main id="app">
|
||||||
|
<p class="loading">Wird geladen …</p>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<noscript>
|
||||||
|
<p class="error">Diese App benötigt JavaScript.</p>
|
||||||
|
</noscript>
|
||||||
|
|
||||||
|
<script type="module" src="/js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
85
web/html/js/api.js
Normal file
85
web/html/js/api.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// Kommunikation mit der API. Einziger Ort, an dem fetch() aufgerufen wird.
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(status, detail) {
|
||||||
|
super(detail);
|
||||||
|
this.name = "ApiError";
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OfflineError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("Keine Verbindung zum Server.");
|
||||||
|
this.name = "OfflineError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Das CSRF-Token steht in einem lesbaren Cookie. Das Session-Cookie ist
|
||||||
|
* HttpOnly und für JavaScript unsichtbar - genau so soll es sein. */
|
||||||
|
function csrfToken() {
|
||||||
|
const hit = document.cookie
|
||||||
|
.split(";")
|
||||||
|
.map((c) => c.trim())
|
||||||
|
.find((c) => c.startsWith("ea_csrf="));
|
||||||
|
return hit ? decodeURIComponent(hit.slice("ea_csrf=".length)) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Übersetzt die Fehlerform von FastAPI in einen Satz für Menschen.
|
||||||
|
* Bei Validierungsfehlern ist "detail" eine Liste, keine Zeichenkette. */
|
||||||
|
function describe(status, data) {
|
||||||
|
const detail = data && data.detail;
|
||||||
|
if (typeof detail === "string") return detail;
|
||||||
|
if (Array.isArray(detail) && detail.length) {
|
||||||
|
const first = detail[0];
|
||||||
|
const field = Array.isArray(first.loc) ? first.loc[first.loc.length - 1] : "";
|
||||||
|
return field ? `Ungültige Eingabe bei "${field}": ${first.msg}` : first.msg;
|
||||||
|
}
|
||||||
|
if (status === 401) return "Nicht angemeldet.";
|
||||||
|
if (status === 429) return "Zu viele Versuche. Bitte einen Moment warten.";
|
||||||
|
if (status >= 500) return "Der Server hat einen Fehler gemeldet.";
|
||||||
|
return `Unerwarteter Fehler (HTTP ${status}).`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function api(path, { method = "GET", body } = {}) {
|
||||||
|
const headers = {};
|
||||||
|
if (body !== undefined) headers["Content-Type"] = "application/json";
|
||||||
|
if (method !== "GET" && method !== "HEAD") headers["X-CSRF-Token"] = csrfToken();
|
||||||
|
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch(path, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
credentials: "same-origin",
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Netzwerkfehler von HTTP-Fehlern trennen: In Phase 4 entscheidet
|
||||||
|
// genau das, ob eine Änderung in die Outbox wandert.
|
||||||
|
throw new OfflineError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
let data = null;
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
throw new ApiError(
|
||||||
|
response.status,
|
||||||
|
`Unerwartete Antwort vom Server (HTTP ${response.status}).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) throw new ApiError(response.status, describe(response.status, data));
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const get = (path) => api(path);
|
||||||
|
export const post = (path, body) => api(path, { method: "POST", body });
|
||||||
|
export const patch = (path, body) => api(path, { method: "PATCH", body });
|
||||||
|
export const put = (path, body) => api(path, { method: "PUT", body });
|
||||||
|
export const del = (path) => api(path, { method: "DELETE" });
|
||||||
285
web/html/js/app.js
Normal file
285
web/html/js/app.js
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
// Einstiegspunkt: Routing zwischen den Ansichten und Start des
|
||||||
|
// Service Workers.
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
import { ApiError, get } from "./api.js";
|
||||||
|
import { clear, el, mount } from "./dom.js";
|
||||||
|
import { set, state } from "./store.js";
|
||||||
|
import {
|
||||||
|
changePasswordView,
|
||||||
|
forgotView,
|
||||||
|
loginView,
|
||||||
|
logout,
|
||||||
|
registerView,
|
||||||
|
resetView,
|
||||||
|
} from "./views/auth.js";
|
||||||
|
import { listDetailView } from "./views/list-detail.js";
|
||||||
|
import { listsView } from "./views/lists.js";
|
||||||
|
import { articlesView } from "./views/articles.js";
|
||||||
|
import { pricesView } from "./views/prices.js";
|
||||||
|
import { adminView } from "./views/admin.js";
|
||||||
|
import { settingsView } from "./views/settings.js";
|
||||||
|
import { welcomeView } from "./views/welcome.js";
|
||||||
|
import { manageView } from "./views/manage.js";
|
||||||
|
import * as db from "./db.js";
|
||||||
|
import { installTriggers, stopWatching } from "./sync.js";
|
||||||
|
import { publicView } from "./views/public.js";
|
||||||
|
import { acceptInviteView, shareView } from "./views/share.js";
|
||||||
|
|
||||||
|
const root = document.getElementById("app");
|
||||||
|
|
||||||
|
// Routen als Zeichenkette im Fragment: "#/lists/<id>". Kein History-API-
|
||||||
|
// Routing, weil der Service Worker dann jede Tiefe abfangen müsste.
|
||||||
|
function currentRoute() {
|
||||||
|
const hash = location.hash.replace(/^#/, "");
|
||||||
|
const parts = hash.split("/").filter(Boolean);
|
||||||
|
if (parts[0] === "lists" && parts[1] && parts[2] === "manage") {
|
||||||
|
return { name: "manage", listId: parts[1] };
|
||||||
|
}
|
||||||
|
if (parts[0] === "lists" && parts[1] && parts[2] === "share") {
|
||||||
|
return { name: "share", listId: parts[1] };
|
||||||
|
}
|
||||||
|
if (parts[0] === "lists" && parts[1] && parts[2] === "articles") {
|
||||||
|
return { name: "articles", listId: parts[1] };
|
||||||
|
}
|
||||||
|
if (parts[0] === "lists" && parts[1] && parts[2] === "prices") {
|
||||||
|
return { name: "prices", listId: parts[1] };
|
||||||
|
}
|
||||||
|
if (parts[0] === "settings") {
|
||||||
|
return { name: "settings" };
|
||||||
|
}
|
||||||
|
if (parts[0] === "admin") {
|
||||||
|
return { name: "admin" };
|
||||||
|
}
|
||||||
|
if (parts[0] === "lists" && parts[1]) {
|
||||||
|
return { name: "list", listId: parts[1] };
|
||||||
|
}
|
||||||
|
return { name: "lists" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Beim Verlassen der Detailansicht den Ereigniskanal schließen -
|
||||||
|
* sonst bleibt pro besuchter Liste eine offene Verbindung stehen. */
|
||||||
|
function navigate(hash) {
|
||||||
|
stopWatching();
|
||||||
|
if (location.hash === hash) {
|
||||||
|
route();
|
||||||
|
} else {
|
||||||
|
location.hash = hash;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let authView = "login";
|
||||||
|
|
||||||
|
function goto(view) {
|
||||||
|
authView = view;
|
||||||
|
route();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signOut() {
|
||||||
|
await logout();
|
||||||
|
authView = "login";
|
||||||
|
location.hash = "";
|
||||||
|
route();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(err) {
|
||||||
|
mount(root,
|
||||||
|
el("section.card", {},
|
||||||
|
el("h1", {}, "Etwas ist schiefgelaufen"),
|
||||||
|
el("p.error", {}, err.message),
|
||||||
|
el("button.primary", { type: "button", onclick: () => route() }, "Erneut versuchen")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function route() {
|
||||||
|
// Öffentlicher Link: /s/<token>
|
||||||
|
// Muss VOR jeder Anmeldeprüfung stehen - der Empfänger hat kein Konto.
|
||||||
|
if (location.pathname.startsWith("/s/")) {
|
||||||
|
document.body.classList.add("public-mode");
|
||||||
|
await publicView(root, {
|
||||||
|
token: decodeURIComponent(location.pathname.slice(3)),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Link aus der Reset-Mail zeigt auf /reset?token=...
|
||||||
|
if (location.pathname === "/reset") {
|
||||||
|
resetView(root, { goto: (v) => { history.replaceState(null, "", "/"); goto(v); } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Willkommenslink aus der vom Administrator versendeten Einladung.
|
||||||
|
// Muss vor der Anmeldeprüfung stehen: Wer den Link öffnet, HAT noch
|
||||||
|
// kein Passwort und kann sich deshalb nicht anmelden.
|
||||||
|
if (location.pathname === "/willkommen") {
|
||||||
|
const token = new URLSearchParams(location.search).get("token");
|
||||||
|
if (token) {
|
||||||
|
await welcomeView(root, {
|
||||||
|
token,
|
||||||
|
toLogin: () => {
|
||||||
|
history.replaceState(null, "", "/");
|
||||||
|
route();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Einladungslink: /invite?token=...
|
||||||
|
// Wer nicht angemeldet ist, sieht zuerst das Anmeldeformular. Der Pfad
|
||||||
|
// bleibt dabei erhalten, sodass es nach der Anmeldung hier weitergeht.
|
||||||
|
const inviteToken =
|
||||||
|
location.pathname === "/invite"
|
||||||
|
? new URLSearchParams(location.search).get("token")
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!state.user) {
|
||||||
|
const views = { login: loginView, register: registerView, forgot: forgotView };
|
||||||
|
views[authView](root, {
|
||||||
|
goto,
|
||||||
|
onSignedIn: (user) => {
|
||||||
|
set({ user });
|
||||||
|
route();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.user.must_change_password) {
|
||||||
|
changePasswordView(root, { onDone: () => route(), onLogout: signOut });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inviteToken) {
|
||||||
|
await acceptInviteView(root, {
|
||||||
|
token: inviteToken,
|
||||||
|
onAccepted: () => {
|
||||||
|
history.replaceState(null, "", "/");
|
||||||
|
route();
|
||||||
|
},
|
||||||
|
toLists: () => {
|
||||||
|
history.replaceState(null, "", "/");
|
||||||
|
route();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = currentRoute();
|
||||||
|
if (r.name !== "list") stopWatching();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (r.name === "list") {
|
||||||
|
await listDetailView(root, {
|
||||||
|
listId: r.listId,
|
||||||
|
back: () => navigate(""),
|
||||||
|
manage: (id) => navigate(`#/lists/${id}/manage`),
|
||||||
|
share: (id) => navigate(`#/lists/${id}/share`),
|
||||||
|
articles: (id) => navigate(`#/lists/${id}/articles`),
|
||||||
|
prices: (id) => navigate(`#/lists/${id}/prices`),
|
||||||
|
});
|
||||||
|
} else if (r.name === "share") {
|
||||||
|
await shareView(root, {
|
||||||
|
listId: r.listId,
|
||||||
|
back: () => navigate(`#/lists/${r.listId}`),
|
||||||
|
});
|
||||||
|
} else if (r.name === "settings") {
|
||||||
|
await settingsView(root, {
|
||||||
|
back: () => navigate(""),
|
||||||
|
admin: () => navigate("#/admin"),
|
||||||
|
});
|
||||||
|
} else if (r.name === "admin") {
|
||||||
|
await adminView(root, { back: () => navigate("#/settings") });
|
||||||
|
} else if (r.name === "prices") {
|
||||||
|
await pricesView(root, {
|
||||||
|
listId: r.listId,
|
||||||
|
back: () => navigate(`#/lists/${r.listId}`),
|
||||||
|
});
|
||||||
|
} else if (r.name === "articles") {
|
||||||
|
await articlesView(root, {
|
||||||
|
listId: r.listId,
|
||||||
|
back: () => navigate(`#/lists/${r.listId}`),
|
||||||
|
});
|
||||||
|
} else if (r.name === "manage") {
|
||||||
|
await manageView(root, {
|
||||||
|
listId: r.listId,
|
||||||
|
back: () => navigate(`#/lists/${r.listId}`),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await listsView(root, {
|
||||||
|
openList: (id) => navigate(`#/lists/${id}`),
|
||||||
|
onLogout: signOut,
|
||||||
|
settings: () => navigate("#/settings"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 401) {
|
||||||
|
set({ user: null });
|
||||||
|
route();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fail(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("hashchange", route);
|
||||||
|
|
||||||
|
/** Anwendungsname aus der API holen und überall anwenden.
|
||||||
|
*
|
||||||
|
* Warum nicht beim Bauen einsetzen: Dann müsste der web-Container bei
|
||||||
|
* jeder Umbenennung neu gebaut werden. So genügt eine Änderung in der
|
||||||
|
* .env und ein Neustart des api-Containers.
|
||||||
|
*
|
||||||
|
* Der zuletzt bekannte Name liegt lokal, damit die App auch ohne
|
||||||
|
* Verbindung nicht namenlos startet. */
|
||||||
|
async function loadAppName() {
|
||||||
|
let config = null;
|
||||||
|
try {
|
||||||
|
config = await get("/api/config");
|
||||||
|
await db.cacheSet("config", config);
|
||||||
|
} catch {
|
||||||
|
config = await db.cacheGet("config").catch(() => null);
|
||||||
|
}
|
||||||
|
if (config?.app_name) set({ appName: config.app_name });
|
||||||
|
applyAppName(state.appName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAppName(name) {
|
||||||
|
document.title = name;
|
||||||
|
for (const node of document.querySelectorAll("[data-app-name]")) {
|
||||||
|
node.textContent = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
await loadAppName();
|
||||||
|
|
||||||
|
try {
|
||||||
|
set({ user: await get("/api/auth/me") });
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof ApiError) || err.status !== 401) {
|
||||||
|
// Offline oder Serverfehler: trotzdem das Anmeldeformular zeigen,
|
||||||
|
// damit die App nicht auf einem Ladehinweis stehenbleibt.
|
||||||
|
console.warn("Anmeldestatus nicht abrufbar:", err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Wartende Änderungen senden, sobald die Verbindung zurück ist, die
|
||||||
|
// App wieder im Vordergrund steht - oder ersatzweise alle 30 Sekunden.
|
||||||
|
installTriggers(
|
||||||
|
() => (currentRoute().name === "list" ? currentRoute().listId : null),
|
||||||
|
() => route()
|
||||||
|
);
|
||||||
|
|
||||||
|
await route();
|
||||||
|
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
try {
|
||||||
|
await navigator.serviceWorker.register("/sw.js", { scope: "/" });
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Service Worker nicht registriert:", err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start();
|
||||||
265
web/html/js/barcode.js
Normal file
265
web/html/js/barcode.js
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
// Decoder für EAN-13, EAN-8 und UPC-A. Ohne Fremdbibliothek.
|
||||||
|
//
|
||||||
|
// Warum selbst geschrieben: Die native BarcodeDetector-API gibt es nur in
|
||||||
|
// Chrome und auf Android. Die übliche Alternative wäre ZXing - eine
|
||||||
|
// 250-KB-Datei aus dem npm-Ökosystem. Für drei Strichcode-Formate, die
|
||||||
|
// alle nach demselben simplen Schema arbeiten, ist das unverhältnismäßig.
|
||||||
|
//
|
||||||
|
// Verfahren: Aus einem Kamerabild werden mehrere waagerechte Linien
|
||||||
|
// abgetastet, in Hell-Dunkel-Folgen zerlegt und gegen die Zifferntabellen
|
||||||
|
// gehalten. Ein Ergebnis gilt erst als sicher, wenn es zweimal
|
||||||
|
// unabhängig herauskommt und die Prüfziffer stimmt.
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// Jede Ziffer besteht aus 7 Modulen in 4 Streifen. Die Tabelle enthält
|
||||||
|
// die Streifenbreiten der L-Kodierung; daraus lassen sich die beiden
|
||||||
|
// anderen ableiten:
|
||||||
|
// L Streifenbreiten, beginnend mit hell (linke Hälfte)
|
||||||
|
// G dieselben Breiten rückwärts, hell (linke Hälfte)
|
||||||
|
// R dieselben Breiten, beginnend mit dunkel (rechte Hälfte)
|
||||||
|
const L_WIDTHS = [
|
||||||
|
[3, 2, 1, 1], [2, 2, 2, 1], [2, 1, 2, 2], [1, 4, 1, 1], [1, 1, 3, 2],
|
||||||
|
[1, 2, 3, 1], [1, 1, 1, 4], [1, 3, 1, 2], [1, 2, 1, 3], [3, 1, 1, 2],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Welche Abfolge von L und G in der linken Hälfte steht, verrät die
|
||||||
|
// erste Ziffer - sie ist selbst nicht als Streifen kodiert.
|
||||||
|
const PARITY = [
|
||||||
|
"LLLLLL", "LLGLGG", "LLGGLG", "LLGGGL", "LGLLGG",
|
||||||
|
"LGGLLG", "LGGGLL", "LGLGLG", "LGLGGL", "LGGLGL",
|
||||||
|
];
|
||||||
|
|
||||||
|
const MAX_DEVIATION = 0.42; // erlaubte Abweichung je Streifen, in Modulen
|
||||||
|
|
||||||
|
/** Vergleicht vier gemessene Streifen mit einem Sollmuster.
|
||||||
|
* Verglichen werden Verhältnisse, keine absoluten Breiten - dadurch
|
||||||
|
* spielt es keine Rolle, wie weit die Kamera vom Code entfernt ist. */
|
||||||
|
function patternDistance(counters, pattern) {
|
||||||
|
const total = counters[0] + counters[1] + counters[2] + counters[3];
|
||||||
|
if (total <= 0) return Infinity;
|
||||||
|
const unit = total / 7;
|
||||||
|
// Zu schmale Streifen deuten auf Rauschen statt auf einen Strichcode.
|
||||||
|
if (unit < 0.6) return Infinity;
|
||||||
|
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const deviation = Math.abs(counters[i] / unit - pattern[i]);
|
||||||
|
if (deviation > MAX_DEVIATION * 2) return Infinity;
|
||||||
|
sum += deviation;
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {{digit: number, code: "L"|"G"|"R"}|null} */
|
||||||
|
function matchDigit(counters, half) {
|
||||||
|
let best = null;
|
||||||
|
let bestDistance = MAX_DEVIATION * 4;
|
||||||
|
|
||||||
|
for (let digit = 0; digit < 10; digit++) {
|
||||||
|
if (half === "left") {
|
||||||
|
const dL = patternDistance(counters, L_WIDTHS[digit]);
|
||||||
|
if (dL < bestDistance) { bestDistance = dL; best = { digit, code: "L" }; }
|
||||||
|
|
||||||
|
const reversed = [...L_WIDTHS[digit]].reverse();
|
||||||
|
const dG = patternDistance(counters, reversed);
|
||||||
|
if (dG < bestDistance) { bestDistance = dG; best = { digit, code: "G" }; }
|
||||||
|
} else {
|
||||||
|
const dR = patternDistance(counters, L_WIDTHS[digit]);
|
||||||
|
if (dR < bestDistance) { bestDistance = dR; best = { digit, code: "R" }; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prüfziffer nach dem Modulo-10-Verfahren. Gilt für EAN-13, EAN-8
|
||||||
|
* und UPC-A gleichermaßen - nur die Gewichtung beginnt je nach Länge
|
||||||
|
* bei 1 oder 3. */
|
||||||
|
export function checksumValid(code) {
|
||||||
|
const digits = [...code].map(Number);
|
||||||
|
if (digits.some(Number.isNaN)) return false;
|
||||||
|
|
||||||
|
const check = digits.pop();
|
||||||
|
let sum = 0;
|
||||||
|
// Von rechts nach links abwechselnd ×3 und ×1.
|
||||||
|
for (let i = digits.length - 1, weight = 3; i >= 0; i--, weight = 4 - weight) {
|
||||||
|
sum += digits[i] * weight;
|
||||||
|
}
|
||||||
|
return (10 - (sum % 10)) % 10 === check;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zerlegt eine Helligkeitszeile in Streifenlängen.
|
||||||
|
* Der Schwellwert wird je Zeile aus deren eigenem Hell-Dunkel-Umfang
|
||||||
|
* gebildet - so stört ein Schatten über dem halben Bild nicht. */
|
||||||
|
function toRuns(line) {
|
||||||
|
let min = 255;
|
||||||
|
let max = 0;
|
||||||
|
for (const value of line) {
|
||||||
|
if (value < min) min = value;
|
||||||
|
if (value > max) max = value;
|
||||||
|
}
|
||||||
|
// Zu wenig Kontrast: da ist kein Strichcode.
|
||||||
|
if (max - min < 40) return null;
|
||||||
|
|
||||||
|
const threshold = (min + max) / 2;
|
||||||
|
const runs = [];
|
||||||
|
let dark = line[0] < threshold;
|
||||||
|
let length = 0;
|
||||||
|
|
||||||
|
for (const value of line) {
|
||||||
|
const isDark = value < threshold;
|
||||||
|
if (isDark === dark) {
|
||||||
|
length++;
|
||||||
|
} else {
|
||||||
|
runs.push({ dark, length });
|
||||||
|
dark = isDark;
|
||||||
|
length = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runs.push({ dark, length });
|
||||||
|
return runs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vor dem Startzeichen verlangt die Norm eine helle Ruhezone von elf
|
||||||
|
// Modulen. Diese Prüfung ist der wirksamste Schutz gegen Fehltreffer:
|
||||||
|
// In zufälligem Rauschen gibt es keine so breiten hellen Flächen, in
|
||||||
|
// einem echten Kamerabild dagegen immer. Konservativ angesetzt, weil
|
||||||
|
// der Rand bei knapper Bildausschnittwahl auch mal kürzer ausfällt.
|
||||||
|
const MIN_QUIET_ZONE = 3.5;
|
||||||
|
|
||||||
|
/** Versucht ab einem dunklen Streifen einen vollständigen Code zu lesen. */
|
||||||
|
function decodeAt(runs, start, digitCount) {
|
||||||
|
// digitCount: 13 -> je 6 Ziffern pro Hälfte, 8 -> je 4
|
||||||
|
const perHalf = digitCount === 13 ? 6 : 4;
|
||||||
|
|
||||||
|
// Startzeichen: drei Streifen von je einem Modul.
|
||||||
|
const guard = [runs[start], runs[start + 1], runs[start + 2]];
|
||||||
|
if (guard.some((r) => r === undefined)) return null;
|
||||||
|
const unit = (guard[0].length + guard[1].length + guard[2].length) / 3;
|
||||||
|
if (unit < 0.7) return null;
|
||||||
|
for (const r of guard) {
|
||||||
|
if (Math.abs(r.length / unit - 1) > MAX_DEVIATION) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ruhezone davor
|
||||||
|
const before = runs[start - 1];
|
||||||
|
if (start > 0 && (before.dark || before.length < unit * MIN_QUIET_ZONE)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = start + 3;
|
||||||
|
const digits = [];
|
||||||
|
let parity = "";
|
||||||
|
|
||||||
|
for (let i = 0; i < perHalf; i++) {
|
||||||
|
const counters = [
|
||||||
|
runs[index]?.length, runs[index + 1]?.length,
|
||||||
|
runs[index + 2]?.length, runs[index + 3]?.length,
|
||||||
|
];
|
||||||
|
if (counters.some((c) => c === undefined)) return null;
|
||||||
|
|
||||||
|
const match = matchDigit(counters, "left");
|
||||||
|
if (!match) return null;
|
||||||
|
digits.push(match.digit);
|
||||||
|
parity += match.code === "R" ? "L" : match.code;
|
||||||
|
index += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trennzeichen in der Mitte: fünf Streifen von je einem Modul.
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const run = runs[index + i];
|
||||||
|
if (!run) return null;
|
||||||
|
if (Math.abs(run.length / unit - 1) > MAX_DEVIATION * 1.5) return null;
|
||||||
|
}
|
||||||
|
index += 5;
|
||||||
|
|
||||||
|
for (let i = 0; i < perHalf; i++) {
|
||||||
|
const counters = [
|
||||||
|
runs[index]?.length, runs[index + 1]?.length,
|
||||||
|
runs[index + 2]?.length, runs[index + 3]?.length,
|
||||||
|
];
|
||||||
|
if (counters.some((c) => c === undefined)) return null;
|
||||||
|
|
||||||
|
const match = matchDigit(counters, "right");
|
||||||
|
if (!match) return null;
|
||||||
|
digits.push(match.digit);
|
||||||
|
index += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schlusszeichen
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const run = runs[index + i];
|
||||||
|
if (!run) return null;
|
||||||
|
if (Math.abs(run.length / unit - 1) > MAX_DEVIATION * 1.5) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ruhezone dahinter - oder das Ende der abgetasteten Zeile.
|
||||||
|
const after = runs[index + 3];
|
||||||
|
if (after && (after.dark || after.length < unit * MIN_QUIET_ZONE)) return null;
|
||||||
|
|
||||||
|
let code;
|
||||||
|
if (digitCount === 13) {
|
||||||
|
const first = PARITY.indexOf(parity);
|
||||||
|
if (first < 0) return null;
|
||||||
|
code = String(first) + digits.join("");
|
||||||
|
} else {
|
||||||
|
code = digits.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return checksumValid(code) ? code : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liest eine einzelne Helligkeitszeile. */
|
||||||
|
export function decodeLine(line) {
|
||||||
|
const runs = toRuns(line);
|
||||||
|
if (!runs || runs.length < 30) return null;
|
||||||
|
|
||||||
|
for (const direction of ["forward", "backward"]) {
|
||||||
|
// Ein auf dem Kopf stehender Code liest sich rückwärts genauso.
|
||||||
|
const sequence = direction === "forward" ? runs : [...runs].reverse();
|
||||||
|
|
||||||
|
for (let i = 0; i < sequence.length - 20; i++) {
|
||||||
|
if (!sequence[i].dark) continue;
|
||||||
|
for (const digitCount of [13, 8]) {
|
||||||
|
const code = decodeAt(sequence, i, digitCount);
|
||||||
|
if (code) return code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sucht in einem Bild nach einem Strichcode.
|
||||||
|
*
|
||||||
|
* @param {ImageData} imageData
|
||||||
|
* @param {number} lines Anzahl waagerechter Abtastlinien
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
export function decodeImage(imageData, lines = 21) {
|
||||||
|
const { width, height, data } = imageData;
|
||||||
|
const results = new Map();
|
||||||
|
|
||||||
|
for (let n = 0; n < lines; n++) {
|
||||||
|
// Linien über die mittleren zwei Drittel verteilen: Dort liegt der
|
||||||
|
// Code, wenn der Nutzer ihn im Sucherrahmen hält.
|
||||||
|
const y = Math.round(height * (1 / 6 + (2 / 3) * (n / (lines - 1 || 1))));
|
||||||
|
const row = new Uint8Array(width);
|
||||||
|
const offset = y * width * 4;
|
||||||
|
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const p = offset + x * 4;
|
||||||
|
// Grauwert nach Wahrnehmungsgewichtung.
|
||||||
|
row[x] = (data[p] * 77 + data[p + 1] * 150 + data[p + 2] * 29) >> 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = decodeLine(row);
|
||||||
|
if (!code) continue;
|
||||||
|
|
||||||
|
const count = (results.get(code) || 0) + 1;
|
||||||
|
// Erst wenn zwei Linien dasselbe ergeben, gilt es als sicher. Ein
|
||||||
|
// Einzeltreffer kann von Rauschen kommen.
|
||||||
|
if (count >= 2) return code;
|
||||||
|
results.set(code, count);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
151
web/html/js/db.js
Normal file
151
web/html/js/db.js
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
// Lokaler Speicher auf Basis von IndexedDB.
|
||||||
|
//
|
||||||
|
// Warum nicht localStorage: Der ist synchron (blockiert die Oberfläche),
|
||||||
|
// auf wenige Megabyte begrenzt und kennt keine Transaktionen. Für eine
|
||||||
|
// Warteschlange, die auch einen Absturz überleben muss, ist er ungeeignet.
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const DB_NAME = "einkaufsapp";
|
||||||
|
const DB_VERSION = 1;
|
||||||
|
|
||||||
|
const STORE_CACHE = "cache"; // Schlüssel -> zuletzt gesehener Serverstand
|
||||||
|
const STORE_OUTBOX = "outbox"; // noch nicht bestätigte Operationen
|
||||||
|
|
||||||
|
let dbPromise = null;
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
if (dbPromise) return dbPromise;
|
||||||
|
|
||||||
|
dbPromise = new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
|
|
||||||
|
request.onupgradeneeded = () => {
|
||||||
|
const db = request.result;
|
||||||
|
if (!db.objectStoreNames.contains(STORE_CACHE)) {
|
||||||
|
db.createObjectStore(STORE_CACHE);
|
||||||
|
}
|
||||||
|
if (!db.objectStoreNames.contains(STORE_OUTBOX)) {
|
||||||
|
const store = db.createObjectStore(STORE_OUTBOX, { keyPath: "op_id" });
|
||||||
|
// Reihenfolge je Liste: Operationen müssen in der Folge ankommen,
|
||||||
|
// in der sie entstanden sind.
|
||||||
|
store.createIndex("by_list_seq", ["list_id", "seq"]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
request.onblocked = () =>
|
||||||
|
reject(new Error("Datenbank blockiert – bitte andere Tabs schließen."));
|
||||||
|
});
|
||||||
|
|
||||||
|
return dbPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(storeName, mode, fn) {
|
||||||
|
return open().then((db) => new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(storeName, mode);
|
||||||
|
const store = tx.objectStore(storeName);
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = fn(store);
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tx.oncomplete = () => resolve(result && result.__req ? result.__req.result : result);
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
tx.onabort = () => reject(tx.error || new Error("Transaktion abgebrochen"));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrap(request) {
|
||||||
|
return { __req: request };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Zwischenspeicher
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function cacheGet(key) {
|
||||||
|
return run(STORE_CACHE, "readonly", (store) => wrap(store.get(key)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cacheSet(key, value) {
|
||||||
|
return run(STORE_CACHE, "readwrite", (store) => wrap(store.put(value, key)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cacheDelete(key) {
|
||||||
|
return run(STORE_CACHE, "readwrite", (store) => wrap(store.delete(key)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Outbox
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Zufällige, praktisch eindeutige Kennung. crypto.randomUUID gibt es
|
||||||
|
* nur in sicheren Kontexten (HTTPS oder localhost) - deshalb der
|
||||||
|
* Rückfallweg. */
|
||||||
|
export function newOpId() {
|
||||||
|
if (globalThis.crypto?.randomUUID) return crypto.randomUUID();
|
||||||
|
const bytes = new Uint8Array(16);
|
||||||
|
(globalThis.crypto || {}).getRandomValues?.(bytes);
|
||||||
|
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")
|
||||||
|
|| `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let seqCounter = Date.now();
|
||||||
|
|
||||||
|
export function enqueue(listId, kind, payload) {
|
||||||
|
const op = {
|
||||||
|
op_id: newOpId(),
|
||||||
|
list_id: listId,
|
||||||
|
kind,
|
||||||
|
payload,
|
||||||
|
seq: seqCounter++,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
attempts: 0,
|
||||||
|
};
|
||||||
|
return run(STORE_OUTBOX, "readwrite", (store) => {
|
||||||
|
store.add(op);
|
||||||
|
return op;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pending(listId) {
|
||||||
|
return run(STORE_OUTBOX, "readonly", (store) => {
|
||||||
|
const index = store.index("by_list_seq");
|
||||||
|
const range = IDBKeyRange.bound([listId, -Infinity], [listId, Infinity]);
|
||||||
|
return wrap(index.getAll(range));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pendingAll() {
|
||||||
|
return run(STORE_OUTBOX, "readonly", (store) => wrap(store.getAll()));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function remove(opIds) {
|
||||||
|
return run(STORE_OUTBOX, "readwrite", (store) => {
|
||||||
|
for (const id of opIds) store.delete(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bumpAttempts(opIds) {
|
||||||
|
return run(STORE_OUTBOX, "readwrite", (store) => {
|
||||||
|
for (const id of opIds) {
|
||||||
|
const request = store.get(id);
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const op = request.result;
|
||||||
|
if (op) {
|
||||||
|
op.attempts = (op.attempts || 0) + 1;
|
||||||
|
op.last_error_at = new Date().toISOString();
|
||||||
|
store.put(op);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearList(listId) {
|
||||||
|
const ops = await pending(listId);
|
||||||
|
await remove(ops.map((o) => o.op_id));
|
||||||
|
}
|
||||||
104
web/html/js/dom.js
Normal file
104
web/html/js/dom.js
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
// Kleine DOM-Hilfen. Ersetzt kein Framework, spart nur Wiederholung.
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/** el("button.primary", { onclick: fn }, "Text")
|
||||||
|
* Setzt Text ausschließlich über textContent - dadurch kann kein
|
||||||
|
* Benutzereingabewert als HTML interpretiert werden. */
|
||||||
|
export function el(spec, props = {}, ...children) {
|
||||||
|
const [tag, ...classes] = String(spec).split(".");
|
||||||
|
const node = document.createElement(tag || "div");
|
||||||
|
if (classes.length) node.className = classes.join(" ");
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(props)) {
|
||||||
|
if (value === null || value === undefined || value === false) continue;
|
||||||
|
if (key.startsWith("on") && typeof value === "function") {
|
||||||
|
node.addEventListener(key.slice(2).toLowerCase(), value);
|
||||||
|
} else if (key === "dataset") {
|
||||||
|
Object.assign(node.dataset, value);
|
||||||
|
} else if (key in node && key !== "list") {
|
||||||
|
node[key] = value;
|
||||||
|
} else {
|
||||||
|
node.setAttribute(key, value === true ? "" : value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
append(node, children);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function append(node, children) {
|
||||||
|
for (const child of children.flat(Infinity)) {
|
||||||
|
if (child === null || child === undefined || child === false) continue;
|
||||||
|
node.append(child instanceof Node ? child : document.createTextNode(String(child)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clear(node) {
|
||||||
|
node.replaceChildren();
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ersetzt den Inhalt eines Knotens.
|
||||||
|
*
|
||||||
|
* Nicht clear(root).append(...) verwenden: Node.append() macht aus einem
|
||||||
|
* null die Zeichenkette "null" und zeigt sie an. Genau dafür filtert
|
||||||
|
* el() seine Kinder - mount() zieht denselben Schutz auf die Wurzel. */
|
||||||
|
export function mount(node, ...children) {
|
||||||
|
node.replaceChildren();
|
||||||
|
for (const child of children.flat(Infinity)) {
|
||||||
|
if (child === null || child === undefined || child === false) continue;
|
||||||
|
node.append(child instanceof Node ? child : document.createTextNode(String(child)));
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preis in Cent -> "1,29 €". Rechnen immer in Cent, formatieren erst hier. */
|
||||||
|
export function euro(cents) {
|
||||||
|
return new Intl.NumberFormat("de-DE", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "EUR",
|
||||||
|
}).format((cents || 0) / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "1,29" oder "1.29" -> 129. Gibt null zurück, wenn nichts Sinnvolles drinsteht. */
|
||||||
|
export function parseCents(text) {
|
||||||
|
const cleaned = String(text).trim().replace(",", ".");
|
||||||
|
if (!cleaned) return null;
|
||||||
|
const value = Number(cleaned);
|
||||||
|
if (!Number.isFinite(value) || value < 0) return null;
|
||||||
|
return Math.round(value * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseQuantity(text) {
|
||||||
|
const cleaned = String(text).trim().replace(",", ".");
|
||||||
|
if (!cleaned) return null;
|
||||||
|
const value = Number(cleaned);
|
||||||
|
return Number.isFinite(value) && value >= 0 ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stückzahl: ganze Zahl ab 1. Leer oder unsinnig ergibt 1 - ein Eintrag
|
||||||
|
* ohne Stückzahl wäre unvollständig. */
|
||||||
|
export function parseCount(text) {
|
||||||
|
const value = Number(String(text).trim().replace(",", "."));
|
||||||
|
if (!Number.isFinite(value) || value < 1) return 1;
|
||||||
|
return Math.min(999, Math.round(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur das Gebinde, ohne Stückzahl.
|
||||||
|
*
|
||||||
|
* Wird in der Listenzeile gebraucht, weil die Stückzahl dort als
|
||||||
|
* eigenes Feld links neben dem Namen steht - beim Einkaufen soll sie
|
||||||
|
* ins Auge springen und nicht im Kleingedruckten stehen. */
|
||||||
|
export function formatPack(packSize, packUnit) {
|
||||||
|
if (!packSize) return "";
|
||||||
|
return `${formatQuantity(packSize)}${packUnit ? " " + packUnit : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Menge aus der API kommt als Zeichenkette ("2.000"), damit unterwegs
|
||||||
|
* keine Nachkommastellen verloren gehen. Für die Anzeige aufräumen. */
|
||||||
|
export function formatQuantity(value) {
|
||||||
|
if (value === null || value === undefined) return "";
|
||||||
|
const num = Number(value);
|
||||||
|
if (!Number.isFinite(num)) return String(value);
|
||||||
|
return num.toLocaleString("de-DE", { maximumFractionDigits: 3 });
|
||||||
|
}
|
||||||
165
web/html/js/push.js
Normal file
165
web/html/js/push.js
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
// An- und Abmeldung für Push-Benachrichtigungen.
|
||||||
|
//
|
||||||
|
// Ablauf: Der Browser holt sich beim Push-Dienst seines Herstellers einen
|
||||||
|
// Endpunkt, den er zusammen mit Schlüsselmaterial an unseren Server
|
||||||
|
// meldet. Der Server kann darüber verschlüsselte Nachrichten schicken -
|
||||||
|
// lesen kann sie nur dieses Gerät, auch der Push-Dienst nicht.
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
import { get, post } from "./api.js";
|
||||||
|
|
||||||
|
/** Der öffentliche VAPID-Schlüssel kommt als base64url ohne
|
||||||
|
* Auffüllzeichen; die Browser-API will ein Uint8Array. */
|
||||||
|
function decodeKey(base64url) {
|
||||||
|
const padded = base64url.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const raw = atob(padded + "=".repeat((4 - (padded.length % 4)) % 4));
|
||||||
|
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kurze Gerätebezeichnung, damit man mehrere Anmeldungen unterscheiden
|
||||||
|
* kann. Bewusst grob - der vollständige User-Agent wäre ein
|
||||||
|
* Wiedererkennungsmerkmal und wird nicht gebraucht. */
|
||||||
|
function deviceLabel() {
|
||||||
|
const ua = navigator.userAgent;
|
||||||
|
const system =
|
||||||
|
/Android/i.test(ua) ? "Android"
|
||||||
|
: /iPhone|iPad|iPod/i.test(ua) ? "iPhone/iPad"
|
||||||
|
: /Macintosh/i.test(ua) ? "Mac"
|
||||||
|
: /Windows/i.test(ua) ? "Windows"
|
||||||
|
: /Linux/i.test(ua) ? "Linux"
|
||||||
|
: "Gerät";
|
||||||
|
const browser =
|
||||||
|
/Firefox\//i.test(ua) ? "Firefox"
|
||||||
|
: /Edg\//i.test(ua) ? "Edge"
|
||||||
|
: /Chrome\//i.test(ua) ? "Chrome"
|
||||||
|
: /Safari\//i.test(ua) ? "Safari"
|
||||||
|
: "Browser";
|
||||||
|
return `${browser} auf ${system}`.slice(0, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Läuft die App als installierte PWA? Auf iOS ist das die
|
||||||
|
* Voraussetzung dafür, dass Push überhaupt funktioniert. */
|
||||||
|
export function isInstalled() {
|
||||||
|
return (
|
||||||
|
window.matchMedia?.("(display-mode: standalone)")?.matches === true ||
|
||||||
|
navigator.standalone === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isApple() {
|
||||||
|
return /iPhone|iPad|iPod/i.test(navigator.userAgent) ||
|
||||||
|
(/Macintosh/i.test(navigator.userAgent) && navigator.maxTouchPoints > 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Was der Browser hier grundsätzlich kann. */
|
||||||
|
export function supported() {
|
||||||
|
return (
|
||||||
|
"serviceWorker" in navigator &&
|
||||||
|
"PushManager" in window &&
|
||||||
|
"Notification" in window &&
|
||||||
|
window.isSecureContext
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktueller Zustand.
|
||||||
|
* @returns {Promise<{
|
||||||
|
* supported: boolean, serverEnabled: boolean, permission: string,
|
||||||
|
* subscribed: boolean, needsInstall: boolean, throttleHours: number
|
||||||
|
* }>}
|
||||||
|
*/
|
||||||
|
export async function status() {
|
||||||
|
const base = {
|
||||||
|
supported: supported(),
|
||||||
|
serverEnabled: false,
|
||||||
|
permission: "Notification" in window ? Notification.permission : "unsupported",
|
||||||
|
subscribed: false,
|
||||||
|
// iOS lässt Push nur in der installierten App zu.
|
||||||
|
needsInstall: isApple() && !isInstalled(),
|
||||||
|
throttleHours: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await get("/api/push/config");
|
||||||
|
base.serverEnabled = config.enabled;
|
||||||
|
base.throttleHours = config.throttle_hours;
|
||||||
|
} catch {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!base.supported) return base;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
base.subscribed = Boolean(await registration.pushManager.getSubscription());
|
||||||
|
} catch {
|
||||||
|
// Kein Service Worker bereit - dann eben nicht angemeldet.
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fragt die Erlaubnis ab und meldet das Gerät an.
|
||||||
|
* @returns {Promise<{ok: boolean, reason?: string}>} */
|
||||||
|
export async function subscribe() {
|
||||||
|
if (!supported()) {
|
||||||
|
return { ok: false, reason: "Dieser Browser unterstützt keine Benachrichtigungen." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await get("/api/push/config");
|
||||||
|
if (!config.enabled || !config.public_key) {
|
||||||
|
return { ok: false, reason: "Auf diesem Server sind Benachrichtigungen nicht eingerichtet." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
if (permission !== "granted") {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: permission === "denied"
|
||||||
|
? "Benachrichtigungen wurden für diese Seite abgelehnt. Das lässt sich nur in den Browsereinstellungen wieder ändern."
|
||||||
|
: "Keine Erlaubnis erteilt.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
let subscription = await registration.pushManager.getSubscription();
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
subscription = await registration.pushManager.subscribe({
|
||||||
|
// Pflicht bei allen aktuellen Browsern: Nachrichten ohne
|
||||||
|
// sichtbare Meldung sind nicht erlaubt.
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: decodeKey(config.public_key),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = subscription.toJSON();
|
||||||
|
await post("/api/push/subscribe", {
|
||||||
|
endpoint: json.endpoint,
|
||||||
|
keys: { p256dh: json.keys.p256dh, auth: json.keys.auth },
|
||||||
|
label: deviceLabel(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Meldet dieses Gerät ab - lokal und auf dem Server. */
|
||||||
|
export async function unsubscribe() {
|
||||||
|
if (!supported()) return;
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
const subscription = await registration.pushManager.getSubscription();
|
||||||
|
if (!subscription) return;
|
||||||
|
|
||||||
|
const endpoint = subscription.endpoint;
|
||||||
|
// Erst lokal abmelden: Schlägt der Server fehl, sollen trotzdem keine
|
||||||
|
// Meldungen mehr ankommen.
|
||||||
|
await subscription.unsubscribe().catch(() => {});
|
||||||
|
await post("/api/push/unsubscribe", { endpoint }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendTest() {
|
||||||
|
return post("/api/push/test");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listDevices() {
|
||||||
|
return get("/api/push/subscriptions");
|
||||||
|
}
|
||||||
130
web/html/js/sortable.js
Normal file
130
web/html/js/sortable.js
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
// Zeilen per Anfasser umsortieren, ohne Fremdbibliothek.
|
||||||
|
//
|
||||||
|
// Umgesetzt mit Pointer Events statt der HTML5-Drag-and-Drop-API: Letztere
|
||||||
|
// funktioniert auf Touchgeräten praktisch nicht, und die App wird
|
||||||
|
// überwiegend am Telefon bedient.
|
||||||
|
//
|
||||||
|
// Verfahren: Das gezogene Element folgt dem Finger per transform. Sobald
|
||||||
|
// es den Mittelpunkt eines Nachbarn überschreitet, wird es im DOM davor
|
||||||
|
// oder dahinter einsortiert und der Bezugspunkt zurückgesetzt - dadurch
|
||||||
|
// springt nichts.
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const DRAG_THRESHOLD = 4; // Pixel, bevor aus einem Tippen ein Ziehen wird
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {HTMLElement} container Elternknoten der sortierbaren Zeilen
|
||||||
|
* @param {object} options
|
||||||
|
* @param {string} options.handleSelector Auswahl für den Anfasser
|
||||||
|
* @param {string} options.itemSelector Auswahl für eine Zeile
|
||||||
|
* @param {(ids: string[]) => void} options.onReorder neue Reihenfolge
|
||||||
|
*/
|
||||||
|
export function makeSortable(container, { handleSelector, itemSelector, onReorder }) {
|
||||||
|
let dragging = null;
|
||||||
|
let startY = 0;
|
||||||
|
let offset = 0;
|
||||||
|
let moved = false;
|
||||||
|
let originalOrder = [];
|
||||||
|
|
||||||
|
const rows = () => [...container.querySelectorAll(itemSelector)];
|
||||||
|
const idsOf = () => rows().map((r) => r.dataset.id);
|
||||||
|
|
||||||
|
function begin(ev) {
|
||||||
|
const handle = ev.target.closest(handleSelector);
|
||||||
|
if (!handle || !container.contains(handle)) return;
|
||||||
|
if (ev.button !== undefined && ev.button !== 0) return;
|
||||||
|
|
||||||
|
dragging = handle.closest(itemSelector);
|
||||||
|
if (!dragging) return;
|
||||||
|
|
||||||
|
startY = ev.clientY;
|
||||||
|
offset = 0;
|
||||||
|
moved = false;
|
||||||
|
originalOrder = idsOf();
|
||||||
|
|
||||||
|
handle.setPointerCapture(ev.pointerId);
|
||||||
|
ev.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(ev) {
|
||||||
|
if (!dragging) return;
|
||||||
|
|
||||||
|
offset = ev.clientY - startY;
|
||||||
|
if (!moved && Math.abs(offset) < DRAG_THRESHOLD) return;
|
||||||
|
|
||||||
|
if (!moved) {
|
||||||
|
moved = true;
|
||||||
|
dragging.classList.add("dragging");
|
||||||
|
container.classList.add("sorting");
|
||||||
|
}
|
||||||
|
|
||||||
|
dragging.style.transform = `translateY(${offset}px)`;
|
||||||
|
|
||||||
|
const box = dragging.getBoundingClientRect();
|
||||||
|
const middle = box.top + box.height / 2;
|
||||||
|
|
||||||
|
const previous = dragging.previousElementSibling;
|
||||||
|
const next = dragging.nextElementSibling;
|
||||||
|
|
||||||
|
if (previous && middle < previous.getBoundingClientRect().top + previous.offsetHeight / 2) {
|
||||||
|
container.insertBefore(dragging, previous);
|
||||||
|
reanchor(ev);
|
||||||
|
} else if (next && middle > next.getBoundingClientRect().top + next.offsetHeight / 2) {
|
||||||
|
container.insertBefore(next, dragging);
|
||||||
|
reanchor(ev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nach einem Umsortieren sitzt das Element an neuer Stelle. Ohne das
|
||||||
|
* Zurücksetzen des Bezugspunkts würde es um seine eigene Höhe springen. */
|
||||||
|
function reanchor(ev) {
|
||||||
|
startY = ev.clientY;
|
||||||
|
offset = 0;
|
||||||
|
dragging.style.transform = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function end() {
|
||||||
|
if (!dragging) return;
|
||||||
|
|
||||||
|
dragging.style.transform = "";
|
||||||
|
dragging.classList.remove("dragging");
|
||||||
|
container.classList.remove("sorting");
|
||||||
|
|
||||||
|
const wasMoved = moved;
|
||||||
|
dragging = null;
|
||||||
|
moved = false;
|
||||||
|
|
||||||
|
if (!wasMoved) return;
|
||||||
|
|
||||||
|
const order = idsOf();
|
||||||
|
if (order.join() !== originalOrder.join()) onReorder(order);
|
||||||
|
}
|
||||||
|
|
||||||
|
container.addEventListener("pointerdown", begin);
|
||||||
|
container.addEventListener("pointermove", move);
|
||||||
|
container.addEventListener("pointerup", end);
|
||||||
|
container.addEventListener("pointercancel", end);
|
||||||
|
|
||||||
|
// Bedienbar auch ohne Zeigegerät: Anfasser fokussieren, dann Pfeiltasten.
|
||||||
|
container.addEventListener("keydown", (ev) => {
|
||||||
|
const handle = ev.target.closest(handleSelector);
|
||||||
|
if (!handle) return;
|
||||||
|
if (ev.key !== "ArrowUp" && ev.key !== "ArrowDown") return;
|
||||||
|
|
||||||
|
const row = handle.closest(itemSelector);
|
||||||
|
const before = idsOf();
|
||||||
|
|
||||||
|
if (ev.key === "ArrowUp" && row.previousElementSibling) {
|
||||||
|
container.insertBefore(row, row.previousElementSibling);
|
||||||
|
} else if (ev.key === "ArrowDown" && row.nextElementSibling) {
|
||||||
|
container.insertBefore(row.nextElementSibling, row);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ev.preventDefault();
|
||||||
|
handle.focus();
|
||||||
|
const after = idsOf();
|
||||||
|
if (after.join() !== before.join()) onReorder(after);
|
||||||
|
});
|
||||||
|
}
|
||||||
51
web/html/js/store.js
Normal file
51
web/html/js/store.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
// Ein einziger Zustandsspeicher. Ansichten lesen daraus und zeichnen sich
|
||||||
|
// neu, wenn er sich ändert - niemand fasst fremde DOM-Knoten an.
|
||||||
|
//
|
||||||
|
// Das ist im Kern das, was ein Framework auch tut. Der Unterschied ist,
|
||||||
|
// dass hier nichts passiert, was nicht in dieser Datei steht.
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const listeners = new Set();
|
||||||
|
|
||||||
|
export const state = {
|
||||||
|
/** Aus /api/config, mit Rückfallwert für den ersten Start ohne Netz. */
|
||||||
|
appName: "Einkaufsliste",
|
||||||
|
user: null,
|
||||||
|
lists: [],
|
||||||
|
/** Ansicht der geöffneten Liste, so wie /view sie liefert. */
|
||||||
|
view: null,
|
||||||
|
markets: [],
|
||||||
|
categories: [],
|
||||||
|
online: navigator.onLine,
|
||||||
|
/** "ok" | "offline" | "error" */
|
||||||
|
syncState: navigator.onLine ? "ok" : "offline",
|
||||||
|
/** Einmalige Meldung aus der Synchronisation, wird von der Ansicht
|
||||||
|
* abgeholt und zurückgesetzt. */
|
||||||
|
syncProblem: null,
|
||||||
|
lastSync: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function subscribe(fn) {
|
||||||
|
listeners.add(fn);
|
||||||
|
return () => listeners.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nach jeder Zustandsänderung genau einmal aufrufen. Mehrere Aufrufe im
|
||||||
|
* selben Tick werden zusammengefasst, damit nicht mehrfach gezeichnet wird. */
|
||||||
|
let scheduled = false;
|
||||||
|
export function notify() {
|
||||||
|
if (scheduled) return;
|
||||||
|
scheduled = true;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
scheduled = false;
|
||||||
|
for (const fn of listeners) fn(state);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function set(patch) {
|
||||||
|
Object.assign(state, patch);
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("online", () => set({ online: true }));
|
||||||
|
window.addEventListener("offline", () => set({ online: false }));
|
||||||
349
web/html/js/sync.js
Normal file
349
web/html/js/sync.js
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
// Synchronisation: Outbox senden, Ansicht holen, Ereigniskanal halten.
|
||||||
|
//
|
||||||
|
// Grundgedanke: Der Server ist die Quelle der Wahrheit. Lokal liegt sein
|
||||||
|
// zuletzt gesehener Stand plus die noch nicht bestätigten eigenen
|
||||||
|
// Operationen. Angezeigt wird beides übereinandergelegt.
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
import { OfflineError, get, post } from "./api.js";
|
||||||
|
import * as db from "./db.js";
|
||||||
|
import { set, state } from "./store.js";
|
||||||
|
|
||||||
|
const MAX_ATTEMPTS = 8;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Operationen lokal auf die zwischengespeicherte Ansicht anwenden
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function allItems(view) {
|
||||||
|
return view.markets.flatMap((m) => m.categories.flatMap((c) => c.items));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Baut die Gruppierung neu auf, nachdem sich Zuordnungen geändert haben.
|
||||||
|
* Dieselbe Reihenfolge wie serverseitig: Märkte und Warengruppen nach
|
||||||
|
* sort_order, dann Name; Artikel alphabetisch. */
|
||||||
|
function regroup(view, markets, categories) {
|
||||||
|
const items = allItems(view);
|
||||||
|
const marketById = new Map(markets.map((m) => [m.id, m]));
|
||||||
|
const categoryById = new Map(categories.map((c) => [c.id, c]));
|
||||||
|
|
||||||
|
const buckets = new Map();
|
||||||
|
for (const item of items) {
|
||||||
|
const mid = marketById.has(item.market_id) ? item.market_id : null;
|
||||||
|
const cid = categoryById.has(item.category_id) ? item.category_id : null;
|
||||||
|
if (!buckets.has(mid)) buckets.set(mid, new Map());
|
||||||
|
const inner = buckets.get(mid);
|
||||||
|
if (!inner.has(cid)) inner.set(cid, []);
|
||||||
|
inner.get(cid).push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rank = (id, byId) => {
|
||||||
|
if (id === null) return [1, 0, ""];
|
||||||
|
const entry = byId.get(id);
|
||||||
|
return [0, entry.sort_order, entry.name.toLocaleLowerCase("de")];
|
||||||
|
};
|
||||||
|
const cmp = (a, b) => (a[0] - b[0]) || (a[1] - b[1]) || a[2].localeCompare(b[2], "de");
|
||||||
|
|
||||||
|
const marketIds = [...buckets.keys()].sort(
|
||||||
|
(a, b) => cmp(rank(a, marketById), rank(b, marketById))
|
||||||
|
);
|
||||||
|
|
||||||
|
let grand = 0;
|
||||||
|
view.markets = marketIds.map((mid) => {
|
||||||
|
const inner = buckets.get(mid);
|
||||||
|
const categoryIds = [...inner.keys()].sort(
|
||||||
|
(a, b) => cmp(rank(a, categoryById), rank(b, categoryById))
|
||||||
|
);
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
let open = 0;
|
||||||
|
const cats = categoryIds.map((cid) => {
|
||||||
|
const group = inner.get(cid).sort((a, b) =>
|
||||||
|
a.article_name.localeCompare(b.article_name, "de"));
|
||||||
|
for (const item of group) {
|
||||||
|
if (item.status === "open") open += 1;
|
||||||
|
if (item.price_cents) {
|
||||||
|
// Preis je Gebinde mal Stückzahl - nicht mal Packungsgröße.
|
||||||
|
total += item.price_cents * (item.count || 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
category_id: cid,
|
||||||
|
category_name: cid ? categoryById.get(cid).name : "Ohne Warengruppe",
|
||||||
|
items: group,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
grand += total;
|
||||||
|
return {
|
||||||
|
market_id: mid,
|
||||||
|
market_name: mid ? marketById.get(mid).name : "Ohne Markt",
|
||||||
|
categories: cats,
|
||||||
|
open_count: open,
|
||||||
|
total_cents: total,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
view.grand_total_cents = grand;
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wendet eine noch nicht bestätigte Operation auf die Ansicht an, damit
|
||||||
|
* offline getätigte Änderungen sofort sichtbar sind. */
|
||||||
|
export function applyOp(view, op, markets, categories) {
|
||||||
|
const items = allItems(view);
|
||||||
|
const find = (id) => items.find((i) => i.id === id);
|
||||||
|
let needsRegroup = false;
|
||||||
|
|
||||||
|
if (op.kind === "item.create") {
|
||||||
|
const p = op.payload;
|
||||||
|
// Vorläufiger Eintrag: Die endgültige ID vergibt der Server.
|
||||||
|
items.push({
|
||||||
|
id: op.op_id,
|
||||||
|
pending: true,
|
||||||
|
article_id: null,
|
||||||
|
article_name: p.article_name,
|
||||||
|
created_by: null,
|
||||||
|
created_by_name: null,
|
||||||
|
market_id: p.market_id ?? null,
|
||||||
|
category_id: p.category_id ?? null,
|
||||||
|
count: p.count ?? 1,
|
||||||
|
pack_size: p.pack_size ?? null,
|
||||||
|
pack_unit: p.pack_unit ?? null,
|
||||||
|
variant: p.variant ?? null,
|
||||||
|
note: p.note ?? null,
|
||||||
|
status: "open",
|
||||||
|
price_cents: null,
|
||||||
|
total_cents: null,
|
||||||
|
row_rev: 0,
|
||||||
|
updated_at: op.created_at,
|
||||||
|
});
|
||||||
|
// items ist eine Kopie - deshalb über regroup zurückschreiben.
|
||||||
|
view.markets = [{ market_id: null, market_name: "", categories: [
|
||||||
|
{ category_id: null, category_name: "", items },
|
||||||
|
], open_count: 0, total_cents: 0 }];
|
||||||
|
needsRegroup = true;
|
||||||
|
} else if (op.kind === "item.update") {
|
||||||
|
const item = find(op.payload.item_id);
|
||||||
|
if (item) {
|
||||||
|
const p = op.payload;
|
||||||
|
if (p.clear_market) { item.market_id = null; needsRegroup = true; }
|
||||||
|
else if ("market_id" in p) { item.market_id = p.market_id; needsRegroup = true; }
|
||||||
|
if (p.clear_category) { item.category_id = null; needsRegroup = true; }
|
||||||
|
else if ("category_id" in p) { item.category_id = p.category_id; needsRegroup = true; }
|
||||||
|
for (const field of ["count", "pack_size", "pack_unit", "variant",
|
||||||
|
"note", "status", "price_cents"]) {
|
||||||
|
if (field in p) item[field] = p[field];
|
||||||
|
}
|
||||||
|
// Zeilensumme lokal nachziehen, damit die Anzeige sofort stimmt.
|
||||||
|
item.total_cents = item.price_cents
|
||||||
|
? item.price_cents * (item.count || 1) : null;
|
||||||
|
item.pending = true;
|
||||||
|
needsRegroup = true;
|
||||||
|
}
|
||||||
|
} else if (op.kind === "item.delete") {
|
||||||
|
const keep = items.filter((i) => i.id !== op.payload.item_id);
|
||||||
|
view.markets = [{ market_id: null, market_name: "", categories: [
|
||||||
|
{ category_id: null, category_name: "", items: keep },
|
||||||
|
], open_count: 0, total_cents: 0 }];
|
||||||
|
needsRegroup = true;
|
||||||
|
} else if (op.kind === "items.clear_bought") {
|
||||||
|
const keep = items.filter((i) => i.status !== "bought");
|
||||||
|
view.markets = [{ market_id: null, market_name: "", categories: [
|
||||||
|
{ category_id: null, category_name: "", items: keep },
|
||||||
|
], open_count: 0, total_cents: 0 }];
|
||||||
|
needsRegroup = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return needsRegroup ? regroup(view, markets, categories) : view;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serverstand plus alle offenen Operationen. */
|
||||||
|
export async function composeView(listId) {
|
||||||
|
const cached = await db.cacheGet(`view:${listId}`);
|
||||||
|
if (!cached) return null;
|
||||||
|
|
||||||
|
const markets = (await db.cacheGet(`markets:${listId}`)) || [];
|
||||||
|
const categories = (await db.cacheGet(`categories:${listId}`)) || [];
|
||||||
|
|
||||||
|
// Tiefe Kopie, damit der zwischengespeicherte Serverstand unberührt bleibt.
|
||||||
|
let view = structuredClone(cached);
|
||||||
|
const ops = await db.pending(listId);
|
||||||
|
for (const op of ops.sort((a, b) => a.seq - b.seq)) {
|
||||||
|
view = applyOp(view, op, markets, categories);
|
||||||
|
}
|
||||||
|
view.pending_ops = ops.length;
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Senden und Holen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let flushing = false;
|
||||||
|
|
||||||
|
/** Schickt die Warteschlange. Gibt zurück, ob etwas gesendet wurde. */
|
||||||
|
export async function flush(listId) {
|
||||||
|
if (flushing) return false;
|
||||||
|
const ops = (await db.pending(listId)).sort((a, b) => a.seq - b.seq);
|
||||||
|
if (!ops.length) return false;
|
||||||
|
|
||||||
|
// Operationen, die zu oft gescheitert sind, blockieren sonst dauerhaft
|
||||||
|
// alles Nachfolgende - sie werden verworfen und gemeldet.
|
||||||
|
const stuck = ops.filter((o) => o.attempts >= MAX_ATTEMPTS);
|
||||||
|
if (stuck.length) {
|
||||||
|
await db.remove(stuck.map((o) => o.op_id));
|
||||||
|
set({ syncProblem:
|
||||||
|
`${stuck.length} Änderung(en) konnten nicht gespeichert werden und wurden verworfen.` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sending = ops.filter((o) => o.attempts < MAX_ATTEMPTS).slice(0, 200);
|
||||||
|
if (!sending.length) return false;
|
||||||
|
|
||||||
|
flushing = true;
|
||||||
|
try {
|
||||||
|
const result = await post(`/api/lists/${listId}/ops`, {
|
||||||
|
ops: sending.map((o) => ({ op_id: o.op_id, kind: o.kind, payload: o.payload })),
|
||||||
|
});
|
||||||
|
|
||||||
|
const done = [];
|
||||||
|
const rejected = [];
|
||||||
|
for (const r of result.results) {
|
||||||
|
if (r.status === "rejected") rejected.push(r);
|
||||||
|
done.push(r.op_id);
|
||||||
|
}
|
||||||
|
await db.remove(done);
|
||||||
|
|
||||||
|
if (rejected.length) {
|
||||||
|
set({ syncProblem: rejected[0].error
|
||||||
|
? `Eine Änderung wurde abgelehnt: ${rejected[0].error}`
|
||||||
|
: "Eine Änderung wurde abgelehnt." });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof OfflineError) {
|
||||||
|
// Kein Fehler im eigentlichen Sinn - beim nächsten Versuch erneut.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await db.bumpAttempts(sending.map((o) => o.op_id));
|
||||||
|
set({ syncProblem: err.message });
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
flushing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Holt den Serverstand und legt ihn in den Zwischenspeicher.
|
||||||
|
*
|
||||||
|
* Ein einziger Aufruf statt vier: /snapshot liefert Liste, Ansicht,
|
||||||
|
* Märkte und Warengruppen zusammen. Bei einer Synchronisation nach
|
||||||
|
* jedem Abhaken sparen die drei eingesparten Rundreisen spürbar Zeit,
|
||||||
|
* besonders im Mobilfunknetz. */
|
||||||
|
export async function pull(listId) {
|
||||||
|
const snapshot = await get(`/api/lists/${listId}/snapshot`);
|
||||||
|
const { view, markets, categories } = snapshot;
|
||||||
|
const meta = snapshot.list;
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
db.cacheSet(`view:${listId}`, view),
|
||||||
|
db.cacheSet(`markets:${listId}`, markets),
|
||||||
|
db.cacheSet(`categories:${listId}`, categories),
|
||||||
|
db.cacheSet(`meta:${listId}`, meta),
|
||||||
|
]);
|
||||||
|
return { view, markets, categories, meta };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der übliche Ablauf: erst senden, dann holen. Umgekehrt würde der
|
||||||
|
* frisch geholte Stand die eigenen offenen Änderungen überdecken. */
|
||||||
|
export async function syncNow(listId) {
|
||||||
|
try {
|
||||||
|
await flush(listId);
|
||||||
|
await pull(listId);
|
||||||
|
set({ lastSync: new Date().toISOString(), syncState: "ok" });
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
set({ syncState: err instanceof OfflineError ? "offline" : "error" });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Ereigniskanal
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let source = null;
|
||||||
|
let sourceListId = null;
|
||||||
|
|
||||||
|
export function watch(listId, onChange) {
|
||||||
|
stopWatching();
|
||||||
|
if (!("EventSource" in globalThis)) return;
|
||||||
|
|
||||||
|
sourceListId = listId;
|
||||||
|
source = new EventSource(`/api/lists/${listId}/events`);
|
||||||
|
|
||||||
|
source.addEventListener("rev", async (ev) => {
|
||||||
|
let rev = null;
|
||||||
|
try {
|
||||||
|
rev = JSON.parse(ev.data).rev;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cached = await db.cacheGet(`view:${listId}`);
|
||||||
|
// Nur holen, wenn sich wirklich etwas geändert hat.
|
||||||
|
if (!cached || cached.rev !== rev) {
|
||||||
|
await syncNow(listId);
|
||||||
|
onChange();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
source.addEventListener("gone", () => {
|
||||||
|
stopWatching();
|
||||||
|
onChange();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wiederverbindung übernimmt der Browser selbst; nur der Zustand wird
|
||||||
|
// gemeldet, damit die Oberfläche es anzeigen kann.
|
||||||
|
source.onerror = () => set({ syncState: "offline" });
|
||||||
|
source.onopen = () => set({ syncState: "ok" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopWatching() {
|
||||||
|
if (source) {
|
||||||
|
source.close();
|
||||||
|
source = null;
|
||||||
|
sourceListId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function watchedList() {
|
||||||
|
return sourceListId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auslöser für den Versand
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function installTriggers(getListId, onChange) {
|
||||||
|
const attempt = async () => {
|
||||||
|
const listId = getListId();
|
||||||
|
if (!listId) return;
|
||||||
|
// Nur senden. Das anschließende Holen übernimmt die Ansicht, die
|
||||||
|
// onChange auslöst - sonst liefe pull() zweimal hintereinander.
|
||||||
|
if (await flush(listId)) onChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verbindung zurück: sofort versuchen.
|
||||||
|
window.addEventListener("online", () => {
|
||||||
|
set({ online: true, syncState: "ok" });
|
||||||
|
attempt();
|
||||||
|
});
|
||||||
|
window.addEventListener("offline", () => set({ online: false, syncState: "offline" }));
|
||||||
|
|
||||||
|
// App wieder im Vordergrund - typischer Moment nach dem Einkauf.
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
if (document.visibilityState === "visible") attempt();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rückfallebene, falls beide Ereignisse ausbleiben.
|
||||||
|
setInterval(attempt, 30000);
|
||||||
|
}
|
||||||
347
web/html/js/views/admin.js
Normal file
347
web/html/js/views/admin.js
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
// Benutzerverwaltung für Administratoren.
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
import { del, get, post, put } from "../api.js";
|
||||||
|
import { el, mount } from "../dom.js";
|
||||||
|
import { state } from "../store.js";
|
||||||
|
|
||||||
|
let openId = null;
|
||||||
|
|
||||||
|
function formatDate(iso) {
|
||||||
|
if (!iso) return null;
|
||||||
|
return new Date(iso).toLocaleDateString("de-DE", {
|
||||||
|
day: "2-digit", month: "2-digit", year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wie lange ist das her, grob. Für "seit wann nicht mehr gesehen" ist
|
||||||
|
* der Monat die passende Auflösung - der Tag wäre Scheingenauigkeit. */
|
||||||
|
function ago(iso) {
|
||||||
|
if (!iso) return "nie";
|
||||||
|
const days = Math.floor((Date.now() - new Date(iso)) / 86400000);
|
||||||
|
if (days < 1) return "heute";
|
||||||
|
if (days === 1) return "gestern";
|
||||||
|
if (days < 31) return `vor ${days} Tagen`;
|
||||||
|
const months = Math.round(days / 30);
|
||||||
|
if (months < 24) return `vor ${months} Monaten`;
|
||||||
|
return `vor ${Math.round(months / 12)} Jahren`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminView(root, { back }) {
|
||||||
|
let users = [];
|
||||||
|
let stats = null;
|
||||||
|
let config = null;
|
||||||
|
let banner = null;
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
[users, stats, config] = await Promise.all([
|
||||||
|
get("/api/admin/users"),
|
||||||
|
get("/api/admin/stats"),
|
||||||
|
get("/api/admin/settings"),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function say(message, kind = "notice") {
|
||||||
|
banner = { message, kind };
|
||||||
|
render();
|
||||||
|
setTimeout(() => { banner = null; render(); }, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function guarded(fn) {
|
||||||
|
try {
|
||||||
|
const result = await fn();
|
||||||
|
await reload();
|
||||||
|
render();
|
||||||
|
if (result && result.detail) say(result.detail);
|
||||||
|
} catch (err) {
|
||||||
|
say(err.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Kennzahlen
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function statsCard() {
|
||||||
|
const tile = (label, value, warn = false) =>
|
||||||
|
el("div", { className: `stat${warn && value ? " warn" : ""}` },
|
||||||
|
el("span.value", {}, String(value)),
|
||||||
|
el("span.label", {}, label));
|
||||||
|
|
||||||
|
return el("section.card", {},
|
||||||
|
el("h1", {}, "Benutzer"),
|
||||||
|
el("div.stats", {},
|
||||||
|
tile("gesamt", stats.total),
|
||||||
|
tile("aktiv", stats.active),
|
||||||
|
tile("deaktiviert", stats.inactive),
|
||||||
|
tile("nicht eingerichtet", stats.unverified),
|
||||||
|
tile("Administratoren", stats.admins)),
|
||||||
|
stats.due_deactivation || stats.due_deletion
|
||||||
|
? el("p.lead.warn", {},
|
||||||
|
"Beim nächsten nächtlichen Durchlauf: ",
|
||||||
|
stats.due_deactivation
|
||||||
|
? `${stats.due_deactivation} Konto/Konten werden deaktiviert`
|
||||||
|
: "",
|
||||||
|
stats.due_deactivation && stats.due_deletion ? ", " : "",
|
||||||
|
stats.due_deletion
|
||||||
|
? `${stats.due_deletion} werden gelöscht`
|
||||||
|
: "",
|
||||||
|
".")
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Neues Konto
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function createCard() {
|
||||||
|
const email = el("input", { type: "email", placeholder: "person@example.de" });
|
||||||
|
const name = el("input", { type: "text", maxLength: 80, placeholder: "freiwillig" });
|
||||||
|
const asAdmin = el("input", { type: "checkbox" });
|
||||||
|
|
||||||
|
return el("section.card", {},
|
||||||
|
el("h2", {}, "Konto anlegen"),
|
||||||
|
el("p.lead", {},
|
||||||
|
"Die Person bekommt eine Willkommensnachricht mit einem Link, über " +
|
||||||
|
"den sie ihr Passwort selbst festlegt. Ein vom Administrator " +
|
||||||
|
"vergebenes Passwort wäre ihm bekannt und ginge im Klartext per Mail."),
|
||||||
|
el("label", {}, "E-Mail-Adresse"), email,
|
||||||
|
el("label", {}, "Anzeigename"), name,
|
||||||
|
el("label.checkline", {}, asAdmin,
|
||||||
|
el("span", {}, "Administratorrechte erteilen")),
|
||||||
|
el("button.primary", {
|
||||||
|
type: "button",
|
||||||
|
onclick: () => {
|
||||||
|
const value = email.value.trim();
|
||||||
|
if (!value) return;
|
||||||
|
guarded(async () => {
|
||||||
|
await post("/api/admin/users", {
|
||||||
|
email: value,
|
||||||
|
display_name: name.value.trim() || null,
|
||||||
|
is_admin: asAdmin.checked,
|
||||||
|
});
|
||||||
|
email.value = "";
|
||||||
|
name.value = "";
|
||||||
|
asAdmin.checked = false;
|
||||||
|
say(`Konto angelegt, Willkommensnachricht an ${value} versendet.`);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}, "Anlegen und einladen")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Ein Konto
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function userActions(user) {
|
||||||
|
const self = user.id === state.user.id;
|
||||||
|
|
||||||
|
const newEmail = el("input", { type: "email", placeholder: "neue Adresse" });
|
||||||
|
|
||||||
|
const blocks = [
|
||||||
|
el("label", {}, "E-Mail-Adresse ändern"),
|
||||||
|
el("p.sub", {},
|
||||||
|
user.is_admin
|
||||||
|
? "Administratorkonto: Die Änderung wird erst wirksam, wenn BEIDE " +
|
||||||
|
"Adressen bestätigt haben – die neue und die bisherige."
|
||||||
|
: "Die neue Adresse muss bestätigen; die bisherige bekommt einen " +
|
||||||
|
"Hinweis, damit eine untergeschobene Änderung auffällt."),
|
||||||
|
el("div.row", {}, newEmail,
|
||||||
|
el("button.secondary", {
|
||||||
|
type: "button",
|
||||||
|
onclick: () => {
|
||||||
|
const value = newEmail.value.trim();
|
||||||
|
if (!value) return;
|
||||||
|
guarded(async () => {
|
||||||
|
const result = await post(`/api/admin/users/${user.id}/email`,
|
||||||
|
{ new_email: value });
|
||||||
|
newEmail.value = "";
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}, "Ändern")),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (user.pending_email) {
|
||||||
|
blocks.push(el("p.lead.warn", {},
|
||||||
|
`Adresswechsel zu ${user.pending_email} läuft. `,
|
||||||
|
el("button.linklike", {
|
||||||
|
type: "button",
|
||||||
|
onclick: () => guarded(() => del(`/api/admin/users/${user.id}/email`)),
|
||||||
|
}, "Zurückziehen")));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.verified) {
|
||||||
|
blocks.push(el("div.menu-actions", {},
|
||||||
|
el("button", {
|
||||||
|
type: "button",
|
||||||
|
onclick: () => guarded(() => post(`/api/admin/users/${user.id}/welcome`)),
|
||||||
|
}, "Willkommensnachricht erneut senden")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Administratorkonten sind vor Deaktivierung und Löschung geschützt.
|
||||||
|
if (user.is_admin) {
|
||||||
|
blocks.push(el("p.sub", {},
|
||||||
|
"Administratorkonten lassen sich nicht deaktivieren oder löschen. " +
|
||||||
|
"Sonst könnte sich die Verwaltung selbst aussperren."));
|
||||||
|
return el("div.item-menu", {}, blocks);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self) {
|
||||||
|
blocks.push(el("p.sub", {}, "Das eigene Konto lässt sich hier nicht ändern."));
|
||||||
|
return el("div.item-menu", {}, blocks);
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks.push(el("div.menu-actions", {},
|
||||||
|
user.is_active
|
||||||
|
? el("button", {
|
||||||
|
type: "button",
|
||||||
|
onclick: () => {
|
||||||
|
if (!confirm(
|
||||||
|
`Konto ${user.email} deaktivieren?\n\n` +
|
||||||
|
"Die Person kann sich nicht mehr anmelden. Listen und " +
|
||||||
|
"Mitgliedschaften bleiben erhalten."
|
||||||
|
)) return;
|
||||||
|
guarded(() => post(`/api/admin/users/${user.id}/deactivate`));
|
||||||
|
},
|
||||||
|
}, "Deaktivieren")
|
||||||
|
: el("button", {
|
||||||
|
type: "button",
|
||||||
|
onclick: () => guarded(() => post(`/api/admin/users/${user.id}/activate`)),
|
||||||
|
}, "Reaktivieren")));
|
||||||
|
|
||||||
|
blocks.push(el("hr.thin", {}));
|
||||||
|
blocks.push(el("label", {}, "Konto löschen"));
|
||||||
|
blocks.push(el("p.sub", {},
|
||||||
|
user.owned_lists
|
||||||
|
? `Diesem Konto gehören ${user.owned_lists} Liste(n). Geteilte Listen ` +
|
||||||
|
"gehen an das dienstälteste andere Mitglied über, Listen ohne " +
|
||||||
|
"weitere Mitglieder werden gelöscht."
|
||||||
|
: "Diesem Konto gehören keine Listen."));
|
||||||
|
blocks.push(el("button.danger.wide", {
|
||||||
|
type: "button",
|
||||||
|
onclick: () => {
|
||||||
|
const typed = prompt(
|
||||||
|
`Konto ${user.email} endgültig löschen?\n\n` +
|
||||||
|
"Zur Bestätigung die E-Mail-Adresse eingeben:");
|
||||||
|
if (!typed) return;
|
||||||
|
guarded(() => post(`/api/admin/users/${user.id}/delete`, {
|
||||||
|
lists: "handover",
|
||||||
|
confirm_email: typed.trim(),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
}, "Endgültig löschen"));
|
||||||
|
|
||||||
|
return el("div.item-menu", {}, blocks);
|
||||||
|
}
|
||||||
|
|
||||||
|
function userRow(user) {
|
||||||
|
const facts = [
|
||||||
|
user.is_admin ? "Administrator" : null,
|
||||||
|
!user.verified ? "noch nicht eingerichtet" : null,
|
||||||
|
!user.is_active ? `deaktiviert seit ${formatDate(user.deactivated_at)}` : null,
|
||||||
|
user.is_active && user.verified ? `zuletzt ${ago(user.last_seen_at)}` : null,
|
||||||
|
user.owned_lists ? `${user.owned_lists} eigene Liste(n)` : null,
|
||||||
|
user.memberships ? `${user.memberships} Mitgliedschaft(en)` : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return el("li", {
|
||||||
|
className: `admin-user${user.is_active ? "" : " inactive"}`,
|
||||||
|
},
|
||||||
|
el("button.user-open", {
|
||||||
|
type: "button",
|
||||||
|
onclick: () => { openId = openId === user.id ? null : user.id; render(); },
|
||||||
|
},
|
||||||
|
el("span.name", {}, user.display_name || user.email),
|
||||||
|
el("span.sub", {},
|
||||||
|
user.display_name ? `${user.email} · ` : "",
|
||||||
|
facts.join(" · "))),
|
||||||
|
openId === user.id ? userActions(user) : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Automatik
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function automationCard() {
|
||||||
|
const deactivate = el("input.months", {
|
||||||
|
type: "number", min: "0", max: "600",
|
||||||
|
value: String(config.auto_deactivate_months),
|
||||||
|
});
|
||||||
|
const remove = el("input.months", {
|
||||||
|
type: "number", min: "0", max: "600",
|
||||||
|
value: String(config.auto_delete_months),
|
||||||
|
});
|
||||||
|
const selfReg = el("input", {
|
||||||
|
type: "checkbox",
|
||||||
|
checked: config.allow_self_registration,
|
||||||
|
disabled: config.locked_by_env,
|
||||||
|
});
|
||||||
|
|
||||||
|
return el("section.card", {},
|
||||||
|
el("h2", {}, "Automatische Bereinigung"),
|
||||||
|
el("p.lead", {},
|
||||||
|
"Läuft einmal täglich. ",
|
||||||
|
el("strong", {}, "0 bedeutet abgeschaltet"),
|
||||||
|
", nicht „sofort“. Administratorkonten sind ausgenommen."),
|
||||||
|
|
||||||
|
el("label", {}, "Deaktivieren nach … Monaten ohne Anmeldung"),
|
||||||
|
deactivate,
|
||||||
|
el("label", {}, "Löschen nach … Monaten Deaktivierung"),
|
||||||
|
remove,
|
||||||
|
|
||||||
|
el("label.checkline", {}, selfReg,
|
||||||
|
el("span", {}, "Selbstregistrierung erlauben")),
|
||||||
|
config.locked_by_env
|
||||||
|
? el("p.sub", {},
|
||||||
|
"Über die Umgebungsvariable ALLOW_SELF_REGISTRATION festgelegt " +
|
||||||
|
"und hier nicht änderbar.")
|
||||||
|
: null,
|
||||||
|
|
||||||
|
el("button.primary", {
|
||||||
|
type: "button",
|
||||||
|
onclick: () => guarded(async () => {
|
||||||
|
await put("/api/admin/settings", {
|
||||||
|
auto_deactivate_months: Number(deactivate.value) || 0,
|
||||||
|
auto_delete_months: Number(remove.value) || 0,
|
||||||
|
...(config.locked_by_env
|
||||||
|
? {}
|
||||||
|
: { allow_self_registration: selfReg.checked }),
|
||||||
|
});
|
||||||
|
say("Einstellungen gespeichert.");
|
||||||
|
}),
|
||||||
|
}, "Speichern"),
|
||||||
|
|
||||||
|
el("div.menu-actions", {},
|
||||||
|
el("button", {
|
||||||
|
type: "button",
|
||||||
|
onclick: () => guarded(() => post("/api/admin/cleanup")),
|
||||||
|
}, "Jetzt aufräumen"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
mount(root,
|
||||||
|
el("header.bar", {},
|
||||||
|
el("button.linklike", { type: "button", onclick: back }, "‹ Zurück")),
|
||||||
|
|
||||||
|
banner ? el(banner.kind === "error" ? "p.error" : "p.notice", {}, banner.message) : null,
|
||||||
|
|
||||||
|
statsCard(),
|
||||||
|
el("section.card", {},
|
||||||
|
el("h2", {}, "Konten"),
|
||||||
|
el("ul.admin-users", {}, users.map(userRow))),
|
||||||
|
createCard(),
|
||||||
|
automationCard()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await reload();
|
||||||
|
render();
|
||||||
|
return render;
|
||||||
|
}
|
||||||
296
web/html/js/views/articles.js
Normal file
296
web/html/js/views/articles.js
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
// Artikelstamm einer Liste: Namen, Strichcodes, Vorgaben für Markt und
|
||||||
|
// Warengruppe, Verfügbarkeit und frei definierbare Eigenschaften.
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
import { del, get, patch, post } from "../api.js";
|
||||||
|
import { el, mount } from "../dom.js";
|
||||||
|
import { set, state } from "../store.js";
|
||||||
|
import { cameraAvailable, scanBarcode } from "./scanner.js";
|
||||||
|
|
||||||
|
let editingId = null;
|
||||||
|
let query = "";
|
||||||
|
|
||||||
|
export async function articlesView(root, { listId, back }) {
|
||||||
|
let articles = [];
|
||||||
|
let banner = null;
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
const [list, markets, categories] = await Promise.all([
|
||||||
|
get(`/api/lists/${listId}/articles${query ? `?q=${encodeURIComponent(query)}` : ""}`),
|
||||||
|
get(`/api/lists/${listId}/markets`),
|
||||||
|
get(`/api/lists/${listId}/categories`),
|
||||||
|
]);
|
||||||
|
articles = list;
|
||||||
|
set({ markets, categories });
|
||||||
|
}
|
||||||
|
|
||||||
|
function say(message, kind = "error") {
|
||||||
|
banner = { message, kind };
|
||||||
|
render();
|
||||||
|
setTimeout(() => { banner = null; render(); }, 6000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function guarded(fn) {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
await reload();
|
||||||
|
render();
|
||||||
|
} catch (err) {
|
||||||
|
say(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Bearbeitungsformular
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function editor(article) {
|
||||||
|
const name = el("input", { type: "text", value: article.name, maxLength: 200 });
|
||||||
|
const barcode = el("input", {
|
||||||
|
type: "text",
|
||||||
|
inputMode: "numeric",
|
||||||
|
value: article.barcode || "",
|
||||||
|
maxLength: 64,
|
||||||
|
placeholder: "keiner hinterlegt",
|
||||||
|
});
|
||||||
|
const note = el("input", {
|
||||||
|
type: "text", value: article.note || "", maxLength: 500,
|
||||||
|
placeholder: "z. B. Lieblingsmarke",
|
||||||
|
});
|
||||||
|
|
||||||
|
const marketSelect = el("select", {},
|
||||||
|
el("option", { value: "" }, "— keine Vorgabe —"),
|
||||||
|
state.markets.map((m) => el("option", {
|
||||||
|
value: m.id, selected: m.id === article.default_market_id,
|
||||||
|
}, m.name)));
|
||||||
|
|
||||||
|
const categorySelect = el("select", {},
|
||||||
|
el("option", { value: "" }, "— keine Vorgabe —"),
|
||||||
|
state.categories.map((c) => el("option", {
|
||||||
|
value: c.id, selected: c.id === article.default_category_id,
|
||||||
|
}, c.name)));
|
||||||
|
|
||||||
|
// Verfügbarkeit: mehrere Märkte ankreuzbar
|
||||||
|
const availability = state.markets.map((m) => {
|
||||||
|
const box = el("input", {
|
||||||
|
type: "checkbox",
|
||||||
|
checked: article.available_market_ids.includes(m.id),
|
||||||
|
dataset: { marketId: m.id },
|
||||||
|
});
|
||||||
|
return el("label.checkline", {}, box, el("span", {}, m.name));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Freie Eigenschaften
|
||||||
|
const attrRows = el("div.attr-rows", {});
|
||||||
|
|
||||||
|
function addAttrRow(attr = { name: "", value: "" }) {
|
||||||
|
const key = el("input.attr-key", {
|
||||||
|
type: "text", value: attr.name, maxLength: 80, placeholder: "Eigenschaft",
|
||||||
|
});
|
||||||
|
const value = el("input.attr-value", {
|
||||||
|
type: "text", value: attr.value, maxLength: 300, placeholder: "Wert",
|
||||||
|
});
|
||||||
|
const row = el("div.attr-row", {}, key, value,
|
||||||
|
el("button.danger.remove", {
|
||||||
|
type: "button", title: "Zeile entfernen",
|
||||||
|
onclick: () => row.remove(),
|
||||||
|
}, "×"));
|
||||||
|
attrRows.append(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const attr of article.attributes) addAttrRow(attr);
|
||||||
|
if (!article.attributes.length) addAttrRow();
|
||||||
|
|
||||||
|
async function scan() {
|
||||||
|
const code = await scanBarcode();
|
||||||
|
if (code) barcode.value = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Angaben zum eingetragenen Code aus der Produktdatenbank holen.
|
||||||
|
* Überschreibt nur leere Felder - Gepflegtes bleibt stehen. */
|
||||||
|
async function fetchProductData() {
|
||||||
|
const code = barcode.value.replace(/\s/g, "");
|
||||||
|
if (!code) {
|
||||||
|
say("Erst einen Strichcode eintragen oder scannen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const hit = await get(
|
||||||
|
`/api/lists/${listId}/barcode/${encodeURIComponent(code)}`);
|
||||||
|
if (!hit.found || hit.source !== "openfoodfacts") {
|
||||||
|
say("Zu diesem Code liegen keine Angaben vor.", "notice");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hit.name && !name.value.trim()) name.value = hit.name;
|
||||||
|
if (hit.package && !note.value.trim()) note.value = hit.package;
|
||||||
|
|
||||||
|
const rows = [...attrRows.querySelectorAll(".attr-row")];
|
||||||
|
const empty = rows.find(
|
||||||
|
(r) => !r.querySelector(".attr-key").value.trim());
|
||||||
|
if (hit.brand && empty) {
|
||||||
|
empty.querySelector(".attr-key").value = "Marke";
|
||||||
|
empty.querySelector(".attr-value").value = hit.brand;
|
||||||
|
}
|
||||||
|
say(`Angaben zu „${hit.name}“ übernommen. Noch nicht gespeichert.`,
|
||||||
|
"notice");
|
||||||
|
} catch (err) {
|
||||||
|
say(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const attributes = [...attrRows.querySelectorAll(".attr-row")]
|
||||||
|
.map((row) => ({
|
||||||
|
name: row.querySelector(".attr-key").value.trim(),
|
||||||
|
value: row.querySelector(".attr-value").value.trim(),
|
||||||
|
}))
|
||||||
|
.filter((a) => a.name);
|
||||||
|
|
||||||
|
const available = [...availability]
|
||||||
|
.map((label) => label.querySelector("input"))
|
||||||
|
.filter((box) => box.checked)
|
||||||
|
.map((box) => box.dataset.marketId);
|
||||||
|
|
||||||
|
editingId = null;
|
||||||
|
await guarded(() => patch(`/api/lists/${listId}/articles/${article.id}`, {
|
||||||
|
name: name.value.trim() || article.name,
|
||||||
|
barcode: barcode.value.replace(/\s/g, "") || null,
|
||||||
|
note: note.value.trim() || null,
|
||||||
|
default_market_id: marketSelect.value || null,
|
||||||
|
default_category_id: categorySelect.value || null,
|
||||||
|
attributes,
|
||||||
|
available_market_ids: available,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return el("div.article-editor", {},
|
||||||
|
el("label", {}, "Name"), name,
|
||||||
|
|
||||||
|
el("label", {}, "Strichcode"),
|
||||||
|
el("div.row", {}, barcode,
|
||||||
|
cameraAvailable()
|
||||||
|
? el("button.secondary", { type: "button", onclick: scan }, "Scannen")
|
||||||
|
: null,
|
||||||
|
el("button.secondary", {
|
||||||
|
type: "button",
|
||||||
|
title: "Bezeichnung und Packungsgröße aus der Produktdatenbank holen",
|
||||||
|
onclick: fetchProductData,
|
||||||
|
}, "Abrufen")),
|
||||||
|
|
||||||
|
el("label", {}, "Notiz"), note,
|
||||||
|
el("label", {}, "Vorgabe Markt"), marketSelect,
|
||||||
|
el("label", {}, "Vorgabe Warengruppe"), categorySelect,
|
||||||
|
|
||||||
|
state.markets.length
|
||||||
|
? el("div.availability", {},
|
||||||
|
el("label", {}, "Erhältlich bei"), availability)
|
||||||
|
: null,
|
||||||
|
|
||||||
|
el("label", {}, "Eigenschaften",
|
||||||
|
el("span.hint", {}, " (Verpackungseinheit, Farbe, Größe …)")),
|
||||||
|
attrRows,
|
||||||
|
el("button.linklike.add-attr", {
|
||||||
|
type: "button", onclick: () => addAttrRow(),
|
||||||
|
}, "+ weitere Eigenschaft"),
|
||||||
|
|
||||||
|
el("div.menu-actions", {},
|
||||||
|
el("button.primary", { type: "button", onclick: save }, "Speichern"),
|
||||||
|
el("button", {
|
||||||
|
type: "button",
|
||||||
|
onclick: () => { editingId = null; render(); },
|
||||||
|
}, "Abbrechen"),
|
||||||
|
el("button.danger", {
|
||||||
|
type: "button",
|
||||||
|
onclick: () => {
|
||||||
|
if (!confirm(
|
||||||
|
`Artikel „${article.name}“ löschen?\n\n` +
|
||||||
|
"Offene Einträge dieses Artikels verschwinden mit."
|
||||||
|
)) return;
|
||||||
|
editingId = null;
|
||||||
|
guarded(() => del(`/api/lists/${listId}/articles/${article.id}`));
|
||||||
|
},
|
||||||
|
}, "Löschen"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Zeile
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function row(article) {
|
||||||
|
if (editingId === article.id) {
|
||||||
|
return el("li.article-row.editing", {},
|
||||||
|
el("span.name", {}, article.name), editor(article));
|
||||||
|
}
|
||||||
|
|
||||||
|
const marketName = state.markets.find((m) => m.id === article.default_market_id)?.name;
|
||||||
|
const categoryName = state.categories.find(
|
||||||
|
(c) => c.id === article.default_category_id)?.name;
|
||||||
|
|
||||||
|
const facts = [
|
||||||
|
article.barcode ? `Code ${article.barcode}` : null,
|
||||||
|
marketName,
|
||||||
|
categoryName,
|
||||||
|
article.attributes.length
|
||||||
|
? article.attributes.map((a) => `${a.name}: ${a.value}`).join(", ")
|
||||||
|
: null,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return el("li.article-row", {},
|
||||||
|
el("button.article-open", {
|
||||||
|
type: "button",
|
||||||
|
onclick: () => { editingId = article.id; render(); },
|
||||||
|
},
|
||||||
|
el("span.name", {}, article.name),
|
||||||
|
facts.length ? el("span.sub", {}, facts.join(" · ")) : null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const search = el("input", {
|
||||||
|
type: "search",
|
||||||
|
value: query,
|
||||||
|
placeholder: "Artikel suchen",
|
||||||
|
oninput: (ev) => {
|
||||||
|
query = ev.target.value;
|
||||||
|
clearTimeout(render._timer);
|
||||||
|
render._timer = setTimeout(() => guarded(async () => {}), 250);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mount(root,
|
||||||
|
el("header.bar", {},
|
||||||
|
el("button.linklike", { type: "button", onclick: back },
|
||||||
|
"‹ Zurück zur Liste")),
|
||||||
|
|
||||||
|
banner ? el(banner.kind === "error" ? "p.error" : "p.notice", {}, banner.message) : null,
|
||||||
|
|
||||||
|
el("section.card", {},
|
||||||
|
el("h1", {}, "Artikel"),
|
||||||
|
el("p.lead", {},
|
||||||
|
"Vorgaben für Markt und Warengruppe gelten für neue Einträge. " +
|
||||||
|
"Ein hinterlegter Strichcode lässt den Artikel beim Scannen sofort " +
|
||||||
|
"finden."),
|
||||||
|
search,
|
||||||
|
articles.length
|
||||||
|
? el("ul.articles", {}, articles.map(row))
|
||||||
|
: el("p.empty", {},
|
||||||
|
query ? "Kein Artikel gefunden." : "Noch kein Artikel angelegt.")),
|
||||||
|
|
||||||
|
el("p.footnote", {},
|
||||||
|
"Artikel entstehen automatisch, sobald du sie auf die Liste setzt.")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
const field = root.querySelector('input[type="search"]');
|
||||||
|
field.focus();
|
||||||
|
field.setSelectionRange(field.value.length, field.value.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await reload();
|
||||||
|
render();
|
||||||
|
return render;
|
||||||
|
}
|
||||||
233
web/html/js/views/auth.js
Normal file
233
web/html/js/views/auth.js
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
// Anmeldung, Registrierung, Passwort zurücksetzen und Startpasswortwechsel.
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
import { ApiError, get, post } from "../api.js";
|
||||||
|
import { clear, el, mount } from "../dom.js";
|
||||||
|
import { set, state } from "../store.js";
|
||||||
|
|
||||||
|
/** Baut ein Formular ohne <form>-Element: dessen Standardverhalten
|
||||||
|
* (Seite neu laden) stört hier nur. Enter löst trotzdem aus. */
|
||||||
|
function form({ title, lead, leadWarn, fields, submitLabel, onSubmit, links = [] }) {
|
||||||
|
const inputs = {};
|
||||||
|
const error = el("p.error", { hidden: true });
|
||||||
|
const notice = el("p.notice", { hidden: true });
|
||||||
|
const button = el("button.primary", { type: "button" }, submitLabel);
|
||||||
|
|
||||||
|
const showError = (message) => {
|
||||||
|
error.textContent = message || "";
|
||||||
|
error.hidden = !message;
|
||||||
|
};
|
||||||
|
const showNotice = (message) => {
|
||||||
|
notice.textContent = message || "";
|
||||||
|
notice.hidden = !message;
|
||||||
|
};
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
showError("");
|
||||||
|
showNotice("");
|
||||||
|
button.disabled = true;
|
||||||
|
try {
|
||||||
|
const values = Object.fromEntries(
|
||||||
|
Object.entries(inputs).map(([k, i]) => [k, i.value])
|
||||||
|
);
|
||||||
|
await onSubmit(values, { showNotice, showError });
|
||||||
|
} catch (err) {
|
||||||
|
showError(err.message);
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
button.addEventListener("click", run);
|
||||||
|
|
||||||
|
const rows = fields.map((f) => {
|
||||||
|
const input = el("input", {
|
||||||
|
id: `f-${f.name}`,
|
||||||
|
type: f.type || "text",
|
||||||
|
autocomplete: f.autocomplete || "off",
|
||||||
|
maxLength: f.maxLength || 256,
|
||||||
|
onkeydown: (ev) => {
|
||||||
|
if (ev.key === "Enter") run();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
inputs[f.name] = input;
|
||||||
|
return [
|
||||||
|
el("label", { htmlFor: `f-${f.name}` },
|
||||||
|
f.label,
|
||||||
|
f.hint ? el("span.hint", {}, ` ${f.hint}`) : null),
|
||||||
|
input,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return el("section.card", {},
|
||||||
|
el("h1", {}, title),
|
||||||
|
lead ? el(leadWarn ? "p.lead.warn" : "p.lead", {}, lead) : null,
|
||||||
|
rows,
|
||||||
|
error,
|
||||||
|
notice,
|
||||||
|
button,
|
||||||
|
links.length
|
||||||
|
? el("p.switch", {}, links.flatMap((l, i) => [
|
||||||
|
i > 0 ? " · " : null,
|
||||||
|
el("a", { href: "#", onclick: (ev) => { ev.preventDefault(); l.action(); } },
|
||||||
|
l.label),
|
||||||
|
]))
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loginView(root, { goto, onSignedIn }) {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const verified = params.get("verified");
|
||||||
|
const emailChange = params.get("adresswechsel");
|
||||||
|
|
||||||
|
const node = form({
|
||||||
|
title: state.appName,
|
||||||
|
lead: "Bitte anmelden.",
|
||||||
|
fields: [
|
||||||
|
{ name: "email", label: "E-Mail-Adresse", type: "email", autocomplete: "username" },
|
||||||
|
{ name: "password", label: "Passwort", type: "password", autocomplete: "current-password" },
|
||||||
|
],
|
||||||
|
submitLabel: "Anmelden",
|
||||||
|
onSubmit: async ({ email, password }) => {
|
||||||
|
const user = await post("/api/auth/login", { email: email.trim(), password });
|
||||||
|
onSignedIn(user);
|
||||||
|
},
|
||||||
|
links: [
|
||||||
|
{ label: "Konto anlegen", action: () => goto("register") },
|
||||||
|
{ label: "Passwort vergessen", action: () => goto("forgot") },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
mount(root, node);
|
||||||
|
|
||||||
|
if (verified === "ok") {
|
||||||
|
const notice = node.querySelector(".notice");
|
||||||
|
notice.textContent = "E-Mail-Adresse bestätigt. Du kannst dich jetzt anmelden.";
|
||||||
|
notice.hidden = false;
|
||||||
|
history.replaceState(null, "", "/");
|
||||||
|
} else if (verified === "invalid") {
|
||||||
|
const error = node.querySelector(".error");
|
||||||
|
error.textContent = "Der Bestätigungslink ist ungültig oder abgelaufen.";
|
||||||
|
error.hidden = false;
|
||||||
|
history.replaceState(null, "", "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emailChange) {
|
||||||
|
const messages = {
|
||||||
|
fertig: ["notice",
|
||||||
|
"E-Mail-Adresse geändert. Bitte melde dich mit der neuen Adresse an."],
|
||||||
|
teilweise: ["notice",
|
||||||
|
"Bestätigung angekommen. Bei Administratorkonten muss auch die " +
|
||||||
|
"zweite Adresse zustimmen – erst danach wird die Änderung wirksam."],
|
||||||
|
abgelaufen: ["error", "Der Bestätigungslink ist abgelaufen."],
|
||||||
|
erledigt: ["notice", "Dieser Adresswechsel ist bereits abgeschlossen."],
|
||||||
|
unbekannt: ["error", "Dieser Bestätigungslink ist unbekannt."],
|
||||||
|
};
|
||||||
|
const entry = messages[emailChange];
|
||||||
|
if (entry) {
|
||||||
|
const target = node.querySelector(entry[0] === "error" ? ".error" : ".notice");
|
||||||
|
target.textContent = entry[1];
|
||||||
|
target.hidden = false;
|
||||||
|
history.replaceState(null, "", "/");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
node.querySelector("input").focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerView(root, { goto }) {
|
||||||
|
mount(root, form({
|
||||||
|
title: "Konto anlegen",
|
||||||
|
fields: [
|
||||||
|
{ name: "email", label: "E-Mail-Adresse", type: "email", autocomplete: "username" },
|
||||||
|
{ name: "display_name", label: "Anzeigename", hint: "(freiwillig)", maxLength: 80 },
|
||||||
|
{ name: "password", label: "Passwort", hint: "(mindestens 12 Zeichen)",
|
||||||
|
type: "password", autocomplete: "new-password" },
|
||||||
|
],
|
||||||
|
submitLabel: "Registrieren",
|
||||||
|
onSubmit: async (v, { showNotice }) => {
|
||||||
|
const result = await post("/api/auth/register", {
|
||||||
|
email: v.email.trim(),
|
||||||
|
password: v.password,
|
||||||
|
display_name: v.display_name.trim() || null,
|
||||||
|
});
|
||||||
|
showNotice(result.detail);
|
||||||
|
},
|
||||||
|
links: [{ label: "Zurück zur Anmeldung", action: () => goto("login") }],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function forgotView(root, { goto }) {
|
||||||
|
mount(root, form({
|
||||||
|
title: "Passwort zurücksetzen",
|
||||||
|
lead: "Wir schicken dir einen Link, sofern die Adresse bei uns registriert ist.",
|
||||||
|
fields: [
|
||||||
|
{ name: "email", label: "E-Mail-Adresse", type: "email", autocomplete: "username" },
|
||||||
|
],
|
||||||
|
submitLabel: "Link anfordern",
|
||||||
|
onSubmit: async ({ email }, { showNotice }) => {
|
||||||
|
const result = await post("/api/auth/password/reset-request", { email: email.trim() });
|
||||||
|
showNotice(result.detail);
|
||||||
|
},
|
||||||
|
links: [{ label: "Zurück zur Anmeldung", action: () => goto("login") }],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetView(root, { goto }) {
|
||||||
|
const token = new URLSearchParams(location.search).get("token") || "";
|
||||||
|
mount(root, form({
|
||||||
|
title: "Neues Passwort setzen",
|
||||||
|
fields: [
|
||||||
|
{ name: "password", label: "Neues Passwort", hint: "(mindestens 12 Zeichen)",
|
||||||
|
type: "password", autocomplete: "new-password" },
|
||||||
|
{ name: "repeat", label: "Wiederholen", type: "password", autocomplete: "new-password" },
|
||||||
|
],
|
||||||
|
submitLabel: "Passwort setzen",
|
||||||
|
onSubmit: async ({ password, repeat }, { showNotice }) => {
|
||||||
|
if (password !== repeat) throw new Error("Die beiden Eingaben stimmen nicht überein.");
|
||||||
|
const result = await post("/api/auth/password/reset", { token, password });
|
||||||
|
showNotice(result.detail);
|
||||||
|
history.replaceState(null, "", "/");
|
||||||
|
setTimeout(() => goto("login"), 1500);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function changePasswordView(root, { onDone, onLogout }) {
|
||||||
|
mount(root, form({
|
||||||
|
title: "Passwort ändern",
|
||||||
|
lead: "Dieses Konto wurde mit einem Startpasswort aus der Konfiguration angelegt. " +
|
||||||
|
"Bevor du weiterarbeiten kannst, musst du ein eigenes Passwort setzen.",
|
||||||
|
leadWarn: true,
|
||||||
|
fields: [
|
||||||
|
{ name: "current", label: "Bisheriges Passwort", type: "password",
|
||||||
|
autocomplete: "current-password" },
|
||||||
|
{ name: "next", label: "Neues Passwort", hint: "(mindestens 12 Zeichen)",
|
||||||
|
type: "password", autocomplete: "new-password" },
|
||||||
|
{ name: "repeat", label: "Wiederholen", type: "password", autocomplete: "new-password" },
|
||||||
|
],
|
||||||
|
submitLabel: "Passwort ändern",
|
||||||
|
onSubmit: async ({ current, next, repeat }) => {
|
||||||
|
if (next !== repeat) throw new Error("Die beiden Eingaben stimmen nicht überein.");
|
||||||
|
await post("/api/auth/password/change", {
|
||||||
|
current_password: current,
|
||||||
|
new_password: next,
|
||||||
|
});
|
||||||
|
const user = await get("/api/auth/me");
|
||||||
|
set({ user });
|
||||||
|
onDone(user);
|
||||||
|
},
|
||||||
|
links: [{ label: "Abmelden", action: onLogout }],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout() {
|
||||||
|
try {
|
||||||
|
await post("/api/auth/logout");
|
||||||
|
} catch (err) {
|
||||||
|
// Auch wenn der Server nicht erreichbar ist: lokal abmelden.
|
||||||
|
if (!(err instanceof ApiError)) { /* offline - egal */ }
|
||||||
|
}
|
||||||
|
set({ user: null, lists: [], view: null });
|
||||||
|
}
|
||||||
600
web/html/js/views/list-detail.js
Normal file
600
web/html/js/views/list-detail.js
Normal file
@@ -0,0 +1,600 @@
|
|||||||
|
// Eine geöffnete Liste: gruppiert nach Markt und Warengruppe, Artikel
|
||||||
|
// alphabetisch. Arbeitet gegen den lokalen Zwischenspeicher, nicht gegen
|
||||||
|
// den Server - dadurch funktioniert die Ansicht auch ohne Verbindung.
|
||||||
|
//
|
||||||
|
// Jede Änderung wandert zuerst in die Outbox und wird sofort angezeigt.
|
||||||
|
// Der Versand läuft danach; scheitert er, bleibt die Operation liegen und
|
||||||
|
// wird beim nächsten Anlauf erneut versucht.
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// get/ApiError nur für die Strichcode-Auflösung: Sie braucht eine
|
||||||
|
// Verbindung, weil die Zuordnung Code -> Artikel serverseitig steht.
|
||||||
|
import { ApiError, get } from "../api.js";
|
||||||
|
import * as db from "../db.js";
|
||||||
|
import {
|
||||||
|
clear, el, euro, formatPack, formatQuantity, mount,
|
||||||
|
parseCents, parseCount, parseQuantity,
|
||||||
|
} from "../dom.js";
|
||||||
|
import { set, state } from "../store.js";
|
||||||
|
import { composeView, flush, pull, syncNow, watch } from "../sync.js";
|
||||||
|
import { confirmScan } from "./scan-result.js";
|
||||||
|
import { cameraAvailable, scanBarcode } from "./scanner.js";
|
||||||
|
|
||||||
|
let openMenuId = null;
|
||||||
|
|
||||||
|
export async function listDetailView(
|
||||||
|
root, { listId, back, manage, share, articles, prices }
|
||||||
|
) {
|
||||||
|
let view = null;
|
||||||
|
let meta = null;
|
||||||
|
let markets = [];
|
||||||
|
let categories = [];
|
||||||
|
let banner = null;
|
||||||
|
/** article_id -> { best_market_ids, best_cents, spread_cents, prices } */
|
||||||
|
let priceHints = new Map();
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Laden
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Preishinweise sind eine Nebeninformation: Kommen sie nicht, arbeitet
|
||||||
|
* die Liste ohne sie weiter. Deshalb hier kein throw, sondern der
|
||||||
|
* Rückgriff auf den zuletzt bekannten Stand. */
|
||||||
|
async function loadHints() {
|
||||||
|
if (!state.online) {
|
||||||
|
const cached = await db.cacheGet(`hints:${listId}`).catch(() => null);
|
||||||
|
if (cached) priceHints = new Map(cached.map((r) => [r.article_id, r]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const rows = await get(`/api/lists/${listId}/price-hints`);
|
||||||
|
priceHints = new Map(rows.map((r) => [r.article_id, r]));
|
||||||
|
await db.cacheSet(`hints:${listId}`, rows);
|
||||||
|
} catch {
|
||||||
|
const cached = await db.cacheGet(`hints:${listId}`).catch(() => null);
|
||||||
|
if (cached) priceHints = new Map(cached.map((r) => [r.article_id, r]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readLocal() {
|
||||||
|
view = await composeView(listId);
|
||||||
|
markets = (await db.cacheGet(`markets:${listId}`)) || [];
|
||||||
|
categories = (await db.cacheGet(`categories:${listId}`)) || [];
|
||||||
|
meta = await db.cacheGet(`meta:${listId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh({ fromServer = false } = {}) {
|
||||||
|
if (fromServer) {
|
||||||
|
try {
|
||||||
|
await pull(listId);
|
||||||
|
set({ syncState: "ok" });
|
||||||
|
} catch {
|
||||||
|
set({ syncState: navigator.onLine ? "error" : "offline" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await readLocal();
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function say(message) {
|
||||||
|
banner = message;
|
||||||
|
render();
|
||||||
|
setTimeout(() => { banner = null; render(); }, 6000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Änderung anmelden: in die Outbox, sofort anzeigen, dann senden. */
|
||||||
|
async function change(kind, payload) {
|
||||||
|
await db.enqueue(listId, kind, payload);
|
||||||
|
await readLocal();
|
||||||
|
render();
|
||||||
|
|
||||||
|
const sent = await flush(listId);
|
||||||
|
if (sent) {
|
||||||
|
await pull(listId).catch(() => {});
|
||||||
|
await readLocal();
|
||||||
|
}
|
||||||
|
if (state.syncProblem) {
|
||||||
|
const problem = state.syncProblem;
|
||||||
|
set({ syncProblem: null });
|
||||||
|
say(problem);
|
||||||
|
} else {
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Bausteine
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function itemRow(item) {
|
||||||
|
const bought = item.status === "bought";
|
||||||
|
const deferred = item.status === "deferred";
|
||||||
|
// Ein neu angelegter, noch nicht bestätigter Eintrag hat serverseitig
|
||||||
|
// keine ID - Folgeänderungen daran ließen sich nicht zuordnen.
|
||||||
|
const provisional = Boolean(item.pending && item.row_rev === 0);
|
||||||
|
|
||||||
|
const check = el("button.check", {
|
||||||
|
type: "button",
|
||||||
|
"aria-pressed": bought ? "true" : "false",
|
||||||
|
title: bought ? "Als offen markieren" : "Als gekauft markieren",
|
||||||
|
onclick: () => change("item.update", {
|
||||||
|
item_id: item.id,
|
||||||
|
status: bought ? "open" : "bought",
|
||||||
|
}),
|
||||||
|
disabled: provisional,
|
||||||
|
}, bought ? "✓" : "");
|
||||||
|
|
||||||
|
const priceField = el("input.price", {
|
||||||
|
type: "text",
|
||||||
|
inputMode: "decimal",
|
||||||
|
placeholder: "€",
|
||||||
|
value: item.price_cents ? (item.price_cents / 100).toFixed(2).replace(".", ",") : "",
|
||||||
|
title: provisional ? "Erst nach der Übertragung möglich" : "Preis erfassen",
|
||||||
|
disabled: provisional,
|
||||||
|
onchange: (ev) => {
|
||||||
|
const cents = parseCents(ev.target.value);
|
||||||
|
if (ev.target.value.trim() && cents === null) {
|
||||||
|
ev.target.value = "";
|
||||||
|
say("Preis konnte nicht gelesen werden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
change("item.update", { item_id: item.id, price_cents: cents });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const menuButton = el("button.menu-toggle", {
|
||||||
|
type: "button",
|
||||||
|
title: "Weitere Aktionen",
|
||||||
|
disabled: provisional,
|
||||||
|
onclick: () => {
|
||||||
|
openMenuId = openMenuId === item.id ? null : item.id;
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
}, "⋯");
|
||||||
|
|
||||||
|
const pack = formatPack(item.pack_size, item.pack_unit);
|
||||||
|
|
||||||
|
const shared = meta && meta.member_count > 1;
|
||||||
|
const by = shared && item.created_by_name ? `von ${item.created_by_name}` : "";
|
||||||
|
const sub = [pack, item.variant, item.note, by].filter(Boolean).join(" · ");
|
||||||
|
|
||||||
|
return el("li", {
|
||||||
|
className: [
|
||||||
|
"item",
|
||||||
|
bought ? "bought" : "",
|
||||||
|
deferred ? "deferred" : "",
|
||||||
|
item.pending ? "pending" : "",
|
||||||
|
].filter(Boolean).join(" "),
|
||||||
|
},
|
||||||
|
el("div.item-main", {},
|
||||||
|
check,
|
||||||
|
// Stückzahl direkt am Artikel: Beim Einkaufen ist "wie viele"
|
||||||
|
// die wichtigste Angabe und darf nicht im Untertitel untergehen.
|
||||||
|
el("span", {
|
||||||
|
className: `item-count${item.count > 1 ? " many" : ""}`,
|
||||||
|
title: item.count === 1 ? "1 Stück" : `${item.count} Stück`,
|
||||||
|
}, `${item.count || 1}\u00d7`),
|
||||||
|
el("div.item-text", {},
|
||||||
|
el("span.name", {}, item.article_name),
|
||||||
|
sub ? el("span.sub", {}, sub) : null),
|
||||||
|
el("div.price-cell", {},
|
||||||
|
priceField,
|
||||||
|
// Bei mehreren Stück wird sichtbar, dass der Preis je Gebinde
|
||||||
|
// gilt und was zusammen daraus wird.
|
||||||
|
item.price_cents && item.count > 1
|
||||||
|
? el("span.line-total", {}, `= ${euro(item.total_cents)}`)
|
||||||
|
: null),
|
||||||
|
menuButton),
|
||||||
|
openMenuId === item.id && !provisional ? itemMenu(item) : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemMenu(item) {
|
||||||
|
const marketSelect = el("select", {
|
||||||
|
onchange: (ev) => {
|
||||||
|
const value = ev.target.value;
|
||||||
|
openMenuId = null;
|
||||||
|
change("item.update", value
|
||||||
|
? { item_id: item.id, market_id: value }
|
||||||
|
: { item_id: item.id, clear_market: true });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
el("option", { value: "" }, "— ohne Markt —"),
|
||||||
|
markets.map((m) =>
|
||||||
|
el("option", { value: m.id, selected: m.id === item.market_id }, m.name))
|
||||||
|
);
|
||||||
|
|
||||||
|
const categorySelect = el("select", {
|
||||||
|
onchange: (ev) => {
|
||||||
|
const value = ev.target.value;
|
||||||
|
openMenuId = null;
|
||||||
|
change("item.update", value
|
||||||
|
? { item_id: item.id, category_id: value }
|
||||||
|
: { item_id: item.id, clear_category: true });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
el("option", { value: "" }, "— ohne Warengruppe —"),
|
||||||
|
categories.map((c) =>
|
||||||
|
el("option", { value: c.id, selected: c.id === item.category_id }, c.name))
|
||||||
|
);
|
||||||
|
|
||||||
|
const deferred = item.status === "deferred";
|
||||||
|
|
||||||
|
const variantField = el("input", {
|
||||||
|
type: "text",
|
||||||
|
value: item.variant || "",
|
||||||
|
maxLength: 200,
|
||||||
|
placeholder: "z. B. bunt, ganz",
|
||||||
|
onchange: (ev) => change("item.update", {
|
||||||
|
item_id: item.id,
|
||||||
|
variant: ev.target.value.trim() || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const noteField = el("input", {
|
||||||
|
type: "text",
|
||||||
|
value: item.note || "",
|
||||||
|
maxLength: 500,
|
||||||
|
placeholder: "Bemerkung für den Einkauf",
|
||||||
|
onchange: (ev) => change("item.update", {
|
||||||
|
item_id: item.id,
|
||||||
|
note: ev.target.value.trim() || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const hint = priceHints.get(item.article_id);
|
||||||
|
const hintBlock = hint ? (() => {
|
||||||
|
const bestNames = hint.best_market_ids
|
||||||
|
.map((id) => markets.find((m) => m.id === id)?.name)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
const hereCents = item.market_id ? hint.prices[item.market_id] : null;
|
||||||
|
if (!bestNames) return null;
|
||||||
|
|
||||||
|
const alreadyBest = item.market_id
|
||||||
|
&& hint.best_market_ids.includes(item.market_id);
|
||||||
|
|
||||||
|
return el("p.price-tip", {},
|
||||||
|
alreadyBest
|
||||||
|
? `Hier am günstigsten: ${euro(hint.best_cents)}.`
|
||||||
|
: `Zuletzt günstigster Markt: ${bestNames} für ${euro(hint.best_cents)}`,
|
||||||
|
!alreadyBest && hereCents
|
||||||
|
? ` – hier zuletzt ${euro(hereCents)}.`
|
||||||
|
: !alreadyBest ? "." : "");
|
||||||
|
})() : null;
|
||||||
|
|
||||||
|
const countEdit = el("input", {
|
||||||
|
type: "text",
|
||||||
|
inputMode: "numeric",
|
||||||
|
value: String(item.count || 1),
|
||||||
|
title: "Stückzahl",
|
||||||
|
onchange: (ev) => change("item.update", {
|
||||||
|
item_id: item.id, count: parseCount(ev.target.value),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const packSizeEdit = el("input.qty", {
|
||||||
|
type: "text",
|
||||||
|
inputMode: "decimal",
|
||||||
|
value: item.pack_size ? formatQuantity(item.pack_size) : "",
|
||||||
|
placeholder: "Gebinde",
|
||||||
|
onchange: (ev) => change("item.update", {
|
||||||
|
item_id: item.id, pack_size: parseQuantity(ev.target.value),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const packUnitEdit = el("input.unit", {
|
||||||
|
type: "text",
|
||||||
|
maxLength: 32,
|
||||||
|
value: item.pack_unit || "",
|
||||||
|
placeholder: "Einheit",
|
||||||
|
onchange: (ev) => change("item.update", {
|
||||||
|
item_id: item.id, pack_unit: ev.target.value.trim() || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return el("div.item-menu", {},
|
||||||
|
hintBlock,
|
||||||
|
el("label", {}, "Anzahl und Gebinde",
|
||||||
|
el("span.hint", {}, " (Preis gilt je Gebinde)")),
|
||||||
|
el("div.add-fields", {}, countEdit, packSizeEdit, packUnitEdit),
|
||||||
|
el("label", {}, "Eigenschaft"), variantField,
|
||||||
|
el("label", {}, "Notiz"), noteField,
|
||||||
|
el("label", {}, "Markt"), marketSelect,
|
||||||
|
el("label", {}, "Warengruppe"), categorySelect,
|
||||||
|
el("div.menu-actions", {},
|
||||||
|
el("button", {
|
||||||
|
type: "button",
|
||||||
|
onclick: () => {
|
||||||
|
openMenuId = null;
|
||||||
|
change("item.update", {
|
||||||
|
item_id: item.id,
|
||||||
|
status: deferred ? "open" : "deferred",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}, deferred ? "Wieder aufnehmen" : "Zurückstellen"),
|
||||||
|
el("button.danger", {
|
||||||
|
type: "button",
|
||||||
|
onclick: () => {
|
||||||
|
openMenuId = null;
|
||||||
|
change("item.delete", { item_id: item.id });
|
||||||
|
},
|
||||||
|
}, "Löschen"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function marketSection(market) {
|
||||||
|
return el("section.market", {},
|
||||||
|
el("h2", {},
|
||||||
|
el("span", {}, market.market_name),
|
||||||
|
el("span.market-meta", {},
|
||||||
|
market.open_count === 1 ? "1 offen" : `${market.open_count} offen`,
|
||||||
|
market.total_cents ? ` · ${euro(market.total_cents)}` : "")),
|
||||||
|
market.categories.map((cat) =>
|
||||||
|
el("div.category", {},
|
||||||
|
el("h3", {}, cat.category_name),
|
||||||
|
el("ul.items", {}, cat.items.map(itemRow))))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRow() {
|
||||||
|
const nameField = el("input", {
|
||||||
|
id: "add-name",
|
||||||
|
type: "text",
|
||||||
|
maxLength: 200,
|
||||||
|
placeholder: "Artikel hinzufügen",
|
||||||
|
autocomplete: "off",
|
||||||
|
onkeydown: (ev) => { if (ev.key === "Enter") addItem(); },
|
||||||
|
});
|
||||||
|
// Anzahl und Gebinde getrennt: Der Preis gilt je Gebinde, die
|
||||||
|
// Summe ist Preis mal Anzahl. Steckte beides in einem Feld, ergaben
|
||||||
|
// 500 ml zu 2,99 EUR eine Summe von 1495 EUR.
|
||||||
|
const countField = el("input.count", {
|
||||||
|
type: "text", inputMode: "numeric", placeholder: "Anz.", value: "",
|
||||||
|
title: "Stückzahl – wie viele Packungen",
|
||||||
|
onkeydown: (ev) => { if (ev.key === "Enter") addItem(); },
|
||||||
|
});
|
||||||
|
const qtyField = el("input.qty", {
|
||||||
|
type: "text", inputMode: "decimal", placeholder: "Gebinde",
|
||||||
|
title: "Packungsgröße, z. B. 500",
|
||||||
|
onkeydown: (ev) => { if (ev.key === "Enter") addItem(); },
|
||||||
|
});
|
||||||
|
const unitField = el("input.unit", {
|
||||||
|
type: "text", maxLength: 32, placeholder: "Einheit",
|
||||||
|
title: "z. B. ml, g, Stk",
|
||||||
|
onkeydown: (ev) => { if (ev.key === "Enter") addItem(); },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Vorschläge aus dem, was auf dieser Liste schon vorkommt - so muss
|
||||||
|
// "bunt, ganz" nur einmal getippt werden.
|
||||||
|
const suggestions = new Set();
|
||||||
|
for (const market of view?.markets || []) {
|
||||||
|
for (const cat of market.categories) {
|
||||||
|
for (const item of cat.items) {
|
||||||
|
if (item.variant) suggestions.add(item.variant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const variantList = el("datalist", { id: "variant-suggestions" },
|
||||||
|
[...suggestions].sort((a, b) => a.localeCompare(b, "de"))
|
||||||
|
.map((v) => el("option", { value: v })));
|
||||||
|
|
||||||
|
const variantField = el("input.variant", {
|
||||||
|
type: "text",
|
||||||
|
maxLength: 200,
|
||||||
|
placeholder: "Eigenschaft",
|
||||||
|
title: "Nähere Bestimmung, z. B. „bunt, ganz“ oder „laktosefrei“",
|
||||||
|
autocomplete: "off",
|
||||||
|
list: "variant-suggestions",
|
||||||
|
onkeydown: (ev) => { if (ev.key === "Enter") addItem(); },
|
||||||
|
});
|
||||||
|
// el() setzt "list" nicht als Eigenschaft - das Attribut muss direkt
|
||||||
|
// gesetzt werden, sonst findet der Browser die Vorschlagsliste nicht.
|
||||||
|
variantField.setAttribute("list", "variant-suggestions");
|
||||||
|
|
||||||
|
async function addItem() {
|
||||||
|
const name = nameField.value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
const payload = {
|
||||||
|
article_name: name,
|
||||||
|
count: parseCount(countField.value),
|
||||||
|
pack_size: parseQuantity(qtyField.value),
|
||||||
|
pack_unit: unitField.value.trim() || null,
|
||||||
|
variant: variantField.value.trim() || null,
|
||||||
|
};
|
||||||
|
nameField.value = "";
|
||||||
|
countField.value = "";
|
||||||
|
qtyField.value = "";
|
||||||
|
unitField.value = "";
|
||||||
|
variantField.value = "";
|
||||||
|
await change("item.create", payload);
|
||||||
|
document.getElementById("add-name")?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strichcode scannen, auflösen, bestätigen, hinzufügen.
|
||||||
|
*
|
||||||
|
* Braucht Verbindung: Sowohl der eigene Artikelstamm als auch die
|
||||||
|
* Produktdatenbank liegen auf dem Server. Offline bliebe eine
|
||||||
|
* Ziffernfolge ohne Bedeutung - deshalb hier ehrlich abbrechen,
|
||||||
|
* statt etwas Unbrauchbares in die Warteschlange zu legen. */
|
||||||
|
async function scanAndAdd() {
|
||||||
|
const code = await scanBarcode();
|
||||||
|
if (!code) return;
|
||||||
|
|
||||||
|
if (!state.online) {
|
||||||
|
say("Zum Scannen wird eine Verbindung gebraucht – die Zuordnung " +
|
||||||
|
"des Codes steht auf dem Server.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lookup;
|
||||||
|
try {
|
||||||
|
lookup = await get(
|
||||||
|
`/api/lists/${listId}/barcode/${encodeURIComponent(code)}`);
|
||||||
|
} catch (err) {
|
||||||
|
say(err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bereits im eigenen Bestand: ohne Rückfrage auf die Liste.
|
||||||
|
if (lookup.source === "catalog") {
|
||||||
|
await change("item.create", {
|
||||||
|
article_id: lookup.article_id,
|
||||||
|
barcode: code,
|
||||||
|
count: parseCount(countField.value),
|
||||||
|
pack_size: parseQuantity(qtyField.value),
|
||||||
|
pack_unit: unitField.value.trim() || null,
|
||||||
|
variant: variantField.value.trim() || null,
|
||||||
|
});
|
||||||
|
countField.value = "";
|
||||||
|
qtyField.value = "";
|
||||||
|
unitField.value = "";
|
||||||
|
variantField.value = "";
|
||||||
|
say(`„${lookup.name}“ hinzugefügt.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sonst bestätigen lassen - auch bei einem Treffer in der
|
||||||
|
// Produktdatenbank. Fremde Angaben sollen nicht ungeprüft in den
|
||||||
|
// eigenen Artikelstamm wandern.
|
||||||
|
const confirmed = await confirmScan(lookup);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
await change("item.create", {
|
||||||
|
article_name: confirmed.name,
|
||||||
|
barcode: code,
|
||||||
|
count: parseCount(confirmed.count),
|
||||||
|
pack_size: parseQuantity(confirmed.packSize),
|
||||||
|
pack_unit: confirmed.packUnit || null,
|
||||||
|
variant: confirmed.variant || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aufbau: Name über die volle Breite, darunter die Detailfelder,
|
||||||
|
// darunter die Schaltflächen. Vorher standen Felder und
|
||||||
|
// Schaltflächen in einer Zeile - auf schmalen Geräten lief der
|
||||||
|
// letzte Knopf aus der Box.
|
||||||
|
return el("div.add-row", {},
|
||||||
|
nameField,
|
||||||
|
el("div.add-fields", {}, countField, qtyField, unitField, variantField),
|
||||||
|
el("div.add-actions", {},
|
||||||
|
cameraAvailable()
|
||||||
|
? el("button.secondary", {
|
||||||
|
type: "button",
|
||||||
|
title: "Strichcode scannen",
|
||||||
|
onclick: scanAndAdd,
|
||||||
|
}, "Scannen")
|
||||||
|
: null,
|
||||||
|
el("button.primary", { type: "button", onclick: addItem }, "Hinzufügen")),
|
||||||
|
variantList
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLine() {
|
||||||
|
const pendingCount = view?.pending_ops || 0;
|
||||||
|
|
||||||
|
if (!state.online) {
|
||||||
|
return el("p.sync-hint.offline", {},
|
||||||
|
"Offline – Änderungen werden gespeichert und später übertragen",
|
||||||
|
pendingCount ? ` (${pendingCount} wartend).` : ".");
|
||||||
|
}
|
||||||
|
if (pendingCount) {
|
||||||
|
return el("p.sync-hint.pending", {},
|
||||||
|
pendingCount === 1
|
||||||
|
? "1 Änderung wird übertragen …"
|
||||||
|
: `${pendingCount} Änderungen werden übertragen …`);
|
||||||
|
}
|
||||||
|
if (state.syncState === "error") {
|
||||||
|
return el("p.sync-hint.problem", {},
|
||||||
|
"Der Server ist gerade nicht erreichbar. ",
|
||||||
|
el("button.linklike", {
|
||||||
|
type: "button",
|
||||||
|
onclick: () => refresh({ fromServer: true }),
|
||||||
|
}, "Erneut versuchen"));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Gesamtansicht
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!view) {
|
||||||
|
mount(root, el("p.loading", {}, "Wird geladen …"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const boughtCount = view.markets.reduce(
|
||||||
|
(sum, m) => sum + m.categories.reduce(
|
||||||
|
(n, c) => n + c.items.filter((i) => i.status === "bought").length, 0), 0);
|
||||||
|
|
||||||
|
mount(root,
|
||||||
|
el("header.bar", {},
|
||||||
|
el("button.linklike", { type: "button", onclick: back }, "‹ Listen"),
|
||||||
|
el("span.bar-actions", {},
|
||||||
|
// Serverseitiger Ausdruck: eigene Seite, damit auch ohne
|
||||||
|
// geöffnete App gedruckt werden kann.
|
||||||
|
el("a.linklike", {
|
||||||
|
href: `/api/lists/${listId}/print`,
|
||||||
|
target: "_blank",
|
||||||
|
rel: "noopener",
|
||||||
|
}, "Drucken"),
|
||||||
|
el("button.linklike", { type: "button", onclick: () => prices(listId) },
|
||||||
|
"Preise"),
|
||||||
|
el("button.linklike", { type: "button", onclick: () => articles(listId) },
|
||||||
|
"Artikel"),
|
||||||
|
el("button.linklike", { type: "button", onclick: () => manage(listId) },
|
||||||
|
"Märkte & Gruppen"),
|
||||||
|
meta && meta.may_share_public
|
||||||
|
? el("button.linklike", { type: "button", onclick: () => share(listId) },
|
||||||
|
meta.member_count > 1 ? `Teilen (${meta.member_count})` : "Teilen")
|
||||||
|
: null)),
|
||||||
|
|
||||||
|
statusLine(),
|
||||||
|
banner ? el("p.error", {}, banner) : null,
|
||||||
|
|
||||||
|
el("section.card", {},
|
||||||
|
el("h1", {}, view.list_name),
|
||||||
|
addRow()),
|
||||||
|
|
||||||
|
view.markets.length
|
||||||
|
? view.markets.map(marketSection)
|
||||||
|
: el("p.empty", {}, "Die Liste ist leer."),
|
||||||
|
|
||||||
|
el("footer.summary", {},
|
||||||
|
el("div.total", {},
|
||||||
|
el("span", {}, "Gesamt"),
|
||||||
|
el("strong", {}, euro(view.grand_total_cents))),
|
||||||
|
boughtCount
|
||||||
|
? el("button.secondary", {
|
||||||
|
type: "button",
|
||||||
|
onclick: () => change("items.clear_bought", {}),
|
||||||
|
}, boughtCount === 1
|
||||||
|
? "1 gekauften Eintrag entfernen"
|
||||||
|
: `${boughtCount} gekaufte Einträge entfernen`)
|
||||||
|
: null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Start
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Erst den lokalen Stand zeigen - die App startet dadurch sofort,
|
||||||
|
// auch ohne Verbindung.
|
||||||
|
await readLocal();
|
||||||
|
render();
|
||||||
|
|
||||||
|
// Dann im Hintergrund abgleichen.
|
||||||
|
await syncNow(listId);
|
||||||
|
await readLocal();
|
||||||
|
await loadHints();
|
||||||
|
render();
|
||||||
|
|
||||||
|
// Änderungen anderer Geräte.
|
||||||
|
watch(listId, async () => {
|
||||||
|
await readLocal();
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => { readLocal().then(render); };
|
||||||
|
}
|
||||||
232
web/html/js/views/lists.js
Normal file
232
web/html/js/views/lists.js
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
// Übersicht aller Listen des angemeldeten Nutzers.
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
import { del, get, patch, post } from "../api.js";
|
||||||
|
import * as db from "../db.js";
|
||||||
|
import { clear, el, mount } from "../dom.js";
|
||||||
|
import { set, state } from "../store.js";
|
||||||
|
|
||||||
|
// Welche Zeile hat gerade ihr Menü offen
|
||||||
|
let openMenuId = null;
|
||||||
|
// Welche Zeile wird gerade umbenannt
|
||||||
|
let renamingId = null;
|
||||||
|
|
||||||
|
const ROLE_LABEL = { owner: "Eigentümer", editor: "Bearbeiter", viewer: "Nur lesen" };
|
||||||
|
|
||||||
|
export async function listsView(root, { openList, onLogout, settings }) {
|
||||||
|
let banner = null;
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
set({ lists: await get("/api/lists") });
|
||||||
|
}
|
||||||
|
|
||||||
|
function say(message, kind = "error") {
|
||||||
|
banner = { message, kind };
|
||||||
|
render();
|
||||||
|
setTimeout(() => { banner = null; render(); }, 6000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function guarded(fn) {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
await reload();
|
||||||
|
render();
|
||||||
|
} catch (err) {
|
||||||
|
say(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Eine Zeile
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function row(entry) {
|
||||||
|
const isOwner = entry.role === "owner";
|
||||||
|
|
||||||
|
if (renamingId === entry.id) {
|
||||||
|
const field = el("input", {
|
||||||
|
type: "text",
|
||||||
|
value: entry.name,
|
||||||
|
maxLength: 120,
|
||||||
|
onkeydown: (ev) => {
|
||||||
|
if (ev.key === "Enter") commit();
|
||||||
|
if (ev.key === "Escape") { renamingId = null; render(); }
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function commit() {
|
||||||
|
const name = field.value.trim();
|
||||||
|
if (!name) {
|
||||||
|
say("Der Name darf nicht leer sein.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renamingId = null;
|
||||||
|
if (name === entry.name) {
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
guarded(() => patch(`/api/lists/${entry.id}`, { name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = el("li.list-row.renaming", {},
|
||||||
|
el("div.row", {}, field,
|
||||||
|
el("button.primary", { type: "button", onclick: commit }, "Speichern"),
|
||||||
|
el("button", {
|
||||||
|
type: "button",
|
||||||
|
className: "cancel",
|
||||||
|
onclick: () => { renamingId = null; render(); },
|
||||||
|
}, "Abbrechen"))
|
||||||
|
);
|
||||||
|
queueMicrotask(() => { field.focus(); field.select(); });
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
const people = entry.member_count === 1
|
||||||
|
? "nur ich"
|
||||||
|
: `${entry.member_count} Personen`;
|
||||||
|
|
||||||
|
return el("li.list-row", {},
|
||||||
|
el("div.list-main", {},
|
||||||
|
el("button.list-open", { type: "button", onclick: () => openList(entry.id) },
|
||||||
|
el("span.name", {}, entry.name),
|
||||||
|
el("span.meta", {}, `${people} · ${ROLE_LABEL[entry.role] || entry.role}`)),
|
||||||
|
el("button.menu-toggle", {
|
||||||
|
type: "button",
|
||||||
|
title: "Weitere Aktionen",
|
||||||
|
"aria-label": `Aktionen für ${entry.name}`,
|
||||||
|
onclick: () => {
|
||||||
|
openMenuId = openMenuId === entry.id ? null : entry.id;
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
}, "⋯")),
|
||||||
|
|
||||||
|
openMenuId === entry.id
|
||||||
|
? el("div.list-menu", {},
|
||||||
|
isOwner
|
||||||
|
? el("div.menu-actions", {},
|
||||||
|
el("button", {
|
||||||
|
type: "button",
|
||||||
|
onclick: () => {
|
||||||
|
openMenuId = null;
|
||||||
|
renamingId = entry.id;
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
}, "Umbenennen"),
|
||||||
|
el("button.danger", {
|
||||||
|
type: "button",
|
||||||
|
onclick: () => remove(entry),
|
||||||
|
}, "Löschen"))
|
||||||
|
: el("div.menu-actions", {},
|
||||||
|
el("button.danger", {
|
||||||
|
type: "button",
|
||||||
|
onclick: () => leave(entry),
|
||||||
|
}, "Liste verlassen")))
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(entry) {
|
||||||
|
const extra = entry.member_count > 1
|
||||||
|
? `\n\n${entry.member_count - 1} weitere Person(en) verlieren damit den Zugriff.`
|
||||||
|
: "";
|
||||||
|
if (!confirm(`Liste „${entry.name}“ löschen?${extra}`)) return;
|
||||||
|
openMenuId = null;
|
||||||
|
guarded(async () => {
|
||||||
|
await del(`/api/lists/${entry.id}`);
|
||||||
|
// Zwischenspeicher der gelöschten Liste mit aufräumen, sonst
|
||||||
|
// bleibt ein verwaister Stand in IndexedDB liegen.
|
||||||
|
await forgetLocally(entry.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function leave(entry) {
|
||||||
|
if (!confirm(
|
||||||
|
`Liste „${entry.name}“ verlassen?\n\n` +
|
||||||
|
"Du siehst sie danach nicht mehr. Der Eigentümer kann dich erneut einladen."
|
||||||
|
)) return;
|
||||||
|
openMenuId = null;
|
||||||
|
guarded(async () => {
|
||||||
|
await post(`/api/lists/${entry.id}/leave`);
|
||||||
|
await forgetLocally(entry.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forgetLocally(listId) {
|
||||||
|
await Promise.allSettled([
|
||||||
|
db.cacheDelete(`view:${listId}`),
|
||||||
|
db.cacheDelete(`markets:${listId}`),
|
||||||
|
db.cacheDelete(`categories:${listId}`),
|
||||||
|
db.cacheDelete(`meta:${listId}`),
|
||||||
|
db.clearList(listId),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Neue Liste
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function createRow() {
|
||||||
|
const field = el("input", {
|
||||||
|
id: "new-list",
|
||||||
|
type: "text",
|
||||||
|
maxLength: 120,
|
||||||
|
placeholder: "z. B. Wocheneinkauf",
|
||||||
|
onkeydown: (ev) => { if (ev.key === "Enter") create(); },
|
||||||
|
});
|
||||||
|
const button = el("button.primary", { type: "button", onclick: () => create() },
|
||||||
|
"Anlegen");
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
const name = field.value.trim();
|
||||||
|
if (!name) {
|
||||||
|
say("Bitte einen Namen eingeben.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
button.disabled = true;
|
||||||
|
try {
|
||||||
|
await post("/api/lists", { name });
|
||||||
|
field.value = "";
|
||||||
|
await reload();
|
||||||
|
// Ohne diesen Aufruf blieb die neue Liste unsichtbar, bis die
|
||||||
|
// Seite neu geladen wurde: Der Zustandsspeicher war aktuell,
|
||||||
|
// die Ansicht hatte sich aber nie dafür angemeldet.
|
||||||
|
render();
|
||||||
|
document.getElementById("new-list")?.focus();
|
||||||
|
} catch (err) {
|
||||||
|
say(err.message);
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return el("div.row", {}, field, button);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Gesamtansicht
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
mount(root,
|
||||||
|
el("header.bar", {},
|
||||||
|
el("span.who", {}, state.user.display_name || state.user.email),
|
||||||
|
el("span.bar-actions", {},
|
||||||
|
el("button.linklike", { type: "button", onclick: settings }, "Einstellungen"),
|
||||||
|
el("button.linklike", { type: "button", onclick: onLogout }, "Abmelden"))),
|
||||||
|
|
||||||
|
banner ? el(banner.kind === "error" ? "p.error" : "p.notice", {}, banner.message) : null,
|
||||||
|
|
||||||
|
el("section.card", {},
|
||||||
|
el("h1", {}, "Meine Listen"),
|
||||||
|
state.lists.length
|
||||||
|
? el("ul.lists", {}, state.lists.map(row))
|
||||||
|
: el("p.empty", {}, "Noch keine Liste vorhanden."),
|
||||||
|
el("label", { htmlFor: "new-list" }, "Neue Liste"),
|
||||||
|
createRow())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await reload();
|
||||||
|
render();
|
||||||
|
return render;
|
||||||
|
}
|
||||||
186
web/html/js/views/manage.js
Normal file
186
web/html/js/views/manage.js
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
// Märkte und Warengruppen pflegen.
|
||||||
|
//
|
||||||
|
// Die Reihenfolge wird gezogen, nicht getippt: `sort_order` verwaltet die
|
||||||
|
// App selbst und schreibt nach jedem Verschieben 10, 20, 30 … zurück. Die
|
||||||
|
// Abstände lassen Platz, falls später einmal einzeln eingefügt werden soll.
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
import { del, get, post, put } from "../api.js";
|
||||||
|
import { clear, el, mount } from "../dom.js";
|
||||||
|
import { makeSortable } from "../sortable.js";
|
||||||
|
import { set, state } from "../store.js";
|
||||||
|
|
||||||
|
const STEP = 10;
|
||||||
|
|
||||||
|
export async function manageView(root, { listId, back }) {
|
||||||
|
let banner = null;
|
||||||
|
let listName = "";
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
const [markets, categories, meta] = await Promise.all([
|
||||||
|
get(`/api/lists/${listId}/markets`),
|
||||||
|
get(`/api/lists/${listId}/categories`),
|
||||||
|
get(`/api/lists/${listId}`),
|
||||||
|
]);
|
||||||
|
listName = meta.name;
|
||||||
|
set({ markets, categories });
|
||||||
|
}
|
||||||
|
|
||||||
|
function say(message, kind = "error") {
|
||||||
|
banner = { message, kind };
|
||||||
|
render();
|
||||||
|
setTimeout(() => { banner = null; render(); }, 6000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function guarded(fn, { silent = false } = {}) {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
await reload();
|
||||||
|
render();
|
||||||
|
} catch (err) {
|
||||||
|
if (!silent) say(err.message);
|
||||||
|
await reload().catch(() => {});
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schreibt die neue Reihenfolge zurück - nur die Einträge, deren Rang
|
||||||
|
* sich tatsächlich geändert hat. */
|
||||||
|
async function persistOrder(entries, endpoint, orderedIds) {
|
||||||
|
const byId = new Map(entries.map((e) => [e.id, e]));
|
||||||
|
const updates = [];
|
||||||
|
|
||||||
|
orderedIds.forEach((id, index) => {
|
||||||
|
const entry = byId.get(id);
|
||||||
|
const next = (index + 1) * STEP;
|
||||||
|
if (entry && entry.sort_order !== next) {
|
||||||
|
updates.push(put(`/api/lists/${listId}/${endpoint}/${entry.id}`, {
|
||||||
|
name: entry.name,
|
||||||
|
sort_order: next,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updates.length) return;
|
||||||
|
await guarded(() => Promise.all(updates));
|
||||||
|
}
|
||||||
|
|
||||||
|
function section({ title, entries, endpoint, hint, emptyText }) {
|
||||||
|
const nameField = el("input", {
|
||||||
|
type: "text",
|
||||||
|
maxLength: 120,
|
||||||
|
placeholder: "Name",
|
||||||
|
onkeydown: (ev) => { if (ev.key === "Enter") add(); },
|
||||||
|
});
|
||||||
|
|
||||||
|
function add() {
|
||||||
|
const name = nameField.value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
guarded(async () => {
|
||||||
|
// Neues ans Ende: ein Schritt hinter dem letzten Rang.
|
||||||
|
const last = entries.length ? Math.max(...entries.map((e) => e.sort_order)) : 0;
|
||||||
|
await post(`/api/lists/${listId}/${endpoint}`, {
|
||||||
|
name,
|
||||||
|
sort_order: last + STEP,
|
||||||
|
});
|
||||||
|
nameField.value = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = el("ul.sortable", {});
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const handle = el("button.grip", {
|
||||||
|
type: "button",
|
||||||
|
title: "Zum Verschieben ziehen – oder mit den Pfeiltasten bewegen",
|
||||||
|
"aria-label": `${entry.name} verschieben`,
|
||||||
|
}, "⠿");
|
||||||
|
|
||||||
|
const name = el("input.entry-name", {
|
||||||
|
type: "text",
|
||||||
|
value: entry.name,
|
||||||
|
maxLength: 120,
|
||||||
|
onchange: (ev) => {
|
||||||
|
const value = ev.target.value.trim();
|
||||||
|
if (!value) {
|
||||||
|
ev.target.value = entry.name;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
guarded(() => put(`/api/lists/${listId}/${endpoint}/${entry.id}`, {
|
||||||
|
name: value,
|
||||||
|
sort_order: entry.sort_order,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = el("button.danger.remove", {
|
||||||
|
type: "button",
|
||||||
|
title: `${entry.name} löschen`,
|
||||||
|
"aria-label": `${entry.name} löschen`,
|
||||||
|
onclick: () => {
|
||||||
|
if (!confirm(`„${entry.name}“ löschen?`)) return;
|
||||||
|
guarded(() => del(`/api/lists/${listId}/${endpoint}/${entry.id}`));
|
||||||
|
},
|
||||||
|
}, "×");
|
||||||
|
|
||||||
|
list.append(el("li.sortable-row", { dataset: { id: entry.id } },
|
||||||
|
handle, name, remove));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.length > 1) {
|
||||||
|
makeSortable(list, {
|
||||||
|
handleSelector: ".grip",
|
||||||
|
itemSelector: ".sortable-row",
|
||||||
|
onReorder: (ids) => persistOrder(entries, endpoint, ids),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return el("section.card", {},
|
||||||
|
el("h2", {}, title),
|
||||||
|
hint ? el("p.lead", {}, hint) : null,
|
||||||
|
entries.length ? list : el("p.empty", {}, emptyText),
|
||||||
|
el("label", { htmlFor: `add-${endpoint}` }, "Hinzufügen"),
|
||||||
|
el("div.row", {},
|
||||||
|
Object.assign(nameField, { id: `add-${endpoint}` }),
|
||||||
|
el("button.primary", { type: "button", onclick: add }, "Anlegen"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
mount(root,
|
||||||
|
el("header.bar", {},
|
||||||
|
el("button.linklike", { type: "button", onclick: back },
|
||||||
|
"‹ Zurück zur Liste"),
|
||||||
|
listName ? el("span.who", {}, listName) : null),
|
||||||
|
|
||||||
|
banner ? el(banner.kind === "error" ? "p.error" : "p.notice", {}, banner.message) : null,
|
||||||
|
|
||||||
|
section({
|
||||||
|
title: "Märkte",
|
||||||
|
entries: state.markets,
|
||||||
|
endpoint: "markets",
|
||||||
|
hint: "Die Reihenfolge bestimmt, in welcher Folge die Märkte in der " +
|
||||||
|
"Liste und im Ausdruck erscheinen. Am Anfasser ziehen, um sie " +
|
||||||
|
"zu ändern.",
|
||||||
|
emptyText: "Noch kein Markt angelegt.",
|
||||||
|
}),
|
||||||
|
|
||||||
|
section({
|
||||||
|
title: "Warengruppen",
|
||||||
|
entries: state.categories,
|
||||||
|
endpoint: "categories",
|
||||||
|
hint: "Innerhalb eines Marktes wird nach diesen Gruppen gegliedert; " +
|
||||||
|
"die Artikel darin stehen alphabetisch.",
|
||||||
|
emptyText: "Noch keine Warengruppe angelegt.",
|
||||||
|
}),
|
||||||
|
|
||||||
|
el("p.footnote", {},
|
||||||
|
"Ein gelöschter Markt nimmt keine Einträge mit – sie landen in der " +
|
||||||
|
"Gruppe „Ohne Markt“.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await reload();
|
||||||
|
render();
|
||||||
|
return render;
|
||||||
|
}
|
||||||
164
web/html/js/views/prices.js
Normal file
164
web/html/js/views/prices.js
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
// Preisvergleich zwischen Märkten.
|
||||||
|
//
|
||||||
|
// Bewusst keine Matrix Artikel × Markt: Bei vier Märkten und dreißig
|
||||||
|
// Artikeln wird die auf einem Telefon unlesbar. Stattdessen je Artikel
|
||||||
|
// eine Karte mit den Märkten nach Preis sortiert - man sucht ohnehin
|
||||||
|
// "wo ist X am günstigsten", nicht "wie sieht die ganze Tabelle aus".
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
import { get } from "../api.js";
|
||||||
|
import { el, euro, formatQuantity, mount } from "../dom.js";
|
||||||
|
|
||||||
|
let expandedId = null;
|
||||||
|
|
||||||
|
function formatDate(iso) {
|
||||||
|
return new Date(iso).toLocaleDateString("de-DE", {
|
||||||
|
day: "2-digit", month: "2-digit", year: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preis je Einheit, geliefert in Zehntelcent. */
|
||||||
|
function unitPrice(deci, unit) {
|
||||||
|
if (!deci || !unit) return null;
|
||||||
|
return `${euro(deci / 10)} je ${unit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pricesView(root, { listId, back }) {
|
||||||
|
let data = null;
|
||||||
|
let banner = null;
|
||||||
|
let detail = null;
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
data = await get(`/api/lists/${listId}/prices`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function say(message) {
|
||||||
|
banner = message;
|
||||||
|
render();
|
||||||
|
setTimeout(() => { banner = null; render(); }, 6000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggle(articleId) {
|
||||||
|
if (expandedId === articleId) {
|
||||||
|
expandedId = null;
|
||||||
|
detail = null;
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expandedId = articleId;
|
||||||
|
detail = null;
|
||||||
|
render();
|
||||||
|
try {
|
||||||
|
detail = await get(`/api/lists/${listId}/articles/${articleId}/prices`);
|
||||||
|
render();
|
||||||
|
} catch (err) {
|
||||||
|
say(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function marketRow(row, marketId, cents, isBest) {
|
||||||
|
return el("div", { className: `price-row${isBest ? " best" : ""}` },
|
||||||
|
el("span.market", {}, data.market_names[marketId] || "?"),
|
||||||
|
el("span.amount", {}, euro(cents)),
|
||||||
|
isBest && row.spread_cents > 0
|
||||||
|
? el("span.badge", {}, "günstigster")
|
||||||
|
: row.spread_cents > 0
|
||||||
|
? el("span.diff", {}, `+${euro(cents - row.best_cents)}`)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function detailBlock() {
|
||||||
|
if (!detail) return el("p.loading", {}, "Wird geladen …");
|
||||||
|
|
||||||
|
return el("div.price-detail", {},
|
||||||
|
el("h4", {}, "Beobachtungen je Markt"),
|
||||||
|
el("ul.price-markets", {}, detail.markets.map((m) =>
|
||||||
|
el("li", {},
|
||||||
|
el("span.market", {}, m.market_name),
|
||||||
|
el("span.sub", {},
|
||||||
|
m.latest_pack_size
|
||||||
|
? `${formatQuantity(m.latest_pack_size)} ${m.latest_pack_unit || ""} · `
|
||||||
|
: "",
|
||||||
|
unitPrice(m.unit_price_deci, m.latest_pack_unit)
|
||||||
|
? `${unitPrice(m.unit_price_deci, m.latest_pack_unit)} · `
|
||||||
|
: "",
|
||||||
|
m.observations === 1
|
||||||
|
? "einmal erfasst"
|
||||||
|
: `${m.observations}× erfasst, ${euro(m.min_cents)}–${euro(m.max_cents)}`,
|
||||||
|
` · zuletzt ${formatDate(m.latest_at)}`),
|
||||||
|
el("span.amount", {}, euro(m.latest_cents))))),
|
||||||
|
|
||||||
|
detail.history.length > 1
|
||||||
|
? el("details.price-history", {},
|
||||||
|
el("summary", {}, `Verlauf (${detail.history.length} Einträge)`),
|
||||||
|
el("ul", {}, detail.history.map((h) =>
|
||||||
|
el("li", {},
|
||||||
|
`${formatDate(h.recorded_at)} · ${h.market_name} · ${euro(h.price_cents)}`,
|
||||||
|
h.pack_size
|
||||||
|
? ` (${formatQuantity(h.pack_size)} ${h.pack_unit || ""})` : ""))))
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function articleCard(row) {
|
||||||
|
const entries = Object.entries(row.prices).sort((a, b) => a[1] - b[1]);
|
||||||
|
|
||||||
|
return el("section.card.price-card", {},
|
||||||
|
el("button.price-head", {
|
||||||
|
type: "button",
|
||||||
|
onclick: () => toggle(row.article_id),
|
||||||
|
"aria-expanded": expandedId === row.article_id ? "true" : "false",
|
||||||
|
},
|
||||||
|
el("span.name", {}, row.article_name),
|
||||||
|
row.spread_cents > 0
|
||||||
|
? el("span.spread", {}, `bis ${euro(row.spread_cents)} Unterschied`)
|
||||||
|
: el("span.spread", {}, "überall gleich")),
|
||||||
|
|
||||||
|
el("div.price-rows", {}, entries.map(([marketId, cents]) =>
|
||||||
|
marketRow(row, marketId, cents, row.best_market_ids.includes(marketId)))),
|
||||||
|
|
||||||
|
expandedId === row.article_id ? detailBlock() : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const withSpread = data.rows.filter((r) => r.spread_cents > 0);
|
||||||
|
const savings = withSpread.reduce((sum, r) => sum + r.spread_cents, 0);
|
||||||
|
|
||||||
|
mount(root,
|
||||||
|
el("header.bar", {},
|
||||||
|
el("button.linklike", { type: "button", onclick: back },
|
||||||
|
"‹ Zurück zur Liste")),
|
||||||
|
|
||||||
|
banner ? el("p.error", {}, banner) : null,
|
||||||
|
|
||||||
|
el("section.card", {},
|
||||||
|
el("h1", {}, "Preise"),
|
||||||
|
data.rows.length
|
||||||
|
? el("p.lead", {},
|
||||||
|
`${data.rows.length} Artikel mit erfassten Preisen. `,
|
||||||
|
withSpread.length
|
||||||
|
? `Bei ${withSpread.length} davon unterscheiden sich die Märkte – ` +
|
||||||
|
`zusammen ${euro(savings)} Unterschied, wenn jeder Artikel ` +
|
||||||
|
`im günstigsten Markt gekauft würde.`
|
||||||
|
: "Bislang gibt es keine Preisunterschiede zwischen den Märkten.")
|
||||||
|
: el("p.empty", {},
|
||||||
|
"Noch keine Preise erfasst. Trag beim Einkaufen Preise an den " +
|
||||||
|
"Artikeln ein – sobald ein Eintrag einem Markt zugeordnet ist, " +
|
||||||
|
"wird der Preis hier gesammelt.")),
|
||||||
|
|
||||||
|
data.rows.map(articleCard),
|
||||||
|
|
||||||
|
data.rows.length
|
||||||
|
? el("p.footnote", {},
|
||||||
|
"Erfasst werden Markt, Artikel, Betrag und Zeitpunkt – ohne " +
|
||||||
|
"Angabe, wer den Preis eingetragen hat.")
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await reload();
|
||||||
|
render();
|
||||||
|
return render;
|
||||||
|
}
|
||||||
143
web/html/js/views/public.js
Normal file
143
web/html/js/views/public.js
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
// Öffentliche Ansicht einer Liste über /s/<token>. Kein Konto nötig.
|
||||||
|
// Nur ansehen und - sofern erlaubt - abhaken. Auf Druck ausgelegt.
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
import { get, post } from "../api.js";
|
||||||
|
import { clear, el, euro, formatPack, mount } from "../dom.js";
|
||||||
|
|
||||||
|
function formatDate(iso) {
|
||||||
|
return new Date(iso).toLocaleDateString("de-DE", {
|
||||||
|
day: "2-digit", month: "2-digit", year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publicView(root, { token }) {
|
||||||
|
let view = null;
|
||||||
|
let banner = null;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
view = await get(`/api/public/${encodeURIComponent(token)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function say(message, kind = "error") {
|
||||||
|
banner = { message, kind };
|
||||||
|
render();
|
||||||
|
setTimeout(() => { banner = null; render(); }, 6000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemRow(item) {
|
||||||
|
const bought = item.status === "bought";
|
||||||
|
|
||||||
|
const check = view.allow_check
|
||||||
|
? el("button.check", {
|
||||||
|
type: "button",
|
||||||
|
"aria-pressed": bought ? "true" : "false",
|
||||||
|
title: bought ? "Als offen markieren" : "Als gekauft markieren",
|
||||||
|
onclick: async () => {
|
||||||
|
const next = bought ? "open" : "bought";
|
||||||
|
item.status = next; // sofort anzeigen
|
||||||
|
render();
|
||||||
|
try {
|
||||||
|
await post(`/api/public/${encodeURIComponent(token)}/items/${item.id}`,
|
||||||
|
{ status: next });
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
await load().catch(() => {});
|
||||||
|
say(err.message);
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
}, bought ? "✓" : "")
|
||||||
|
: el("span.check.readonly", { "aria-hidden": "true" }, bought ? "✓" : "");
|
||||||
|
|
||||||
|
const pack = formatPack(item.pack_size, item.pack_unit);
|
||||||
|
|
||||||
|
return el("li", { className: `item${bought ? " bought" : ""}` },
|
||||||
|
el("div.item-main", {},
|
||||||
|
check,
|
||||||
|
el("span", {
|
||||||
|
className: `item-count${item.count > 1 ? " many" : ""}`,
|
||||||
|
}, `${item.count || 1}\u00d7`),
|
||||||
|
el("div.item-text", {},
|
||||||
|
el("span.name", {}, item.article_name),
|
||||||
|
pack || item.variant || item.note
|
||||||
|
? el("span.sub", {},
|
||||||
|
[pack, item.variant, item.note].filter(Boolean).join(" · "))
|
||||||
|
: null),
|
||||||
|
item.price_cents
|
||||||
|
? el("span.price-static", {},
|
||||||
|
euro(item.total_cents ?? item.price_cents))
|
||||||
|
: null
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
mount(root,
|
||||||
|
el("header.public-head", {},
|
||||||
|
el("h1", {}, view.list_name),
|
||||||
|
el("p.sub", {},
|
||||||
|
"Geteilte Ansicht",
|
||||||
|
view.allow_check ? " – du kannst Artikel abhaken." : " – nur zum Ansehen.",
|
||||||
|
` Gültig bis ${formatDate(view.expires_at)}.`)
|
||||||
|
),
|
||||||
|
banner ? el("p.error", {}, banner.message) : null,
|
||||||
|
|
||||||
|
view.markets.length
|
||||||
|
? view.markets.map((market) =>
|
||||||
|
el("section.market", {},
|
||||||
|
el("h2", {},
|
||||||
|
el("span", {}, market.market_name),
|
||||||
|
el("span.market-meta", {},
|
||||||
|
market.open_count === 1 ? "1 offen" : `${market.open_count} offen`,
|
||||||
|
market.total_cents ? ` · ${euro(market.total_cents)}` : "")),
|
||||||
|
market.categories.map((cat) =>
|
||||||
|
el("div.category", {},
|
||||||
|
el("h3", {}, cat.category_name),
|
||||||
|
el("ul.items", {}, cat.items.map(itemRow))))))
|
||||||
|
: el("p.empty", {}, "Die Liste ist leer."),
|
||||||
|
|
||||||
|
el("footer.summary", {},
|
||||||
|
el("div.total", {},
|
||||||
|
el("span", {}, "Gesamt"),
|
||||||
|
el("strong", {}, euro(view.grand_total_cents)))),
|
||||||
|
|
||||||
|
el("p.footnote.no-print", {},
|
||||||
|
// Eigene Druckseite statt window.print(): Sie ist auf A4
|
||||||
|
// ausgelegt und funktioniert auch, wenn der Empfänger die
|
||||||
|
// Ansicht nur weiterleiten will.
|
||||||
|
el("a.linklike", {
|
||||||
|
href: `/api/public/${encodeURIComponent(token)}/print`,
|
||||||
|
target: "_blank",
|
||||||
|
rel: "noopener",
|
||||||
|
}, "Drucken"),
|
||||||
|
" · ",
|
||||||
|
el("button.linklike", {
|
||||||
|
type: "button",
|
||||||
|
onclick: async () => {
|
||||||
|
try {
|
||||||
|
await load();
|
||||||
|
render();
|
||||||
|
} catch (err) {
|
||||||
|
say(err.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}, "Aktualisieren"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
mount(root,
|
||||||
|
el("section.card", {},
|
||||||
|
el("h1", {}, "Liste nicht verfügbar"),
|
||||||
|
el("p.error", {}, err.message),
|
||||||
|
el("p.lead", {},
|
||||||
|
"Bitte die Person, die den Link geteilt hat, um einen neuen."))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
render();
|
||||||
|
}
|
||||||
110
web/html/js/views/scan-result.js
Normal file
110
web/html/js/views/scan-result.js
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
// Bestätigungsdialog nach einem Scan.
|
||||||
|
//
|
||||||
|
// Ersetzt das frühere prompt(): Dort ließ sich nur ein Name eingeben,
|
||||||
|
// und die Herkunft der Angaben war nicht erkennbar. Wenn Daten aus einer
|
||||||
|
// fremden Quelle vorbelegt werden, soll man sehen, woher sie stammen -
|
||||||
|
// und sie ändern können, bevor sie im eigenen Bestand landen.
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
import { el } from "../dom.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} lookup Antwort von /api/lists/{id}/barcode/{code}
|
||||||
|
* @returns {Promise<{name: string, quantity: string, unit: string, variant: string}|null>}
|
||||||
|
*/
|
||||||
|
export function confirmScan(lookup) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const known = lookup.found && lookup.source === "openfoodfacts";
|
||||||
|
|
||||||
|
const nameField = el("input", {
|
||||||
|
type: "text",
|
||||||
|
maxLength: 200,
|
||||||
|
value: lookup.name || "",
|
||||||
|
placeholder: "Wie heißt der Artikel?",
|
||||||
|
onkeydown: (ev) => { if (ev.key === "Enter") accept(); },
|
||||||
|
});
|
||||||
|
const countField = el("input.count", {
|
||||||
|
type: "text",
|
||||||
|
inputMode: "numeric",
|
||||||
|
value: lookup.count != null ? String(lookup.count) : "",
|
||||||
|
placeholder: "Anz.",
|
||||||
|
title: "Stückzahl",
|
||||||
|
});
|
||||||
|
const qtyField = el("input.qty", {
|
||||||
|
type: "text",
|
||||||
|
inputMode: "decimal",
|
||||||
|
value: lookup.pack_size != null
|
||||||
|
? String(lookup.pack_size).replace(".", ",") : "",
|
||||||
|
placeholder: "Gebinde",
|
||||||
|
title: "Packungsgröße",
|
||||||
|
});
|
||||||
|
const unitField = el("input.unit", {
|
||||||
|
type: "text",
|
||||||
|
maxLength: 32,
|
||||||
|
value: lookup.pack_unit || "",
|
||||||
|
placeholder: "Einheit",
|
||||||
|
});
|
||||||
|
const variantField = el("input.variant", {
|
||||||
|
type: "text",
|
||||||
|
maxLength: 200,
|
||||||
|
value: lookup.brand || "",
|
||||||
|
placeholder: "Eigenschaft",
|
||||||
|
});
|
||||||
|
|
||||||
|
function accept() {
|
||||||
|
const name = nameField.value.trim();
|
||||||
|
if (!name) {
|
||||||
|
nameField.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
close({
|
||||||
|
name,
|
||||||
|
count: countField.value,
|
||||||
|
packSize: qtyField.value,
|
||||||
|
packUnit: unitField.value.trim(),
|
||||||
|
variant: variantField.value.trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const dialog = el("div.modal", {},
|
||||||
|
el("div.modal-card", {},
|
||||||
|
el("h2", {}, known ? "Produkt gefunden" : "Neuer Artikel"),
|
||||||
|
el("p.sub", {}, `Strichcode ${lookup.barcode}`),
|
||||||
|
|
||||||
|
known
|
||||||
|
? el("p.source-note", {},
|
||||||
|
"Vorgeschlagen aus Open Food Facts",
|
||||||
|
lookup.package ? ` – Packung: ${lookup.package}` : "",
|
||||||
|
". Bitte prüfen und bei Bedarf anpassen; übernommen wird, " +
|
||||||
|
"was hier steht.")
|
||||||
|
: el("p.lead", {},
|
||||||
|
"Zu diesem Code liegen keine Angaben vor. Trag den Namen ein – " +
|
||||||
|
"beim nächsten Scan wird der Artikel dann sofort gefunden."),
|
||||||
|
|
||||||
|
el("label", {}, "Name"), nameField,
|
||||||
|
el("div.add-fields", {}, countField, qtyField, unitField, variantField),
|
||||||
|
|
||||||
|
el("div.menu-actions", {},
|
||||||
|
el("button.primary", { type: "button", onclick: accept }, "Hinzufügen"),
|
||||||
|
el("button", { type: "button", onclick: () => close(null) }, "Abbrechen"))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
function close(result) {
|
||||||
|
dialog.remove();
|
||||||
|
window.removeEventListener("keydown", onKey);
|
||||||
|
document.body.classList.remove("modal-open");
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKey(ev) {
|
||||||
|
if (ev.key === "Escape") close(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
document.body.classList.add("modal-open");
|
||||||
|
document.body.append(dialog);
|
||||||
|
nameField.focus();
|
||||||
|
nameField.select();
|
||||||
|
});
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user