Erste Produktivversion

This commit is contained in:
2026-08-08 20:31:58 +02:00
parent d8ded2816d
commit 7b21e2d1b4
110 changed files with 18170 additions and 644 deletions

22
backend/Dockerfile Normal file
View 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
View 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
View 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()

View 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"}

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

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

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

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

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

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

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

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

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

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

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

View 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
View File

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

View 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
View 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
View 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 }}&times;</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,
)

View 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
View 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()

View File

View 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."
)

View 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
View 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.")

View 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."
)

View 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
)

View 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.")

View 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)

View 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
View 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."
)

View 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
View 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
View 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

View 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

View 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

View 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]

View 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
View 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
View 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}

View 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
View 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
View 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