Erste Produktivversion
This commit is contained in:
22
backend/Dockerfile
Normal file
22
backend/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
# Nicht als root laufen lassen.
|
||||
RUN useradd --create-home --uid 10001 appuser \
|
||||
&& chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Ueber "sh" aufrufen: das Bind-Mount ./backend:/app ueberschreibt /app
|
||||
# samt Dateirechten, ein x-Bit aus dem Image waere dann wirkungslos.
|
||||
CMD ["sh", "/app/entrypoint.sh"]
|
||||
40
backend/alembic.ini
Normal file
40
backend/alembic.ini
Normal file
@@ -0,0 +1,40 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
|
||||
# Kein sqlalchemy.url hier: die Verbindungsdaten kommen aus der
|
||||
# Umgebung und werden in alembic/env.py direkt an create_engine()
|
||||
# uebergeben. configparser wuerde sonst an "%" im Passwort scheitern.
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
52
backend/alembic/env.py
Normal file
52
backend/alembic/env.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import create_engine, pool
|
||||
|
||||
from app.config import settings
|
||||
from app.db import Base
|
||||
import app.models # noqa: F401 - registriert die Tabellen bei Base
|
||||
|
||||
config = context.config
|
||||
|
||||
# WICHTIG: die Datenbank-URL NICHT ueber config.set_main_option() setzen.
|
||||
# alembic.ini wird von configparser mit BasicInterpolation gelesen, und
|
||||
# der deutet jedes "%" als Variablenreferenz. Ein URL-kodiertes Passwort
|
||||
# (z.B. "%21" fuer "!") laesst den Aufruf mit
|
||||
# ValueError: invalid interpolation syntax
|
||||
# scheitern. Die Engine wird deshalb hier direkt gebaut.
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=settings.database_url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = create_engine(settings.database_url, poolclass=pool.NullPool)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
connectable.dispose()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
24
backend/alembic/script.py.mako
Normal file
24
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: str | None = ${repr(down_revision)}
|
||||
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
|
||||
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
102
backend/alembic/versions/0001_initial.py
Normal file
102
backend/alembic/versions/0001_initial.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Phase 1: Benutzer, Sessions, Mail-Token, Einstellungen, Rate Limiting
|
||||
|
||||
Revision ID: 0001
|
||||
Revises:
|
||||
Create Date: 2026-08-07
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0001"
|
||||
down_revision: str | None = None
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"user",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("email", sa.String(255), nullable=False),
|
||||
sa.Column("display_name", sa.String(80), nullable=True),
|
||||
sa.Column("password_hash", sa.String(255), nullable=False),
|
||||
sa.Column("verified_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("is_admin", sa.Boolean(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("1")),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("email", name="uq_user_email"),
|
||||
mysql_engine="InnoDB",
|
||||
mysql_charset="utf8mb4",
|
||||
mysql_collate="utf8mb4_unicode_ci",
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"user_session",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("token_hash", sa.String(64), nullable=False),
|
||||
sa.Column("user_id", sa.String(36), nullable=False),
|
||||
sa.Column("csrf_token", sa.String(64), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
sa.Column("last_seen_at", sa.DateTime(), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
sa.Column("expires_at", sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
|
||||
sa.UniqueConstraint("token_hash", name="uq_user_session_token"),
|
||||
mysql_engine="InnoDB",
|
||||
mysql_charset="utf8mb4",
|
||||
mysql_collate="utf8mb4_unicode_ci",
|
||||
)
|
||||
op.create_index("ix_user_session_expires", "user_session", ["expires_at"])
|
||||
|
||||
op.create_table(
|
||||
"email_token",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("token_hash", sa.String(64), nullable=False),
|
||||
sa.Column("user_id", sa.String(36), nullable=False),
|
||||
sa.Column("purpose", sa.String(32), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("used_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
|
||||
sa.UniqueConstraint("token_hash", name="uq_email_token_token"),
|
||||
mysql_engine="InnoDB",
|
||||
mysql_charset="utf8mb4",
|
||||
mysql_collate="utf8mb4_unicode_ci",
|
||||
)
|
||||
op.create_index("ix_email_token_user_purpose", "email_token", ["user_id", "purpose"])
|
||||
|
||||
op.create_table(
|
||||
"setting",
|
||||
sa.Column("key", sa.String(64), primary_key=True),
|
||||
sa.Column("value", sa.String(255), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
mysql_engine="InnoDB",
|
||||
mysql_charset="utf8mb4",
|
||||
mysql_collate="utf8mb4_unicode_ci",
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"rate_limit",
|
||||
sa.Column("bucket", sa.String(160), primary_key=True),
|
||||
sa.Column("window_start", sa.DateTime(), primary_key=True),
|
||||
sa.Column("count", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
mysql_engine="InnoDB",
|
||||
mysql_charset="utf8mb4",
|
||||
mysql_collate="utf8mb4_unicode_ci",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("rate_limit")
|
||||
op.drop_table("setting")
|
||||
op.drop_index("ix_email_token_user_purpose", table_name="email_token")
|
||||
op.drop_table("email_token")
|
||||
op.drop_index("ix_user_session_expires", table_name="user_session")
|
||||
op.drop_table("user_session")
|
||||
op.drop_table("user")
|
||||
153
backend/alembic/versions/0002_lists.py
Normal file
153
backend/alembic/versions/0002_lists.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""Phase 2: Listen, Mitgliedschaften, Märkte, Warengruppen, Artikel, Einträge
|
||||
|
||||
Revision ID: 0002
|
||||
Revises: 0001
|
||||
Create Date: 2026-08-07
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0002"
|
||||
down_revision: str | None = "0001"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
MYSQL = {
|
||||
"mysql_engine": "InnoDB",
|
||||
"mysql_charset": "utf8mb4",
|
||||
"mysql_collate": "utf8mb4_unicode_ci",
|
||||
}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"shopping_list",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("name", sa.String(120), nullable=False),
|
||||
sa.Column("owner_id", sa.String(36), nullable=False),
|
||||
sa.Column("rev", sa.BigInteger(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("deleted_at", sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["owner_id"], ["user.id"], ondelete="RESTRICT"),
|
||||
**MYSQL,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"list_member",
|
||||
sa.Column("list_id", sa.String(36), primary_key=True),
|
||||
sa.Column("user_id", sa.String(36), primary_key=True),
|
||||
sa.Column("role", sa.String(16), nullable=False, server_default="editor"),
|
||||
sa.Column("joined_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
|
||||
**MYSQL,
|
||||
)
|
||||
op.create_index("ix_list_member_user", "list_member", ["user_id"])
|
||||
|
||||
op.create_table(
|
||||
"market",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("list_id", sa.String(36), nullable=False),
|
||||
sa.Column("name", sa.String(120), nullable=False),
|
||||
sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("row_rev", sa.BigInteger(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("deleted_at", sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
|
||||
sa.UniqueConstraint("list_id", "name", name="uq_market_list_name"),
|
||||
**MYSQL,
|
||||
)
|
||||
op.create_index("ix_market_list_rev", "market", ["list_id", "row_rev"])
|
||||
|
||||
op.create_table(
|
||||
"category",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("list_id", sa.String(36), nullable=False),
|
||||
sa.Column("name", sa.String(120), nullable=False),
|
||||
sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("row_rev", sa.BigInteger(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("deleted_at", sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
|
||||
sa.UniqueConstraint("list_id", "name", name="uq_category_list_name"),
|
||||
**MYSQL,
|
||||
)
|
||||
op.create_index("ix_category_list_rev", "category", ["list_id", "row_rev"])
|
||||
|
||||
op.create_table(
|
||||
"article",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("list_id", sa.String(36), nullable=False),
|
||||
sa.Column("name", sa.String(200), nullable=False),
|
||||
sa.Column("barcode", sa.String(64), nullable=True),
|
||||
sa.Column("note", sa.String(500), nullable=True),
|
||||
sa.Column("default_market_id", sa.String(36), nullable=True),
|
||||
sa.Column("default_category_id", sa.String(36), nullable=True),
|
||||
sa.Column("row_rev", sa.BigInteger(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("deleted_at", sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["default_market_id"], ["market.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["default_category_id"], ["category.id"], ondelete="SET NULL"),
|
||||
sa.UniqueConstraint("list_id", "name", name="uq_article_list_name"),
|
||||
**MYSQL,
|
||||
)
|
||||
op.create_index("ix_article_list_rev", "article", ["list_id", "row_rev"])
|
||||
op.create_index("ix_article_barcode", "article", ["list_id", "barcode"])
|
||||
|
||||
op.create_table(
|
||||
"article_attribute",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("article_id", sa.String(36), nullable=False),
|
||||
sa.Column("attr_name", sa.String(80), nullable=False),
|
||||
sa.Column("attr_value", sa.String(300), nullable=False),
|
||||
sa.ForeignKeyConstraint(["article_id"], ["article.id"], ondelete="CASCADE"),
|
||||
sa.UniqueConstraint("article_id", "attr_name", name="uq_article_attr"),
|
||||
**MYSQL,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"article_market",
|
||||
sa.Column("article_id", sa.String(36), primary_key=True),
|
||||
sa.Column("market_id", sa.String(36), primary_key=True),
|
||||
sa.ForeignKeyConstraint(["article_id"], ["article.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["market_id"], ["market.id"], ondelete="CASCADE"),
|
||||
**MYSQL,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"list_item",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("list_id", sa.String(36), nullable=False),
|
||||
sa.Column("article_id", sa.String(36), nullable=False),
|
||||
sa.Column("market_id", sa.String(36), nullable=True),
|
||||
sa.Column("category_id", sa.String(36), nullable=True),
|
||||
sa.Column("quantity", sa.Numeric(10, 3), nullable=True),
|
||||
sa.Column("unit", sa.String(32), nullable=True),
|
||||
sa.Column("note", sa.String(500), nullable=True),
|
||||
sa.Column("status", sa.String(16), nullable=False, server_default="open"),
|
||||
sa.Column("price_cents", sa.Integer(), nullable=True),
|
||||
sa.Column("row_rev", sa.BigInteger(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("deleted_at", sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["article_id"], ["article.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["market_id"], ["market.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["category_id"], ["category.id"], ondelete="SET NULL"),
|
||||
**MYSQL,
|
||||
)
|
||||
op.create_index("ix_list_item_list_rev", "list_item", ["list_id", "row_rev"])
|
||||
op.create_index("ix_list_item_list_status", "list_item", ["list_id", "status"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("list_item")
|
||||
op.drop_table("article_market")
|
||||
op.drop_table("article_attribute")
|
||||
op.drop_table("article")
|
||||
op.drop_table("category")
|
||||
op.drop_table("market")
|
||||
op.drop_index("ix_list_member_user", table_name="list_member")
|
||||
op.drop_table("list_member")
|
||||
op.drop_table("shopping_list")
|
||||
31
backend/alembic/versions/0003_must_change_password.py
Normal file
31
backend/alembic/versions/0003_must_change_password.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Phase 2b: Zwang zum Passwortwechsel beim initial angelegten Admin
|
||||
|
||||
Revision ID: 0003
|
||||
Revises: 0002
|
||||
Create Date: 2026-08-07
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0003"
|
||||
down_revision: str | None = "0002"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"user",
|
||||
sa.Column(
|
||||
"must_change_password",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("0"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("user", "must_change_password")
|
||||
60
backend/alembic/versions/0004_sharing.py
Normal file
60
backend/alembic/versions/0004_sharing.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Phase 5: Einladungen, Eigentümerwechsel, Ersteller je Eintrag
|
||||
|
||||
Revision ID: 0004
|
||||
Revises: 0003
|
||||
Create Date: 2026-08-08
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0004"
|
||||
down_revision: str | None = "0003"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"list_item",
|
||||
sa.Column("created_by", sa.String(36), nullable=True),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_list_item_created_by", "list_item", "user",
|
||||
["created_by"], ["id"], ondelete="SET NULL",
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"list_invite",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("list_id", sa.String(36), nullable=False),
|
||||
sa.Column("email", sa.String(255), nullable=False),
|
||||
sa.Column("role", sa.String(16), nullable=False, server_default="editor"),
|
||||
sa.Column("token_hash", sa.String(64), nullable=False),
|
||||
sa.Column("invited_by", sa.String(36), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("last_sent_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("send_count", sa.Integer(), nullable=False, server_default=sa.text("1")),
|
||||
sa.Column("expires_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("accepted_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("accepted_by", sa.String(36), nullable=True),
|
||||
sa.Column("revoked_at", sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["invited_by"], ["user.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["accepted_by"], ["user.id"], ondelete="SET NULL"),
|
||||
sa.UniqueConstraint("token_hash", name="uq_list_invite_token"),
|
||||
mysql_engine="InnoDB",
|
||||
mysql_charset="utf8mb4",
|
||||
mysql_collate="utf8mb4_unicode_ci",
|
||||
)
|
||||
op.create_index("ix_list_invite_list", "list_invite", ["list_id"])
|
||||
op.create_index("ix_list_invite_email", "list_invite", ["email"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_list_invite_email", table_name="list_invite")
|
||||
op.drop_index("ix_list_invite_list", table_name="list_invite")
|
||||
op.drop_table("list_invite")
|
||||
op.drop_constraint("fk_list_item_created_by", "list_item", type_="foreignkey")
|
||||
op.drop_column("list_item", "created_by")
|
||||
56
backend/alembic/versions/0005_public_links.py
Normal file
56
backend/alembic/versions/0005_public_links.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Phase 5b: Öffentliche Ansichtslinks und Zusatzrecht zum Teilen
|
||||
|
||||
Revision ID: 0005
|
||||
Revises: 0004
|
||||
Create Date: 2026-08-08
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0005"
|
||||
down_revision: str | None = "0004"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"list_member",
|
||||
sa.Column(
|
||||
"may_share_public", sa.Boolean(), nullable=False,
|
||||
server_default=sa.text("0"),
|
||||
),
|
||||
)
|
||||
# Eigentuemer haben das Recht immer - der Vollstaendigkeit halber
|
||||
# auch in der Spalte, damit Abfragen einheitlich bleiben.
|
||||
op.execute("UPDATE list_member SET may_share_public = 1 WHERE role = 'owner'")
|
||||
|
||||
op.create_table(
|
||||
"public_share",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("list_id", sa.String(36), nullable=False),
|
||||
sa.Column("token_hash", sa.String(64), nullable=False),
|
||||
sa.Column("label", sa.String(120), nullable=True),
|
||||
sa.Column("allow_check", sa.Boolean(), nullable=False, server_default=sa.text("1")),
|
||||
sa.Column("created_by", sa.String(36), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("expires_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("revoked_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("last_access_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("access_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["created_by"], ["user.id"], ondelete="SET NULL"),
|
||||
sa.UniqueConstraint("token_hash", name="uq_public_share_token"),
|
||||
mysql_engine="InnoDB",
|
||||
mysql_charset="utf8mb4",
|
||||
mysql_collate="utf8mb4_unicode_ci",
|
||||
)
|
||||
op.create_index("ix_public_share_list", "public_share", ["list_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_public_share_list", table_name="public_share")
|
||||
op.drop_table("public_share")
|
||||
op.drop_column("list_member", "may_share_public")
|
||||
39
backend/alembic/versions/0006_sync.py
Normal file
39
backend/alembic/versions/0006_sync.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Phase 4: Idempotenz-Quittungen für die Outbox
|
||||
|
||||
Revision ID: 0006
|
||||
Revises: 0005
|
||||
Create Date: 2026-08-08
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0006"
|
||||
down_revision: str | None = "0005"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"applied_op",
|
||||
sa.Column("op_id", sa.String(64), primary_key=True),
|
||||
sa.Column("list_id", sa.String(36), nullable=False),
|
||||
sa.Column("user_id", sa.String(36), nullable=True),
|
||||
sa.Column("kind", sa.String(32), nullable=False),
|
||||
sa.Column("result_id", sa.String(36), nullable=True),
|
||||
sa.Column("rev", sa.BigInteger(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="SET NULL"),
|
||||
mysql_engine="InnoDB",
|
||||
mysql_charset="utf8mb4",
|
||||
mysql_collate="utf8mb4_unicode_ci",
|
||||
)
|
||||
op.create_index("ix_applied_op_list", "applied_op", ["list_id", "created_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_applied_op_list", table_name="applied_op")
|
||||
op.drop_table("applied_op")
|
||||
23
backend/alembic/versions/0007_item_variant.py
Normal file
23
backend/alembic/versions/0007_item_variant.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""Phase 6b: Eigenschaft je Listeneintrag
|
||||
|
||||
Revision ID: 0007
|
||||
Revises: 0006
|
||||
Create Date: 2026-08-08
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0007"
|
||||
down_revision: str | None = "0006"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("list_item", sa.Column("variant", sa.String(200), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("list_item", "variant")
|
||||
37
backend/alembic/versions/0008_product_cache.py
Normal file
37
backend/alembic/versions/0008_product_cache.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Phase 6c: Zwischenspeicher für Produktabfragen
|
||||
|
||||
Revision ID: 0008
|
||||
Revises: 0007
|
||||
Create Date: 2026-08-08
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0008"
|
||||
down_revision: str | None = "0007"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"product_cache",
|
||||
sa.Column("barcode", sa.String(64), primary_key=True),
|
||||
sa.Column("found", sa.Boolean(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("name", sa.String(300), nullable=True),
|
||||
sa.Column("brand", sa.String(200), nullable=True),
|
||||
sa.Column("package", sa.String(120), nullable=True),
|
||||
sa.Column("quantity", sa.Numeric(10, 3), nullable=True),
|
||||
sa.Column("unit", sa.String(32), nullable=True),
|
||||
sa.Column("source", sa.String(32), nullable=False, server_default="openfoodfacts"),
|
||||
sa.Column("fetched_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
mysql_engine="InnoDB",
|
||||
mysql_charset="utf8mb4",
|
||||
mysql_collate="utf8mb4_unicode_ci",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("product_cache")
|
||||
45
backend/alembic/versions/0009_prices.py
Normal file
45
backend/alembic/versions/0009_prices.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Phase 7: Preisdatenbank
|
||||
|
||||
Revision ID: 0009
|
||||
Revises: 0008
|
||||
Create Date: 2026-08-08
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0009"
|
||||
down_revision: str | None = "0008"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"price_point",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("list_id", sa.String(36), nullable=False),
|
||||
sa.Column("article_id", sa.String(36), nullable=False),
|
||||
sa.Column("market_id", sa.String(36), nullable=False),
|
||||
sa.Column("price_cents", sa.Integer(), nullable=False),
|
||||
sa.Column("quantity", sa.Numeric(10, 3), nullable=True),
|
||||
sa.Column("unit", sa.String(32), nullable=True),
|
||||
sa.Column("recorded_at", sa.DateTime(), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["article_id"], ["article.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["market_id"], ["market.id"], ondelete="CASCADE"),
|
||||
mysql_engine="InnoDB",
|
||||
mysql_charset="utf8mb4",
|
||||
mysql_collate="utf8mb4_unicode_ci",
|
||||
)
|
||||
op.create_index("ix_price_article_market", "price_point",
|
||||
["article_id", "market_id", "recorded_at"])
|
||||
op.create_index("ix_price_list", "price_point", ["list_id", "recorded_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_price_list", table_name="price_point")
|
||||
op.drop_index("ix_price_article_market", table_name="price_point")
|
||||
op.drop_table("price_point")
|
||||
62
backend/alembic/versions/0010_pack_and_count.py
Normal file
62
backend/alembic/versions/0010_pack_and_count.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Phase 7b: Stückzahl und Gebinde trennen
|
||||
|
||||
Bis hierher steckten beide in `quantity`/`unit`. Bei "500 ml" zu 2,99 EUR
|
||||
rechnete die Summenbildung 500 × 2,99 = 1495 EUR.
|
||||
|
||||
Die vorhandenen Werte werden als Gebinde übernommen (aus "500 ml" wird
|
||||
pack_size=500, pack_unit=ml) und die Stückzahl auf 1 gesetzt. Das ist die
|
||||
sichere Richtung: Wo bisher tatsächlich eine Stückzahl gemeint war,
|
||||
stimmt die Summe danach - sie wird nur nicht mehr vervielfacht.
|
||||
|
||||
Revision ID: 0010
|
||||
Revises: 0009
|
||||
Create Date: 2026-08-08
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0010"
|
||||
down_revision: str | None = "0009"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"list_item",
|
||||
sa.Column("count", sa.Integer(), nullable=False, server_default=sa.text("1")),
|
||||
)
|
||||
op.alter_column("list_item", "quantity",
|
||||
new_column_name="pack_size", existing_type=sa.Numeric(10, 3))
|
||||
op.alter_column("list_item", "unit",
|
||||
new_column_name="pack_unit", existing_type=sa.String(32))
|
||||
|
||||
op.alter_column("price_point", "quantity",
|
||||
new_column_name="pack_size", existing_type=sa.Numeric(10, 3))
|
||||
op.alter_column("price_point", "unit",
|
||||
new_column_name="pack_unit", existing_type=sa.String(32))
|
||||
|
||||
op.add_column("product_cache", sa.Column("count", sa.Integer(), nullable=True))
|
||||
op.alter_column("product_cache", "quantity",
|
||||
new_column_name="pack_size", existing_type=sa.Numeric(10, 3))
|
||||
op.alter_column("product_cache", "unit",
|
||||
new_column_name="pack_unit", existing_type=sa.String(32))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column("product_cache", "pack_unit",
|
||||
new_column_name="unit", existing_type=sa.String(32))
|
||||
op.alter_column("product_cache", "pack_size",
|
||||
new_column_name="quantity", existing_type=sa.Numeric(10, 3))
|
||||
op.drop_column("product_cache", "count")
|
||||
op.alter_column("price_point", "pack_unit",
|
||||
new_column_name="unit", existing_type=sa.String(32))
|
||||
op.alter_column("price_point", "pack_size",
|
||||
new_column_name="quantity", existing_type=sa.Numeric(10, 3))
|
||||
op.alter_column("list_item", "pack_unit",
|
||||
new_column_name="unit", existing_type=sa.String(32))
|
||||
op.alter_column("list_item", "pack_size",
|
||||
new_column_name="quantity", existing_type=sa.Numeric(10, 3))
|
||||
op.drop_column("list_item", "count")
|
||||
55
backend/alembic/versions/0011_push.py
Normal file
55
backend/alembic/versions/0011_push.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Phase 8: Push-Benachrichtigungen
|
||||
|
||||
Revision ID: 0011
|
||||
Revises: 0010
|
||||
Create Date: 2026-08-08
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0011"
|
||||
down_revision: str | None = "0010"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"push_subscription",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("user_id", sa.String(36), nullable=False),
|
||||
sa.Column("endpoint", sa.String(500), nullable=False),
|
||||
sa.Column("p256dh", sa.String(200), nullable=False),
|
||||
sa.Column("auth", sa.String(100), nullable=False),
|
||||
sa.Column("label", sa.String(80), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("last_success_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("failure_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
|
||||
sa.UniqueConstraint("endpoint", name="uq_push_subscription_endpoint"),
|
||||
mysql_engine="InnoDB",
|
||||
mysql_charset="utf8mb4",
|
||||
mysql_collate="utf8mb4_unicode_ci",
|
||||
)
|
||||
op.create_index("ix_push_subscription_user", "push_subscription", ["user_id"])
|
||||
|
||||
op.create_table(
|
||||
"notify_state",
|
||||
sa.Column("list_id", sa.String(36), primary_key=True),
|
||||
sa.Column("user_id", sa.String(36), primary_key=True),
|
||||
sa.Column("last_notified_at", sa.DateTime(), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
|
||||
mysql_engine="InnoDB",
|
||||
mysql_charset="utf8mb4",
|
||||
mysql_collate="utf8mb4_unicode_ci",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("notify_state")
|
||||
op.drop_index("ix_push_subscription_user", table_name="push_subscription")
|
||||
op.drop_table("push_subscription")
|
||||
59
backend/alembic/versions/0012_user_admin.py
Normal file
59
backend/alembic/versions/0012_user_admin.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Phase 10: Benutzerverwaltung, Adressänderung, automatische Bereinigung
|
||||
|
||||
Revision ID: 0012
|
||||
Revises: 0011
|
||||
Create Date: 2026-08-08
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0012"
|
||||
down_revision: str | None = "0011"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("user", sa.Column("last_seen_at", sa.DateTime(), nullable=True))
|
||||
op.add_column("user", sa.Column("deactivated_at", sa.DateTime(), nullable=True))
|
||||
|
||||
# Bestandskonten nicht sofort der automatischen Deaktivierung
|
||||
# aussetzen: Ohne Startwert waere last_seen_at NULL und damit
|
||||
# scheinbar "nie benutzt". Das Erstellungsdatum ist die
|
||||
# konservativere Annahme.
|
||||
op.execute("UPDATE `user` SET last_seen_at = created_at WHERE last_seen_at IS NULL")
|
||||
|
||||
op.create_table(
|
||||
"email_change",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("user_id", sa.String(36), nullable=False),
|
||||
sa.Column("old_email", sa.String(255), nullable=False),
|
||||
sa.Column("new_email", sa.String(255), nullable=False),
|
||||
sa.Column("token_new_hash", sa.String(64), nullable=False),
|
||||
sa.Column("token_old_hash", sa.String(64), nullable=True),
|
||||
sa.Column("requires_old", sa.Boolean(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("confirmed_new_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("confirmed_old_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("applied_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("cancelled_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("requested_by", sa.String(36), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("expires_at", sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["requested_by"], ["user.id"], ondelete="SET NULL"),
|
||||
sa.UniqueConstraint("token_new_hash", name="uq_email_change_token_new"),
|
||||
sa.UniqueConstraint("token_old_hash", name="uq_email_change_token_old"),
|
||||
mysql_engine="InnoDB",
|
||||
mysql_charset="utf8mb4",
|
||||
mysql_collate="utf8mb4_unicode_ci",
|
||||
)
|
||||
op.create_index("ix_email_change_user", "email_change", ["user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_email_change_user", table_name="email_change")
|
||||
op.drop_table("email_change")
|
||||
op.drop_column("user", "deactivated_at")
|
||||
op.drop_column("user", "last_seen_at")
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
78
backend/app/bootstrap_admin.py
Normal file
78
backend/app/bootstrap_admin.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Anlegen des ersten Administratorkontos beim Start.
|
||||
|
||||
Bewusst eng gefasst: Das Konto entsteht nur, wenn die Datenbank noch
|
||||
*gar keinen* Nutzer enthält. Damit kann ein vergessener Eintrag in der
|
||||
.env kein bestehendes Konto überschreiben und auch kein zweites
|
||||
Admin-Konto nachschieben.
|
||||
|
||||
Das angelegte Konto trägt `must_change_password`. Bis zum Wechsel sind
|
||||
alle Routen gesperrt, die an `verified_user` hängen - also alles außer
|
||||
"eigenes Profil lesen" und "Passwort ändern". Der Wert in der .env ist
|
||||
nach der ersten Anmeldung damit wertlos.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.models import User
|
||||
from app.security import hash_password, normalize_email, utcnow
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
MIN_LENGTH = 12
|
||||
|
||||
|
||||
def ensure_initial_admin(db: Session) -> None:
|
||||
password = settings.initial_admin_password
|
||||
user_count = db.scalar(select(func.count()).select_from(User)) or 0
|
||||
email = normalize_email(settings.admin_email)
|
||||
|
||||
if user_count > 0:
|
||||
if password:
|
||||
log.warning(
|
||||
"ADMIN_INITIAL_PASSWORD ist gesetzt, es existieren aber bereits "
|
||||
"%d Konten - der Wert wird ignoriert. Entferne ihn aus der .env, "
|
||||
"damit kein Passwort unnötig in der Umgebung steht.",
|
||||
user_count,
|
||||
)
|
||||
return
|
||||
|
||||
if not password:
|
||||
log.info(
|
||||
"Keine Konten vorhanden und kein ADMIN_INITIAL_PASSWORD gesetzt. "
|
||||
"Erstes Konto über POST /api/auth/register anlegen (%s wird dabei "
|
||||
"automatisch Administrator).",
|
||||
email,
|
||||
)
|
||||
return
|
||||
|
||||
if len(password) < MIN_LENGTH:
|
||||
raise RuntimeError(
|
||||
f"ADMIN_INITIAL_PASSWORD ist zu kurz (mindestens {MIN_LENGTH} Zeichen)."
|
||||
)
|
||||
|
||||
admin = User(
|
||||
email=email,
|
||||
display_name="Administrator",
|
||||
password_hash=hash_password(password),
|
||||
# Die Adresse gilt als bestätigt: Wer die .env schreiben kann,
|
||||
# hat ohnehin vollen Zugriff. Ein Verifikationsumweg brächte hier
|
||||
# keinen Sicherheitsgewinn, nur eine Abhängigkeit vom Mailversand
|
||||
# bei der Ersteinrichtung.
|
||||
verified_at=utcnow(),
|
||||
is_admin=True,
|
||||
must_change_password=True,
|
||||
)
|
||||
db.add(admin)
|
||||
db.flush()
|
||||
|
||||
log.warning(
|
||||
"Administratorkonto %s aus ADMIN_INITIAL_PASSWORD angelegt. "
|
||||
"Das Passwort MUSS bei der ersten Anmeldung geändert werden; bis dahin "
|
||||
"sind alle übrigen Funktionen gesperrt. Danach den Wert aus der .env "
|
||||
"entfernen.",
|
||||
email,
|
||||
)
|
||||
196
backend/app/config.py
Normal file
196
backend/app/config.py
Normal file
@@ -0,0 +1,196 @@
|
||||
from email.utils import parseaddr
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import field_validator, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=None, extra="ignore")
|
||||
|
||||
# ---------------- Datenbank ----------------
|
||||
db_host: str = "db"
|
||||
db_port: int = 3306
|
||||
db_name: str = "einkaufsapp"
|
||||
db_user: str = "einkaufsapp"
|
||||
db_password: str = ""
|
||||
|
||||
# ---------------- Anwendung ----------------
|
||||
# Erscheint in der Oberflaeche, im Browsertitel, auf dem
|
||||
# Startbildschirm und in allen Mails. Eine Stelle fuer alles.
|
||||
app_name: str = "Einkaufsliste"
|
||||
# Kuerzere Fassung fuer das Symbol auf dem Startbildschirm - dort ist
|
||||
# nur wenig Platz. Leer = app_name.
|
||||
app_short_name: str = ""
|
||||
|
||||
# Nachschlagen von Strichcodes in einer oeffentlichen Datenbank.
|
||||
# "off" aus - nur der eigene Artikelstamm
|
||||
# "openfoodfacts" Open Food Facts, ueber diesen Server abgefragt
|
||||
product_lookup: Literal["off", "openfoodfacts"] = "openfoodfacts"
|
||||
# Wie lange ein Treffer gilt, bevor erneut gefragt wird.
|
||||
product_cache_days: int = 180
|
||||
# Wie lange ein Fehlschlag gilt. Kuerzer, weil ein Produkt spaeter
|
||||
# eingepflegt worden sein kann.
|
||||
product_miss_days: int = 14
|
||||
product_lookup_timeout: int = 6
|
||||
|
||||
public_base_url: str = "http://localhost:8000"
|
||||
# Kein SECRET_KEY: Sitzungen und CSRF arbeiten mit serverseitig
|
||||
# gespeicherten Zufallstoken (32 Byte aus secrets.token_urlsafe),
|
||||
# nicht mit signierten Werten. Ein Signierschlüssel hätte hier keine
|
||||
# Aufgabe - und eine Einstellung, die Wichtigkeit vortäuscht, ist
|
||||
# schlechter als keine. Falls später etwas signiert werden soll,
|
||||
# gehört er wieder hinein.
|
||||
session_days: int = 30
|
||||
cookie_secure: bool = False
|
||||
|
||||
admin_email: str = "admin@example.com"
|
||||
# Nur beim allerersten Start verwendet - und auch dann nur, wenn noch
|
||||
# kein einziger Nutzer existiert. Das angelegte Konto muss das
|
||||
# Passwort bei der ersten Anmeldung wechseln, danach ist der Wert
|
||||
# hier wertlos und sollte entfernt werden.
|
||||
admin_initial_password: str = ""
|
||||
# Alternative fuer Docker Secrets: Pfad zu einer Datei, deren Inhalt
|
||||
# als Passwort dient. Hat Vorrang vor admin_initial_password.
|
||||
admin_initial_password_file: str = ""
|
||||
|
||||
# "true" = Selbstregistrierung fest an, Admin kann nichts aendern
|
||||
# "false" = fest aus, Admin kann nichts aendern
|
||||
# "admin" = die Datenbankeinstellung entscheidet, Admin darf umschalten
|
||||
allow_self_registration: Literal["true", "false", "admin"] = "admin"
|
||||
# Startwert fuer den Modus "admin", greift nur beim allerersten Start.
|
||||
self_registration_default: bool = True
|
||||
|
||||
# ---------------- Aufraeumen ----------------
|
||||
# Wie lange weich geloeschte Daten aufbewahrt werden. Solange kann
|
||||
# ein Geraet offline bleiben und beim naechsten Abgleich noch
|
||||
# erfahren, dass etwas verschwunden ist.
|
||||
cleanup_deleted_days: int = 30
|
||||
# Quittungen der Outbox. Kuerzer, weil ein Geraet seine Operationen
|
||||
# nach so langer Zeit ohnehin aufgegeben haette.
|
||||
cleanup_ops_days: int = 7
|
||||
# Abstand zwischen zwei Durchlaeufen.
|
||||
cleanup_interval_hours: int = 24
|
||||
|
||||
# ---------------- Push-Benachrichtigungen ----------------
|
||||
# Erzeugen mit: python3 tools/vapid-keys.py
|
||||
# Leer = Push abgeschaltet.
|
||||
vapid_public_key: str = ""
|
||||
vapid_private_key: str = ""
|
||||
# Kontaktadresse fuer die Push-Dienste der Browserhersteller.
|
||||
vapid_subject: str = ""
|
||||
# Innerhalb dieser Spanne bekommt ein Geraet hoechstens eine
|
||||
# Benachrichtigung je Liste - nicht bei jeder einzelnen Aenderung.
|
||||
push_throttle_hours: int = 2
|
||||
push_timeout: int = 10
|
||||
|
||||
# ---------------- SMTP-Relay ----------------
|
||||
smtp_host: str = "mailpit"
|
||||
smtp_port: int = 1025
|
||||
smtp_user: str = ""
|
||||
smtp_password: str = ""
|
||||
# "none" = unverschluesselt (nur fuer lokale Testrelays)
|
||||
# "starttls" = Klartextverbindung, dann Upgrade (typisch Port 587)
|
||||
# "ssl" = TLS von Anfang an, "implicit TLS" (typisch Port 465)
|
||||
smtp_security: Literal["none", "starttls", "ssl"] = "none"
|
||||
smtp_timeout: int = 20
|
||||
# Hostname im EHLO. Viele Relays und Spamfilter erwarten hier einen
|
||||
# FQDN, der zur sendenden Domain passt - nicht die Container-ID.
|
||||
smtp_helo_hostname: str = ""
|
||||
|
||||
# Absender in der Kopfzeile (das, was der Empfaenger sieht)
|
||||
smtp_from: str = "einkaufsapp@example.com"
|
||||
# Leer = app_name wird verwendet.
|
||||
smtp_from_name: str = ""
|
||||
# Envelope-Absender / Return-Path fuer Bounces. Leer = wie smtp_from.
|
||||
# SPF wird gegen DIESE Adresse geprueft, nicht gegen den From-Header.
|
||||
smtp_envelope_from: str = ""
|
||||
# Optional: Antworten sollen woanders hingehen als an den Absender.
|
||||
smtp_reply_to: str = ""
|
||||
smtp_max_retries: int = 3
|
||||
|
||||
@field_validator("allow_self_registration", mode="before")
|
||||
@classmethod
|
||||
def _normalize_registration(cls, v: object) -> str:
|
||||
s = str(v).strip().lower()
|
||||
if s in {"1", "yes", "on", "enabled"}:
|
||||
return "true"
|
||||
if s in {"0", "no", "off", "disabled"}:
|
||||
return "false"
|
||||
if s in {"", "db", "database", "runtime", "managed"}:
|
||||
return "admin"
|
||||
return s
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_mail(self) -> "Settings":
|
||||
if not parseaddr(self.smtp_from)[1]:
|
||||
raise ValueError("SMTP_FROM ist keine gueltige E-Mail-Adresse")
|
||||
if self.smtp_user and self.smtp_security == "none":
|
||||
raise ValueError(
|
||||
"SMTP_USER gesetzt, aber SMTP_SECURITY=none - "
|
||||
"Zugangsdaten duerfen nicht unverschluesselt uebertragen werden. "
|
||||
"Setze SMTP_SECURITY auf starttls oder ssl."
|
||||
)
|
||||
return self
|
||||
|
||||
@property
|
||||
def push_enabled(self) -> bool:
|
||||
return bool(self.vapid_public_key and self.vapid_private_key)
|
||||
|
||||
@property
|
||||
def vapid_contact(self) -> str:
|
||||
# Die Push-Dienste verlangen eine erreichbare Kontaktangabe.
|
||||
# Ohne eigene Angabe die Absenderadresse verwenden.
|
||||
return self.vapid_subject or f"mailto:{self.envelope_from}"
|
||||
|
||||
@property
|
||||
def from_name(self) -> str:
|
||||
return self.smtp_from_name or self.app_name
|
||||
|
||||
@property
|
||||
def short_name(self) -> str:
|
||||
return self.app_short_name or self.app_name
|
||||
|
||||
@property
|
||||
def initial_admin_password(self) -> str:
|
||||
"""Passwort aus Datei (Docker Secret) oder Umgebungsvariable."""
|
||||
if self.admin_initial_password_file:
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(self.admin_initial_password_file)
|
||||
if path.is_file():
|
||||
return path.read_text(encoding="utf-8").strip()
|
||||
raise ValueError(
|
||||
f"ADMIN_INITIAL_PASSWORD_FILE zeigt auf {path}, "
|
||||
"die Datei existiert nicht."
|
||||
)
|
||||
return self.admin_initial_password
|
||||
|
||||
@property
|
||||
def envelope_from(self) -> str:
|
||||
return parseaddr(self.smtp_envelope_from or self.smtp_from)[1]
|
||||
|
||||
@property
|
||||
def mail_domain(self) -> str:
|
||||
"""Domain fuer die Message-ID. Sollte zur DKIM-signierten Domain
|
||||
passen, sonst werten manche Filter das ab."""
|
||||
return self.envelope_from.rpartition("@")[2] or "localhost"
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
return (
|
||||
f"mysql+pymysql://{quote_plus(self.db_user)}:"
|
||||
f"{quote_plus(self.db_password)}@{self.db_host}:{self.db_port}/"
|
||||
f"{self.db_name}?charset=utf8mb4"
|
||||
)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
27
backend/app/db.py
Normal file
27
backend/app/db.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from collections.abc import Iterator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from app.config import settings
|
||||
|
||||
engine = create_engine(
|
||||
settings.database_url,
|
||||
pool_pre_ping=True, # tote Verbindungen nach DB-Neustart erkennen
|
||||
pool_recycle=1800, # unter MariaDBs wait_timeout bleiben
|
||||
future=True,
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def get_db() -> Iterator[Session]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
187
backend/app/deps.py
Normal file
187
backend/app/deps.py
Normal file
@@ -0,0 +1,187 @@
|
||||
from datetime import timedelta
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_db
|
||||
from app.models import Setting, User, UserSession
|
||||
from app.security import (
|
||||
hash_token,
|
||||
new_token,
|
||||
tokens_equal,
|
||||
utcnow,
|
||||
)
|
||||
|
||||
SESSION_COOKIE = "ea_session"
|
||||
CSRF_COOKIE = "ea_csrf"
|
||||
CSRF_HEADER = "X-CSRF-Token"
|
||||
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Laufzeit-Einstellungen
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def get_setting(db: Session, key: str, default: str = "") -> str:
|
||||
row = db.get(Setting, key)
|
||||
return row.value if row else default
|
||||
|
||||
|
||||
def set_setting(db: Session, key: str, value: str) -> None:
|
||||
row = db.get(Setting, key)
|
||||
if row is None:
|
||||
db.add(Setting(key=key, value=value))
|
||||
else:
|
||||
row.value = value
|
||||
|
||||
|
||||
def registration_locked_by_env() -> bool:
|
||||
"""True, wenn ALLOW_SELF_REGISTRATION hart auf true/false steht.
|
||||
Dann darf die Admin-Oberflaeche den Wert nicht aendern."""
|
||||
return settings.allow_self_registration in ("true", "false")
|
||||
|
||||
|
||||
def self_registration_enabled(db: Session) -> bool:
|
||||
mode = settings.allow_self_registration
|
||||
if mode == "true":
|
||||
return True
|
||||
if mode == "false":
|
||||
return False
|
||||
# mode == "admin": die Laufzeiteinstellung entscheidet.
|
||||
return get_setting(db, "allow_self_registration", "true") == "true"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Sessions
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def create_session(db: Session, user: User, response: Response) -> UserSession:
|
||||
raw = new_token()
|
||||
csrf = new_token()
|
||||
sess = UserSession(
|
||||
token_hash=hash_token(raw),
|
||||
user_id=user.id,
|
||||
csrf_token=csrf,
|
||||
expires_at=utcnow() + timedelta(days=settings.session_days),
|
||||
)
|
||||
db.add(sess)
|
||||
db.flush()
|
||||
|
||||
max_age = settings.session_days * 24 * 3600
|
||||
# HttpOnly: fuer JavaScript unsichtbar, damit ein XSS-Fund das Token
|
||||
# nicht abgreifen kann.
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE, raw, max_age=max_age, httponly=True,
|
||||
secure=settings.cookie_secure, samesite="lax", path="/",
|
||||
)
|
||||
# Bewusst NICHT HttpOnly: der Client muss den Wert lesen und als
|
||||
# Header zurueckschicken koennen (Double-Submit-Verfahren).
|
||||
response.set_cookie(
|
||||
CSRF_COOKIE, csrf, max_age=max_age, httponly=False,
|
||||
secure=settings.cookie_secure, samesite="lax", path="/",
|
||||
)
|
||||
return sess
|
||||
|
||||
|
||||
def destroy_session(db: Session, request: Request, response: Response) -> None:
|
||||
raw = request.cookies.get(SESSION_COOKIE)
|
||||
if raw:
|
||||
sess = db.scalar(
|
||||
select(UserSession).where(UserSession.token_hash == hash_token(raw))
|
||||
)
|
||||
if sess:
|
||||
db.delete(sess)
|
||||
response.delete_cookie(SESSION_COOKIE, path="/")
|
||||
response.delete_cookie(CSRF_COOKIE, path="/")
|
||||
|
||||
|
||||
def _load_session(db: Session, request: Request) -> UserSession | None:
|
||||
raw = request.cookies.get(SESSION_COOKIE)
|
||||
if not raw:
|
||||
return None
|
||||
sess = db.scalar(
|
||||
select(UserSession).where(UserSession.token_hash == hash_token(raw))
|
||||
)
|
||||
if sess is None:
|
||||
return None
|
||||
if sess.expires_at <= utcnow():
|
||||
db.delete(sess)
|
||||
db.commit()
|
||||
return None
|
||||
return sess
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Abhaengigkeiten fuer Routen
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
_UNSAFE = {"POST", "PUT", "PATCH", "DELETE"}
|
||||
|
||||
|
||||
def current_user(request: Request, db: DbSession) -> User:
|
||||
sess = _load_session(db, request)
|
||||
if sess is None:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Nicht angemeldet")
|
||||
|
||||
if request.method in _UNSAFE:
|
||||
supplied = request.headers.get(CSRF_HEADER, "")
|
||||
if not supplied or not tokens_equal(supplied, sess.csrf_token):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "CSRF-Token fehlt oder ungültig")
|
||||
|
||||
user = db.get(User, sess.user_id)
|
||||
if user is None or not user.is_active:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Konto nicht aktiv")
|
||||
|
||||
# Nur einmal pro Stunde schreiben, sonst erzeugt jeder Request ein
|
||||
# UPDATE. Der Zeitstempel am Konto ist die Grundlage der
|
||||
# automatischen Deaktivierung - er muss auch dann mitlaufen, wenn
|
||||
# sich jemand monatelang nicht neu anmeldet, weil die Sitzung hält.
|
||||
if utcnow() - sess.last_seen_at > timedelta(hours=1):
|
||||
sess.last_seen_at = utcnow()
|
||||
user.last_seen_at = utcnow()
|
||||
db.commit()
|
||||
|
||||
return user
|
||||
|
||||
|
||||
CurrentUser = Annotated[User, Depends(current_user)]
|
||||
|
||||
|
||||
def verified_user(user: CurrentUser) -> User:
|
||||
if user.verified_at is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN, "E-Mail-Adresse noch nicht bestätigt"
|
||||
)
|
||||
# Alles ausser GET /api/auth/me und POST /api/auth/password/change
|
||||
# haengt an dieser Abhaengigkeit - der Zwang wirkt also flaechendeckend,
|
||||
# ohne dass jede Route ihn einzeln pruefen muesste.
|
||||
if user.must_change_password:
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
"Das Startpasswort muss zuerst geändert werden: "
|
||||
"POST /api/auth/password/change",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
VerifiedUser = Annotated[User, Depends(verified_user)]
|
||||
|
||||
|
||||
def admin_user(user: VerifiedUser) -> User:
|
||||
if not user.is_admin:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "Administratorrechte erforderlich")
|
||||
return user
|
||||
|
||||
|
||||
AdminUser = Annotated[User, Depends(admin_user)]
|
||||
|
||||
|
||||
def client_ip(request: Request) -> str:
|
||||
"""Fuer Rate Limiting. Hinter einem Reverse Proxy liefert
|
||||
request.client.host die Proxy-IP - ab Phase 3 setzen wir dafuer
|
||||
ProxyHeadersMiddleware mit einer Liste vertrauenswuerdiger Hosts."""
|
||||
return request.client.host if request.client else "unknown"
|
||||
156
backend/app/list_view.py
Normal file
156
backend/app/list_view.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Aufbereitung der gruppierten Listenansicht: Markt → Warengruppe → Artikel.
|
||||
|
||||
Liegt bewusst außerhalb der Router, weil drei Stellen dieselbe Gliederung
|
||||
brauchen: die App, der öffentliche Link und der Ausdruck. Läge die Logik
|
||||
im Router, würden die drei mit der Zeit auseinanderlaufen.
|
||||
"""
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Category, ListItem, Market, ShoppingList
|
||||
from app.schemas_shopping import ItemOut, ListView, ViewCategory, ViewMarket
|
||||
|
||||
NO_MARKET = "Ohne Markt"
|
||||
NO_CATEGORY = "Ohne Warengruppe"
|
||||
|
||||
|
||||
def creator_label(item: ListItem) -> str | None:
|
||||
"""Anzeigename, sonst der lokale Teil der Adresse. Die vollständige
|
||||
E-Mail-Adresse anderer Mitglieder wird nie ausgeliefert."""
|
||||
if item.creator is None:
|
||||
return None
|
||||
return item.creator.display_name or item.creator.email.split("@")[0]
|
||||
|
||||
|
||||
def item_out(item: ListItem, *, anonymous: bool = False) -> ItemOut:
|
||||
"""`anonymous=True` für öffentliche Links: Dort ist der Empfängerkreis
|
||||
unbestimmt, deshalb darf nicht erkennbar sein, wer was eingetragen hat."""
|
||||
return ItemOut(
|
||||
id=item.id,
|
||||
article_id=item.article_id,
|
||||
article_name=item.article.name,
|
||||
created_by=None if anonymous else item.created_by,
|
||||
created_by_name=None if anonymous else creator_label(item),
|
||||
market_id=item.market_id,
|
||||
category_id=item.category_id,
|
||||
count=item.count,
|
||||
pack_size=item.pack_size,
|
||||
pack_unit=item.pack_unit,
|
||||
variant=item.variant,
|
||||
note=item.note,
|
||||
status=item.status,
|
||||
price_cents=item.price_cents,
|
||||
total_cents=(item.price_cents * item.count) if item.price_cents else None,
|
||||
row_rev=item.row_rev,
|
||||
updated_at=item.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def build_view(
|
||||
db: Session,
|
||||
lst: ShoppingList,
|
||||
*,
|
||||
include_bought: bool = True,
|
||||
include_deferred: bool = True,
|
||||
anonymous: bool = False,
|
||||
) -> ListView:
|
||||
"""Märkte und Warengruppen ordnen sich nach `sort_order`, dann nach
|
||||
Namen - so lässt sich die Reihenfolge an den tatsächlichen Weg durch
|
||||
den Laden anpassen. Artikel innerhalb einer Gruppe alphabetisch.
|
||||
|
||||
Einträge ohne Zuordnung landen in Sammelgruppen am Ende, damit nichts
|
||||
unsichtbar wird.
|
||||
"""
|
||||
wanted = {"open"}
|
||||
if include_bought:
|
||||
wanted.add("bought")
|
||||
if include_deferred:
|
||||
wanted.add("deferred")
|
||||
|
||||
items = db.scalars(
|
||||
select(ListItem).where(
|
||||
ListItem.list_id == lst.id,
|
||||
ListItem.deleted_at.is_(None),
|
||||
ListItem.status.in_(wanted),
|
||||
)
|
||||
).all()
|
||||
|
||||
markets = {
|
||||
m.id: m
|
||||
for m in db.scalars(
|
||||
select(Market).where(Market.list_id == lst.id, Market.deleted_at.is_(None))
|
||||
).all()
|
||||
}
|
||||
categories = {
|
||||
c.id: c
|
||||
for c in db.scalars(
|
||||
select(Category).where(
|
||||
Category.list_id == lst.id, Category.deleted_at.is_(None)
|
||||
)
|
||||
).all()
|
||||
}
|
||||
|
||||
buckets: dict[str | None, dict[str | None, list[ListItem]]] = {}
|
||||
for item in items:
|
||||
mid = item.market_id if item.market_id in markets else None
|
||||
cid = item.category_id if item.category_id in categories else None
|
||||
buckets.setdefault(mid, {}).setdefault(cid, []).append(item)
|
||||
|
||||
def market_key(mid: str | None) -> tuple[int, int, str]:
|
||||
if mid is None:
|
||||
return (1, 0, "") # "Ohne Markt" ganz nach hinten
|
||||
m = markets[mid]
|
||||
return (0, m.sort_order, m.name.casefold())
|
||||
|
||||
def category_key(cid: str | None) -> tuple[int, int, str]:
|
||||
if cid is None:
|
||||
return (1, 0, "")
|
||||
c = categories[cid]
|
||||
return (0, c.sort_order, c.name.casefold())
|
||||
|
||||
out_markets: list[ViewMarket] = []
|
||||
grand_total = 0
|
||||
|
||||
for mid in sorted(buckets, key=market_key):
|
||||
out_categories: list[ViewCategory] = []
|
||||
market_total = 0
|
||||
open_count = 0
|
||||
|
||||
for cid in sorted(buckets[mid], key=category_key):
|
||||
group = sorted(buckets[mid][cid], key=lambda i: i.article.name.casefold())
|
||||
for item in group:
|
||||
if item.status == "open":
|
||||
open_count += 1
|
||||
if item.price_cents:
|
||||
# Preis gilt je Gebinde, multipliziert wird mit der
|
||||
# Stückzahl - nicht mit der Packungsgröße. Vorher
|
||||
# ergaben 500 ml zu 2,99 EUR eine Summe von 1495 EUR.
|
||||
market_total += item.price_cents * item.count
|
||||
|
||||
out_categories.append(
|
||||
ViewCategory(
|
||||
category_id=cid,
|
||||
category_name=categories[cid].name if cid else NO_CATEGORY,
|
||||
items=[item_out(i, anonymous=anonymous) for i in group],
|
||||
)
|
||||
)
|
||||
|
||||
grand_total += market_total
|
||||
out_markets.append(
|
||||
ViewMarket(
|
||||
market_id=mid,
|
||||
market_name=markets[mid].name if mid else NO_MARKET,
|
||||
categories=out_categories,
|
||||
open_count=open_count,
|
||||
total_cents=market_total,
|
||||
)
|
||||
)
|
||||
|
||||
return ListView(
|
||||
list_id=lst.id,
|
||||
list_name=lst.name,
|
||||
rev=lst.rev,
|
||||
markets=out_markets,
|
||||
grand_total_cents=grand_total,
|
||||
)
|
||||
270
backend/app/mail.py
Normal file
270
backend/app/mail.py
Normal file
@@ -0,0 +1,270 @@
|
||||
import logging
|
||||
import smtplib
|
||||
import ssl
|
||||
import time
|
||||
from email.headerregistry import Address
|
||||
from email.message import EmailMessage
|
||||
from email.utils import format_datetime, make_msgid, parseaddr
|
||||
|
||||
from app.config import settings
|
||||
from app.security import utcnow
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Fehler, bei denen ein erneuter Versuch sinnlos ist: das Relay hat die
|
||||
# Nachricht endgueltig abgelehnt (4xx waere temporaer, 5xx ist permanent).
|
||||
_PERMANENT = (smtplib.SMTPRecipientsRefused, smtplib.SMTPSenderRefused,
|
||||
smtplib.SMTPAuthenticationError)
|
||||
|
||||
|
||||
def _connect() -> smtplib.SMTP:
|
||||
"""Baut die Verbindung zum Relay auf. local_hostname landet im EHLO;
|
||||
ohne diesen Wert schickt Python den Container-Hostnamen, was bei
|
||||
strengen Filtern Punkte kostet."""
|
||||
helo = settings.smtp_helo_hostname or None
|
||||
context = ssl.create_default_context()
|
||||
|
||||
if settings.smtp_security == "ssl":
|
||||
server = smtplib.SMTP_SSL(
|
||||
settings.smtp_host, settings.smtp_port,
|
||||
local_hostname=helo, context=context, timeout=settings.smtp_timeout,
|
||||
)
|
||||
else:
|
||||
server = smtplib.SMTP(
|
||||
settings.smtp_host, settings.smtp_port,
|
||||
local_hostname=helo, timeout=settings.smtp_timeout,
|
||||
)
|
||||
if settings.smtp_security == "starttls":
|
||||
server.ehlo()
|
||||
server.starttls(context=context)
|
||||
server.ehlo()
|
||||
|
||||
if settings.smtp_user:
|
||||
server.login(settings.smtp_user, settings.smtp_password)
|
||||
|
||||
return server
|
||||
|
||||
|
||||
def build_message(to: str, subject: str, body: str) -> EmailMessage:
|
||||
"""Setzt die Kopfzeilen, die Spamfilter erwarten. Fehlendes Date oder
|
||||
eine Message-ID mit fremder Domain sind zwei der haeufigsten Gruende,
|
||||
warum sonst harmlose Transaktionsmails im Spam landen."""
|
||||
msg = EmailMessage()
|
||||
|
||||
local, _, domain = parseaddr(settings.smtp_from)[1].partition("@")
|
||||
msg["From"] = Address(settings.from_name, local, domain)
|
||||
msg["To"] = to
|
||||
msg["Subject"] = subject
|
||||
msg["Date"] = format_datetime(utcnow().astimezone())
|
||||
# Domain der Message-ID sollte zur DKIM-Signatur passen.
|
||||
msg["Message-ID"] = make_msgid(domain=settings.mail_domain)
|
||||
|
||||
if settings.smtp_reply_to:
|
||||
msg["Reply-To"] = settings.smtp_reply_to
|
||||
|
||||
# RFC 3834: automatisch erzeugte Nachricht. Verhindert Abwesenheits-
|
||||
# Autoresponder und Ping-Pong-Schleifen.
|
||||
msg["Auto-Submitted"] = "auto-generated"
|
||||
msg["X-Auto-Response-Suppress"] = "All"
|
||||
|
||||
msg.set_content(body, charset="utf-8")
|
||||
return msg
|
||||
|
||||
|
||||
def send_mail(to: str, subject: str, body: str) -> bool:
|
||||
"""Blockierender Versand - immer ueber BackgroundTasks aufrufen,
|
||||
nie direkt im Request-Pfad.
|
||||
|
||||
Fehler werden geloggt, aber nicht nach oben gereicht: aus einem 500er
|
||||
koennte man sonst ableiten, ob ein Konto existiert.
|
||||
"""
|
||||
msg = build_message(to, subject, body)
|
||||
envelope = settings.envelope_from
|
||||
last: Exception | None = None
|
||||
|
||||
for attempt in range(1, settings.smtp_max_retries + 1):
|
||||
try:
|
||||
with _connect() as server:
|
||||
# from_addr getrennt vom From-Header: hierueber laeuft die
|
||||
# SPF-Pruefung und hierhin gehen Bounces (Return-Path).
|
||||
server.send_message(msg, from_addr=envelope, to_addrs=[to])
|
||||
log.info("Mail an %s versendet (Versuch %d).", to, attempt)
|
||||
return True
|
||||
except _PERMANENT as exc:
|
||||
log.error("Relay hat die Mail an %s endgueltig abgelehnt: %s", to, exc)
|
||||
return False
|
||||
except Exception as exc:
|
||||
last = exc
|
||||
if attempt < settings.smtp_max_retries:
|
||||
delay = 2 ** attempt
|
||||
log.warning(
|
||||
"Versand an %s fehlgeschlagen (Versuch %d/%d): %s - "
|
||||
"neuer Versuch in %ds",
|
||||
to, attempt, settings.smtp_max_retries, exc, delay,
|
||||
)
|
||||
time.sleep(delay)
|
||||
|
||||
log.error("Versand an %s endgueltig fehlgeschlagen: %s", to, last)
|
||||
return False
|
||||
|
||||
|
||||
def check_connection() -> tuple[bool, str]:
|
||||
"""Verbindungstest ohne Versand - fuer /api/admin/mail/check."""
|
||||
try:
|
||||
with _connect() as server:
|
||||
code, _ = server.noop()
|
||||
return True, f"Verbindung zu {settings.smtp_host}:{settings.smtp_port} ok (NOOP {code})"
|
||||
except Exception as exc:
|
||||
return False, f"{type(exc).__name__}: {exc}"
|
||||
|
||||
|
||||
def send_verification(to: str, token: str) -> None:
|
||||
url = f"{settings.public_base_url.rstrip('/')}/api/auth/verify?token={token}"
|
||||
send_mail(
|
||||
to,
|
||||
f"{settings.app_name}: E-Mail-Adresse bestätigen",
|
||||
"Hallo,\n\n"
|
||||
"bitte bestätige deine E-Mail-Adresse über den folgenden Link:\n\n"
|
||||
f"{url}\n\n"
|
||||
"Der Link ist 24 Stunden gültig.\n\n"
|
||||
"Wenn du dich nicht registriert hast, ignoriere diese Nachricht.\n",
|
||||
)
|
||||
|
||||
|
||||
def send_password_reset(to: str, token: str) -> None:
|
||||
url = f"{settings.public_base_url.rstrip('/')}/reset?token={token}"
|
||||
send_mail(
|
||||
to,
|
||||
f"{settings.app_name}: Passwort zurücksetzen",
|
||||
"Hallo,\n\n"
|
||||
"über den folgenden Link kannst du ein neues Passwort setzen:\n\n"
|
||||
f"{url}\n\n"
|
||||
"Der Link ist 1 Stunde gültig.\n\n"
|
||||
"Wenn du das nicht angefordert hast, ignoriere diese Nachricht -\n"
|
||||
"dein Passwort bleibt dann unverändert.\n",
|
||||
)
|
||||
|
||||
|
||||
def send_invitation(
|
||||
to: str, token: str, list_name: str, inviter: str, valid_days: int
|
||||
) -> None:
|
||||
url = f"{settings.public_base_url.rstrip('/')}/invite?token={token}"
|
||||
send_mail(
|
||||
to,
|
||||
f"{settings.app_name}: {inviter} teilt die Liste „{list_name}“ mit dir",
|
||||
"Hallo,\n\n"
|
||||
f"{inviter} möchte die Einkaufsliste „{list_name}“ mit dir teilen.\n\n"
|
||||
"Über den folgenden Link kannst du die Einladung annehmen:\n\n"
|
||||
f"{url}\n\n"
|
||||
f"Der Link ist {valid_days} Tage gültig und gilt nur für diese "
|
||||
"E-Mail-Adresse.\n"
|
||||
"Falls du noch kein Konto hast, kannst du dir beim Öffnen des Links "
|
||||
"eines anlegen.\n\n"
|
||||
"Wenn du damit nichts anfangen kannst, ignoriere diese Nachricht -\n"
|
||||
"ohne den Link passiert nichts.\n",
|
||||
)
|
||||
|
||||
|
||||
def send_welcome(to: str, token: str, inviter: str, valid_days: int) -> None:
|
||||
"""Willkommensnachricht mit Link zum Setzen des Passworts.
|
||||
|
||||
Bewusst kein vom Administrator vergebenes Passwort: Das wäre ihm
|
||||
bekannt und ginge zudem im Klartext per Mail.
|
||||
"""
|
||||
url = f"{settings.public_base_url.rstrip('/')}/willkommen?token={token}"
|
||||
send_mail(
|
||||
to,
|
||||
f"{settings.app_name}: Dein Zugang steht bereit",
|
||||
"Hallo,\n\n"
|
||||
f"{inviter} hat für dich einen Zugang zu {settings.app_name} "
|
||||
"eingerichtet.\n\n"
|
||||
"Über den folgenden Link legst du dein Passwort fest und schaltest "
|
||||
"den Zugang frei:\n\n"
|
||||
f"{url}\n\n"
|
||||
f"Der Link ist {valid_days} Tage gültig.\n\n"
|
||||
"Wenn du damit nichts anfangen kannst, ignoriere diese Nachricht - "
|
||||
"ohne den Link\npassiert nichts.\n",
|
||||
)
|
||||
|
||||
|
||||
def send_email_change_verify(to: str, token: str, old_email: str, hours: int) -> None:
|
||||
"""An die NEUE Adresse: Bestätigung, dass sie erreichbar ist."""
|
||||
url = f"{settings.public_base_url.rstrip('/')}/adresswechsel?token={token}"
|
||||
send_mail(
|
||||
to,
|
||||
f"{settings.app_name}: Neue E-Mail-Adresse bestätigen",
|
||||
"Hallo,\n\n"
|
||||
f"für das Konto {old_email} soll künftig diese Adresse verwendet "
|
||||
"werden.\n\n"
|
||||
"Bitte bestätige das über den folgenden Link:\n\n"
|
||||
f"{url}\n\n"
|
||||
f"Der Link ist {hours} Stunden gültig.\n\n"
|
||||
"Wenn du das nicht veranlasst hast, ignoriere diese Nachricht.\n",
|
||||
)
|
||||
|
||||
|
||||
def send_email_change_notice(to: str, new_email: str, hours: int) -> None:
|
||||
"""An die ALTE Adresse: Hinweis, damit eine untergeschobene Änderung
|
||||
auffällt."""
|
||||
send_mail(
|
||||
to,
|
||||
f"{settings.app_name}: Änderung deiner E-Mail-Adresse angefordert",
|
||||
"Hallo,\n\n"
|
||||
"für dein Konto wurde eine neue E-Mail-Adresse angefordert:\n\n"
|
||||
f" {new_email}\n\n"
|
||||
f"Sobald die neue Adresse bestätigt ist (innerhalb von {hours} "
|
||||
"Stunden), gilt sie\nfür die Anmeldung. Diese Adresse hier "
|
||||
"funktioniert dann nicht mehr.\n\n"
|
||||
"Wenn du das nicht veranlasst hast, wende dich bitte umgehend an "
|
||||
"die Administration -\ndann versucht jemand, dir den Zugang zu "
|
||||
"entziehen.\n",
|
||||
)
|
||||
|
||||
|
||||
def send_email_change_verify_old(
|
||||
to: str, token: str, new_email: str, hours: int
|
||||
) -> None:
|
||||
"""An die ALTE Adresse eines Administratorkontos: hier reicht der
|
||||
Hinweis nicht, es wird eine ausdrückliche Zustimmung verlangt."""
|
||||
url = f"{settings.public_base_url.rstrip('/')}/adresswechsel?token={token}"
|
||||
send_mail(
|
||||
to,
|
||||
f"{settings.app_name}: Adressänderung deines Administratorkontos bestätigen",
|
||||
"Hallo,\n\n"
|
||||
"für dein Administratorkonto wurde eine neue E-Mail-Adresse "
|
||||
"angefordert:\n\n"
|
||||
f" {new_email}\n\n"
|
||||
"Bei Administratorkonten muss die Änderung von BEIDEN Adressen "
|
||||
"bestätigt werden.\n"
|
||||
"Bitte bestätige hier von deiner bisherigen Adresse aus:\n\n"
|
||||
f"{url}\n\n"
|
||||
f"Der Link ist {hours} Stunden gültig.\n\n"
|
||||
"Wenn du das nicht veranlasst hast, klicke NICHT auf den Link. Ohne "
|
||||
"deine\nBestätigung bleibt die Adresse unverändert - und du "
|
||||
"solltest dein Passwort ändern.\n",
|
||||
)
|
||||
|
||||
|
||||
def send_deactivation_notice(to: str, months: int) -> None:
|
||||
send_mail(
|
||||
to,
|
||||
f"{settings.app_name}: Dein Zugang wurde deaktiviert",
|
||||
"Hallo,\n\n"
|
||||
f"dein Zugang zu {settings.app_name} wurde deaktiviert, weil er "
|
||||
f"länger als {months} Monate\nnicht genutzt wurde.\n\n"
|
||||
"Deine Listen bleiben erhalten. Wende dich an die Administration, "
|
||||
"wenn du den\nZugang wieder brauchst.\n",
|
||||
)
|
||||
|
||||
|
||||
def send_test_mail(to: str) -> None:
|
||||
send_mail(
|
||||
to,
|
||||
f"{settings.app_name}: Testnachricht",
|
||||
"Diese Nachricht bestätigt, dass der SMTP-Versand funktioniert.\n\n"
|
||||
f"Relay: {settings.smtp_host}:{settings.smtp_port}\n"
|
||||
f"Verschlüsselung: {settings.smtp_security}\n"
|
||||
f"From-Header: {settings.smtp_from}\n"
|
||||
f"Envelope-From: {settings.envelope_from}\n"
|
||||
f"EHLO-Hostname: {settings.smtp_helo_hostname or '(automatisch)'}\n",
|
||||
)
|
||||
184
backend/app/main.py
Normal file
184
backend/app/main.py
Normal file
@@ -0,0 +1,184 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from app.bootstrap_admin import ensure_initial_admin
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal, engine
|
||||
from app.deps import get_setting, set_setting
|
||||
from app.maintenance import describe, run_cleanup
|
||||
from app.models import User
|
||||
from app.routers import (
|
||||
admin, appinfo, auth, catalog, items, lists, prices, public, push,
|
||||
sharing, sync,
|
||||
)
|
||||
from app.security import normalize_email, purge_rate_limits, utcnow
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
||||
log = logging.getLogger("einkaufsapp")
|
||||
|
||||
|
||||
def bootstrap() -> None:
|
||||
"""Beim Start: Startwerte setzen, abgelaufene Sessions und
|
||||
Rate-Limit-Zaehler aufraeumen."""
|
||||
with SessionLocal() as db:
|
||||
ensure_initial_admin(db)
|
||||
|
||||
if get_setting(db, "allow_self_registration") == "":
|
||||
set_setting(
|
||||
db,
|
||||
"allow_self_registration",
|
||||
"true" if settings.self_registration_default else "false",
|
||||
)
|
||||
|
||||
if settings.allow_self_registration == "admin":
|
||||
log.info(
|
||||
"Selbstregistrierung: über die Admin-API steuerbar (aktuell %s).",
|
||||
"an" if get_setting(db, "allow_self_registration") == "true" else "aus",
|
||||
)
|
||||
else:
|
||||
log.info(
|
||||
"Selbstregistrierung: per ALLOW_SELF_REGISTRATION fest auf '%s' "
|
||||
"gesetzt, über die Admin-API nicht änderbar.",
|
||||
settings.allow_self_registration,
|
||||
)
|
||||
|
||||
# Nur ein gesetzter Schlüssel ist immer ein Fehler - dann bleibt
|
||||
# Push still abgeschaltet, und niemand weiß warum.
|
||||
has_public = bool(settings.vapid_public_key)
|
||||
has_private = bool(settings.vapid_private_key)
|
||||
if has_public != has_private:
|
||||
fehlend = "VAPID_PRIVATE_KEY" if has_public else "VAPID_PUBLIC_KEY"
|
||||
log.error(
|
||||
"Push-Benachrichtigungen sind ABGESCHALTET: %s fehlt oder ist "
|
||||
"leer, während der andere Schlüssel gesetzt ist. Häufigste "
|
||||
"Ursache: die Variable steht mehrfach in der .env - Docker "
|
||||
"Compose nimmt die letzte Definition, und das ist oft die "
|
||||
"leere Vorlagenzeile. Prüfen mit: grep -n VAPID .env",
|
||||
fehlend,
|
||||
)
|
||||
elif has_public:
|
||||
log.info(
|
||||
"Push-Benachrichtigungen: aktiv, Drosselung %d Stunde(n), "
|
||||
"Kontakt %s",
|
||||
settings.push_throttle_hours, settings.vapid_contact,
|
||||
)
|
||||
else:
|
||||
log.info(
|
||||
"Push-Benachrichtigungen: abgeschaltet (kein VAPID-Schlüsselpaar). "
|
||||
"Erzeugen mit: python3 tools/vapid-keys.py"
|
||||
)
|
||||
|
||||
log.info(
|
||||
"SMTP-Relay: %s:%s, Verschlüsselung=%s, Auth=%s, Envelope-From=%s",
|
||||
settings.smtp_host, settings.smtp_port, settings.smtp_security,
|
||||
"ja" if settings.smtp_user else "nein", settings.envelope_from,
|
||||
)
|
||||
if settings.smtp_security == "none" and settings.smtp_host not in ("mailpit", "localhost"):
|
||||
log.warning(
|
||||
"SMTP_SECURITY=none bei externem Relay %s - Mails gehen "
|
||||
"unverschlüsselt über das Netz.", settings.smtp_host,
|
||||
)
|
||||
|
||||
# Konfiguriertes Admin-Konto markieren, falls es schon existiert.
|
||||
admin_mail = normalize_email(settings.admin_email)
|
||||
user = db.scalar(select(User).where(User.email == admin_mail))
|
||||
if user is not None and not user.is_admin:
|
||||
user.is_admin = True
|
||||
log.info("Konto %s als Administrator markiert.", admin_mail)
|
||||
|
||||
db.execute(text("DELETE FROM user_session WHERE expires_at <= :now"),
|
||||
{"now": utcnow()})
|
||||
purge_rate_limits(db)
|
||||
db.commit()
|
||||
|
||||
|
||||
def cleanup_once() -> None:
|
||||
"""Laeuft im Threadpool - SQLAlchemy ist hier synchron konfiguriert,
|
||||
und ein blockierender Aufruf in der Ereignisschleife wuerde den
|
||||
gesamten Server anhalten."""
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
counts = run_cleanup(db)
|
||||
log.info("Aufräumen: %s", describe(counts))
|
||||
except Exception:
|
||||
log.exception("Aufräumen fehlgeschlagen")
|
||||
|
||||
|
||||
async def cleanup_loop() -> None:
|
||||
"""Einmal kurz nach dem Start, danach im eingestellten Abstand.
|
||||
|
||||
Bewusst kein zusaetzlicher Cron-Container: Das waere eine weitere
|
||||
Stelle, an der etwas kaputtgehen kann, fuer eine Aufgabe, die
|
||||
einmal am Tag ein paar Zeilen loescht.
|
||||
"""
|
||||
import anyio
|
||||
|
||||
# Kurz warten, damit der Start nicht durch Aufräumarbeiten
|
||||
# verzögert wird.
|
||||
await asyncio.sleep(30)
|
||||
while True:
|
||||
await anyio.to_thread.run_sync(cleanup_once)
|
||||
await asyncio.sleep(settings.cleanup_interval_hours * 3600)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
bootstrap()
|
||||
task = asyncio.create_task(cleanup_loop())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
task.cancel()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
# Kein CORS-Middleware: PWA und API laufen ab Phase 3 unter derselben
|
||||
# Origin hinter nginx. Damit entfaellt eine ganze Fehlerklasse.
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def security_headers(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["Referrer-Policy"] = "no-referrer"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["Permissions-Policy"] = "camera=(self), geolocation=(), microphone=()"
|
||||
if settings.cookie_secure:
|
||||
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/healthz", tags=["system"])
|
||||
def healthz():
|
||||
"""Liveness: sagt nur, dass der Prozess antwortet."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/readyz", tags=["system"])
|
||||
def readyz():
|
||||
"""Readiness: prueft zusaetzlich die Datenbankverbindung."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(text("SELECT 1"))
|
||||
return {"status": "ok", "database": "ok"}
|
||||
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(lists.router)
|
||||
app.include_router(catalog.router)
|
||||
app.include_router(items.router)
|
||||
app.include_router(sharing.router)
|
||||
app.include_router(public.router)
|
||||
app.include_router(sync.router)
|
||||
app.include_router(prices.router)
|
||||
app.include_router(push.router)
|
||||
app.include_router(appinfo.router)
|
||||
212
backend/app/maintenance.py
Normal file
212
backend/app/maintenance.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""Regelmäßiges Aufräumen.
|
||||
|
||||
Die Anwendung sammelt an mehreren Stellen bewusst mehr Daten, als für den
|
||||
Augenblick nötig sind - Soft Delete, damit offline gebliebene Geräte vom
|
||||
Löschen erfahren; Quittungen für die Outbox, damit Wiederholungen erkannt
|
||||
werden; Zwischenspeicher für Produktabfragen. Ohne Aufräumen wächst das
|
||||
unbegrenzt.
|
||||
|
||||
Läuft ohne zusätzlichen Dienst: eine Hintergrundaufgabe im api-Container,
|
||||
einmal beim Start und danach täglich. Kein Cron-Container, kein Redis, und
|
||||
damit auch keine weitere Stelle, an der etwas kaputtgehen kann.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.mail import send_deactivation_notice
|
||||
from app.models import (
|
||||
AppliedOp,
|
||||
Article,
|
||||
ArticleAttribute,
|
||||
ArticleMarket,
|
||||
Category,
|
||||
EmailChange,
|
||||
EmailToken,
|
||||
ListInvite,
|
||||
ListItem,
|
||||
Market,
|
||||
ProductCache,
|
||||
PublicShare,
|
||||
RateLimit,
|
||||
ShoppingList,
|
||||
User,
|
||||
UserSession,
|
||||
)
|
||||
from app.security import utcnow
|
||||
from app.users import deactivate, delete_user, months_setting
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_cleanup(db: Session) -> dict[str, int]:
|
||||
"""Räumt auf und gibt zurück, was entfernt wurde."""
|
||||
now = utcnow()
|
||||
counts: dict[str, int] = {}
|
||||
|
||||
def purge(model, condition, label: str) -> None:
|
||||
result = db.execute(delete(model).where(condition))
|
||||
if result.rowcount:
|
||||
counts[label] = result.rowcount
|
||||
|
||||
# --- Sitzungen und Token ---
|
||||
purge(UserSession, UserSession.expires_at <= now, "abgelaufene Sitzungen")
|
||||
|
||||
# Verbrauchte oder abgelaufene Mail-Token: nach sieben Tagen weg. Der
|
||||
# Puffer erlaubt es, im Zweifelsfall nachzusehen, warum ein Link nicht
|
||||
# mehr ging.
|
||||
purge(
|
||||
EmailToken,
|
||||
EmailToken.expires_at <= now - timedelta(days=7),
|
||||
"abgelaufene Mail-Token",
|
||||
)
|
||||
|
||||
# --- Rate-Limit-Zähler ---
|
||||
purge(
|
||||
RateLimit,
|
||||
RateLimit.window_start < now - timedelta(days=1),
|
||||
"Rate-Limit-Zähler",
|
||||
)
|
||||
|
||||
# --- Quittungen der Outbox ---
|
||||
# Sie verhindern doppeltes Ausführen. Ein Gerät, das länger als die
|
||||
# Aufbewahrungsfrist offline war, hätte seine Operationen ohnehin
|
||||
# längst über die Wiederholungsgrenze hinaus versucht.
|
||||
purge(
|
||||
AppliedOp,
|
||||
AppliedOp.created_at < now - timedelta(days=settings.cleanup_ops_days),
|
||||
"Outbox-Quittungen",
|
||||
)
|
||||
|
||||
# --- Abgelaufene Einladungen und öffentliche Links ---
|
||||
purge(
|
||||
ListInvite,
|
||||
ListInvite.expires_at < now - timedelta(days=settings.cleanup_deleted_days),
|
||||
"alte Einladungen",
|
||||
)
|
||||
purge(
|
||||
PublicShare,
|
||||
PublicShare.expires_at < now - timedelta(days=settings.cleanup_deleted_days),
|
||||
"abgelaufene öffentliche Links",
|
||||
)
|
||||
|
||||
# --- Produktzwischenspeicher ---
|
||||
purge(
|
||||
ProductCache,
|
||||
ProductCache.fetched_at
|
||||
< now - timedelta(days=max(settings.product_cache_days * 2, 365)),
|
||||
"Produktzwischenspeicher",
|
||||
)
|
||||
|
||||
# --- Endgültiges Löschen weicher Löschungen ---
|
||||
# Erst jetzt verschwinden die Daten wirklich. Bis dahin konnten
|
||||
# offline gebliebene Geräte erfahren, dass es sie nicht mehr gibt.
|
||||
cutoff = now - timedelta(days=settings.cleanup_deleted_days)
|
||||
|
||||
purge(ListItem, ListItem.deleted_at < cutoff, "gelöschte Einträge")
|
||||
|
||||
# Artikel erst nach ihren Einträgen: Die Fremdschlüssel räumen zwar
|
||||
# per CASCADE mit auf, aber die Reihenfolge macht die Zählung ehrlich.
|
||||
stale_articles = db.scalars(
|
||||
select(Article.id).where(Article.deleted_at < cutoff)
|
||||
).all()
|
||||
if stale_articles:
|
||||
db.execute(
|
||||
delete(ArticleAttribute).where(
|
||||
ArticleAttribute.article_id.in_(stale_articles))
|
||||
)
|
||||
db.execute(
|
||||
delete(ArticleMarket).where(ArticleMarket.article_id.in_(stale_articles))
|
||||
)
|
||||
db.execute(delete(Article).where(Article.id.in_(stale_articles)))
|
||||
counts["gelöschte Artikel"] = len(stale_articles)
|
||||
|
||||
purge(Market, Market.deleted_at < cutoff, "gelöschte Märkte")
|
||||
purge(Category, Category.deleted_at < cutoff, "gelöschte Warengruppen")
|
||||
|
||||
# Gelöschte Listen zuletzt - daran hängt per CASCADE alles Übrige.
|
||||
purge(ShoppingList, ShoppingList.deleted_at < cutoff, "gelöschte Listen")
|
||||
|
||||
# --- Abgeschlossene Adressänderungen ---
|
||||
purge(
|
||||
EmailChange,
|
||||
EmailChange.expires_at < now - timedelta(days=settings.cleanup_deleted_days),
|
||||
"alte Adressänderungen",
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
counts.update(_retire_users(db))
|
||||
db.commit()
|
||||
return counts
|
||||
|
||||
|
||||
def _retire_users(db: Session) -> dict[str, int]:
|
||||
"""Deaktiviert lange untätige Konten und löscht lange deaktivierte.
|
||||
|
||||
Administratorkonten bleiben ausgenommen - sonst könnte sich die
|
||||
Verwaltung selbst aussperren, und zwar unbemerkt, weil niemand
|
||||
hinsieht, solange alles läuft.
|
||||
|
||||
Ein Wert von 0 bedeutet "abgeschaltet", nicht "sofort".
|
||||
"""
|
||||
now = utcnow()
|
||||
counts: dict[str, int] = {}
|
||||
|
||||
deactivate_months = months_setting(db, "auto_deactivate_months", 12)
|
||||
delete_months = months_setting(db, "auto_delete_months", 12)
|
||||
|
||||
if deactivate_months:
|
||||
cutoff = now - timedelta(days=deactivate_months * 30)
|
||||
candidates = db.scalars(
|
||||
select(User).where(
|
||||
User.is_active.is_(True),
|
||||
User.is_admin.is_(False),
|
||||
User.last_seen_at.is_not(None),
|
||||
User.last_seen_at < cutoff,
|
||||
)
|
||||
).all()
|
||||
for user in candidates:
|
||||
log.info(
|
||||
"Deaktiviere %s - seit %s nicht mehr gesehen",
|
||||
user.email, user.last_seen_at.date(),
|
||||
)
|
||||
address = user.email
|
||||
deactivate(db, user)
|
||||
# Der Versand blockiert; hier ist das vertretbar, weil der
|
||||
# Aufräumlauf ohnehin im Hintergrund läuft und selten
|
||||
# mehr als eine Handvoll Konten betrifft.
|
||||
send_deactivation_notice(address, deactivate_months)
|
||||
if candidates:
|
||||
counts["deaktivierte Konten"] = len(candidates)
|
||||
|
||||
if delete_months:
|
||||
cutoff = now - timedelta(days=delete_months * 30)
|
||||
candidates = db.scalars(
|
||||
select(User).where(
|
||||
User.is_active.is_(False),
|
||||
User.is_admin.is_(False),
|
||||
User.deactivated_at.is_not(None),
|
||||
User.deactivated_at < cutoff,
|
||||
)
|
||||
).all()
|
||||
for user in candidates:
|
||||
log.info(
|
||||
"Lösche %s - seit %s deaktiviert",
|
||||
user.email, user.deactivated_at.date(),
|
||||
)
|
||||
delete_user(db, user)
|
||||
if candidates:
|
||||
counts["gelöschte Konten"] = len(candidates)
|
||||
|
||||
return counts
|
||||
|
||||
|
||||
def describe(counts: dict[str, int]) -> str:
|
||||
if not counts:
|
||||
return "nichts zu tun"
|
||||
return ", ".join(f"{value} {label}" for label, value in sorted(counts.items()))
|
||||
632
backend/app/models.py
Normal file
632
backend/app/models.py
Normal file
@@ -0,0 +1,632 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
Numeric,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db import Base
|
||||
|
||||
|
||||
def new_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "user"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
# Normalisiert (lowercase, getrimmt) - eindeutig ueber diesen Wert.
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True)
|
||||
display_name: Mapped[str | None] = mapped_column(String(80), default=None)
|
||||
password_hash: Mapped[str] = mapped_column(String(255))
|
||||
verified_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
|
||||
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
# Sperrt alles ausser "Profil lesen" und "Passwort aendern".
|
||||
# Wird beim initial angelegten Admin gesetzt.
|
||||
must_change_password: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
# Grundlage der automatischen Deaktivierung. Wird bei der Anmeldung
|
||||
# gesetzt und danach hoechstens stuendlich nachgefuehrt - jeder
|
||||
# Request zu schreiben waere unnoetige Last.
|
||||
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
|
||||
# Seit wann ist das Konto deaktiviert. Grundlage der automatischen
|
||||
# Loeschung; is_active allein sagt nichts ueber die Dauer.
|
||||
deactivated_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
sessions: Mapped[list["UserSession"]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class UserSession(Base):
|
||||
"""Serverseitige Session. Der Client haelt nur das Klartext-Token im
|
||||
HttpOnly-Cookie; hier liegt ausschliesslich dessen SHA-256-Hash."""
|
||||
|
||||
__tablename__ = "user_session"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("user.id", ondelete="CASCADE")
|
||||
)
|
||||
# Double-Submit-CSRF: dieser Wert steht zusaetzlich in einem lesbaren
|
||||
# Cookie und muss bei schreibenden Requests im Header wiederkommen.
|
||||
csrf_token: Mapped[str] = mapped_column(String(64))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
last_seen_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime)
|
||||
|
||||
user: Mapped[User] = relationship(back_populates="sessions")
|
||||
|
||||
__table_args__ = (Index("ix_user_session_expires", "expires_at"),)
|
||||
|
||||
|
||||
class EmailToken(Base):
|
||||
"""Einmal-Token fuer Mailverifikation und Passwort-Reset.
|
||||
Auch hier liegt nur der Hash in der Datenbank."""
|
||||
|
||||
__tablename__ = "email_token"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("user.id", ondelete="CASCADE")
|
||||
)
|
||||
purpose: Mapped[str] = mapped_column(String(32)) # "verify" | "reset"
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime)
|
||||
used_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
__table_args__ = (Index("ix_email_token_user_purpose", "user_id", "purpose"),)
|
||||
|
||||
|
||||
class Setting(Base):
|
||||
"""Zur Laufzeit aenderbare Konfiguration (Admin-Oberflaeche)."""
|
||||
|
||||
__tablename__ = "setting"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
value: Mapped[str] = mapped_column(String(255))
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class RateLimit(Base):
|
||||
"""Zaehlerbasiertes Rate Limiting ohne Redis.
|
||||
Ein Datensatz pro (Schluessel, Zeitfenster)."""
|
||||
|
||||
__tablename__ = "rate_limit"
|
||||
|
||||
bucket: Mapped[str] = mapped_column(String(160), primary_key=True)
|
||||
window_start: Mapped[datetime] = mapped_column(DateTime, primary_key=True)
|
||||
count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Phase 2: Listen, Katalog, Eintraege
|
||||
# ==========================================================================
|
||||
#
|
||||
# Zwei Konventionen ziehen sich durch alle folgenden Tabellen:
|
||||
#
|
||||
# rev / row_rev Pro Liste laeuft ein monoton steigender Zaehler
|
||||
# (shopping_list.rev). Jede Aenderung erhoeht ihn und
|
||||
# schreibt den neuen Wert in row_rev der geaenderten
|
||||
# Zeile. Ein Client fragt "gib mir alles mit
|
||||
# row_rev > N" und bekommt genau das Delta.
|
||||
#
|
||||
# deleted_at Soft Delete. Geloeschte Zeilen bleiben stehen, damit
|
||||
# auch ein Client, der tagelang offline war, vom
|
||||
# Loeschen erfaehrt. Aufraeumen per Cron nach 30 Tagen.
|
||||
|
||||
|
||||
class ShoppingList(Base):
|
||||
__tablename__ = "shopping_list"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
name: Mapped[str] = mapped_column(String(120))
|
||||
owner_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("user.id", ondelete="RESTRICT")
|
||||
)
|
||||
rev: Mapped[int] = mapped_column(BigInteger, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
|
||||
|
||||
members: Mapped[list["ListMember"]] = relationship(
|
||||
back_populates="shopping_list", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class ListMember(Base):
|
||||
__tablename__ = "list_member"
|
||||
|
||||
list_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("shopping_list.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("user.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
# "owner" | "editor" | "viewer"
|
||||
role: Mapped[str] = mapped_column(String(16), default="editor")
|
||||
# Zusatzrecht, unabhaengig von der Rolle: oeffentliche Links erzeugen
|
||||
# und widerrufen. Der Eigentuemer hat es immer, alle anderen nur,
|
||||
# wenn er es ausdruecklich erteilt.
|
||||
may_share_public: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
joined_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
shopping_list: Mapped[ShoppingList] = relationship(back_populates="members")
|
||||
user: Mapped[User] = relationship()
|
||||
|
||||
|
||||
class Market(Base):
|
||||
"""Ein Markt, z.B. 'Edeka Neustadt'. Gehoert zu genau einer Liste -
|
||||
so entsteht kein listenuebergreifender Datenbestand ueber Nutzer."""
|
||||
|
||||
__tablename__ = "market"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
list_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("shopping_list.id", ondelete="CASCADE")
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(120))
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
row_rev: Mapped[int] = mapped_column(BigInteger, default=0)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_market_list_rev", "list_id", "row_rev"),
|
||||
UniqueConstraint("list_id", "name", name="uq_market_list_name"),
|
||||
)
|
||||
|
||||
|
||||
class Category(Base):
|
||||
"""Warengruppe, z.B. 'Molkerei'. Bestimmt die Gliederung innerhalb
|
||||
eines Marktes - in der App wie im Ausdruck."""
|
||||
|
||||
__tablename__ = "category"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
list_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("shopping_list.id", ondelete="CASCADE")
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(120))
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
row_rev: Mapped[int] = mapped_column(BigInteger, default=0)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_category_list_rev", "list_id", "row_rev"),
|
||||
UniqueConstraint("list_id", "name", name="uq_category_list_name"),
|
||||
)
|
||||
|
||||
|
||||
class Article(Base):
|
||||
"""Stammdaten eines Artikels. Der Listeneintrag (list_item) verweist
|
||||
darauf; derselbe Artikel kann also mehrfach auf der Liste landen,
|
||||
ohne dass Name und Attribute doppelt gepflegt werden."""
|
||||
|
||||
__tablename__ = "article"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
list_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("shopping_list.id", ondelete="CASCADE")
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
barcode: Mapped[str | None] = mapped_column(String(64), default=None)
|
||||
note: Mapped[str | None] = mapped_column(String(500), default=None)
|
||||
default_market_id: Mapped[str | None] = mapped_column(
|
||||
String(36), ForeignKey("market.id", ondelete="SET NULL"), default=None
|
||||
)
|
||||
default_category_id: Mapped[str | None] = mapped_column(
|
||||
String(36), ForeignKey("category.id", ondelete="SET NULL"), default=None
|
||||
)
|
||||
row_rev: Mapped[int] = mapped_column(BigInteger, default=0)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
|
||||
|
||||
attributes: Mapped[list["ArticleAttribute"]] = relationship(
|
||||
back_populates="article", cascade="all, delete-orphan", lazy="selectin"
|
||||
)
|
||||
availability: Mapped[list["ArticleMarket"]] = relationship(
|
||||
cascade="all, delete-orphan", lazy="selectin"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_article_list_rev", "list_id", "row_rev"),
|
||||
Index("ix_article_barcode", "list_id", "barcode"),
|
||||
UniqueConstraint("list_id", "name", name="uq_article_list_name"),
|
||||
)
|
||||
|
||||
|
||||
class ArticleAttribute(Base):
|
||||
"""Frei definierbare Eigenschaft: Verpackungseinheit, Farbe, Groesse.
|
||||
Spalte heisst attr_name, weil "key" in MariaDB reserviert ist."""
|
||||
|
||||
__tablename__ = "article_attribute"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
article_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("article.id", ondelete="CASCADE")
|
||||
)
|
||||
attr_name: Mapped[str] = mapped_column(String(80))
|
||||
attr_value: Mapped[str] = mapped_column(String(300))
|
||||
|
||||
article: Mapped[Article] = relationship(back_populates="attributes")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("article_id", "attr_name", name="uq_article_attr"),
|
||||
)
|
||||
|
||||
|
||||
class ArticleMarket(Base):
|
||||
"""In welchen Maerkten ist der Artikel erhaeltlich."""
|
||||
|
||||
__tablename__ = "article_market"
|
||||
|
||||
article_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("article.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
market_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("market.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
|
||||
|
||||
class ListItem(Base):
|
||||
"""Ein konkreter Eintrag auf der Einkaufsliste."""
|
||||
|
||||
__tablename__ = "list_item"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
list_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("shopping_list.id", ondelete="CASCADE")
|
||||
)
|
||||
article_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("article.id", ondelete="CASCADE")
|
||||
)
|
||||
# Ueberschreibt die Vorgabe aus dem Artikel, falls gesetzt.
|
||||
market_id: Mapped[str | None] = mapped_column(
|
||||
String(36), ForeignKey("market.id", ondelete="SET NULL"), default=None
|
||||
)
|
||||
category_id: Mapped[str | None] = mapped_column(
|
||||
String(36), ForeignKey("category.id", ondelete="SET NULL"), default=None
|
||||
)
|
||||
# Wer den Eintrag angelegt hat. SET NULL beim Löschen des Kontos:
|
||||
# der Eintrag bleibt bestehen, der Personenbezug verschwindet.
|
||||
created_by: Mapped[str | None] = mapped_column(
|
||||
String(36), ForeignKey("user.id", ondelete="SET NULL"), default=None
|
||||
)
|
||||
# Stueckzahl: wie viele Packungen sollen es sein. Der Preis am
|
||||
# Eintrag gilt fuer EINE davon - die Summe ist Preis mal Anzahl.
|
||||
count: Mapped[int] = mapped_column(Integer, default=1)
|
||||
# Gebinde: wie gross ist eine Packung. Getrennt von der Stueckzahl,
|
||||
# weil beides sonst verwechselt wird: Bei "500 ml" zu 2,99 EUR
|
||||
# ergaebe eine gemeinsame Zahl eine Summe von 1495 EUR statt 2,99.
|
||||
pack_size: Mapped[Decimal | None] = mapped_column(Numeric(10, 3), default=None)
|
||||
pack_unit: Mapped[str | None] = mapped_column(String(32), default=None)
|
||||
# Naehere Bestimmung des gewuenschten Artikels, z.B. bei Pfeffer
|
||||
# "bunt, ganz" oder "schwarz, gemahlen". Bewusst getrennt von `note`:
|
||||
# die Eigenschaft gehoert zum Produkt und steht deshalb neben dem
|
||||
# Namen, waehrend die Notiz eine Bemerkung fuer den Einkaufenden ist
|
||||
# ("beim Metzger fragen").
|
||||
variant: Mapped[str | None] = mapped_column(String(200), default=None)
|
||||
note: Mapped[str | None] = mapped_column(String(500), default=None)
|
||||
# "open" | "bought" | "deferred"
|
||||
status: Mapped[str] = mapped_column(String(16), default="open")
|
||||
# Preis in Cent - niemals als Float rechnen.
|
||||
price_cents: Mapped[int | None] = mapped_column(Integer, default=None)
|
||||
|
||||
row_rev: Mapped[int] = mapped_column(BigInteger, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
|
||||
|
||||
article: Mapped[Article] = relationship(lazy="joined")
|
||||
creator: Mapped["User | None"] = relationship(
|
||||
foreign_keys=[created_by], lazy="joined"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_list_item_list_rev", "list_id", "row_rev"),
|
||||
Index("ix_list_item_list_status", "list_id", "status"),
|
||||
)
|
||||
|
||||
|
||||
class ListInvite(Base):
|
||||
"""Einladung zu einer Liste, adressiert an eine E-Mail-Adresse.
|
||||
|
||||
Bewusst unabhaengig davon, ob unter der Adresse schon ein Konto
|
||||
existiert: So verraet das Anlegen einer Einladung nicht, wer bei uns
|
||||
registriert ist, und derselbe Ablauf gilt fuer neue wie fuer
|
||||
bestehende Nutzer.
|
||||
|
||||
In der Datenbank steht nur der Hash des Tokens. Ein erneuter Versand
|
||||
erzeugt deshalb ein neues Token und entwertet das alte - der
|
||||
Klartext laesst sich nicht rekonstruieren.
|
||||
"""
|
||||
|
||||
__tablename__ = "list_invite"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
list_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("shopping_list.id", ondelete="CASCADE")
|
||||
)
|
||||
email: Mapped[str] = mapped_column(String(255))
|
||||
role: Mapped[str] = mapped_column(String(16), default="editor")
|
||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
|
||||
invited_by: Mapped[str | None] = mapped_column(
|
||||
String(36), ForeignKey("user.id", ondelete="SET NULL"), default=None
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
last_sent_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
send_count: Mapped[int] = mapped_column(Integer, default=1)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime)
|
||||
accepted_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
|
||||
accepted_by: Mapped[str | None] = mapped_column(
|
||||
String(36), ForeignKey("user.id", ondelete="SET NULL"), default=None
|
||||
)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
|
||||
|
||||
inviter: Mapped["User | None"] = relationship(
|
||||
foreign_keys=[invited_by], lazy="joined"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_list_invite_list", "list_id"),
|
||||
Index("ix_list_invite_email", "email"),
|
||||
)
|
||||
|
||||
|
||||
class PublicShare(Base):
|
||||
"""Oeffentlicher Ansichtslink auf eine Liste.
|
||||
|
||||
Wer den Link hat, darf sehen und abhaken - mehr nicht. Kein Konto
|
||||
noetig. Ablaufdatum ist Pflicht: ein unbefristeter Link waere ein
|
||||
dauerhaft offenes Fenster in fremde Daten.
|
||||
|
||||
In der Datenbank steht nur der Hash des Tokens. Der Klartext wird
|
||||
einmal bei der Erzeugung ausgeliefert und ist danach nicht mehr
|
||||
rekonstruierbar - ein Datenbankleck gibt also keinen Zugriff.
|
||||
"""
|
||||
|
||||
__tablename__ = "public_share"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
list_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("shopping_list.id", ondelete="CASCADE")
|
||||
)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
|
||||
# Freie Bezeichnung, damit mehrere Links unterscheidbar bleiben
|
||||
# ("fuer Oma", "Vereinsfest").
|
||||
label: Mapped[str | None] = mapped_column(String(120), default=None)
|
||||
# Darf ueber diesen Link abgehakt werden?
|
||||
allow_check: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
created_by: Mapped[str | None] = mapped_column(
|
||||
String(36), ForeignKey("user.id", ondelete="SET NULL"), default=None
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
|
||||
|
||||
# Grobe Nutzungsanzeige fuer den Eigentuemer. Bewusst ohne IP-Adressen
|
||||
# und ohne Zeitreihe - es soll erkennbar sein, DASS ein Link benutzt
|
||||
# wird, nicht von wem.
|
||||
last_access_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
|
||||
access_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
creator: Mapped["User | None"] = relationship(
|
||||
foreign_keys=[created_by], lazy="joined"
|
||||
)
|
||||
|
||||
__table_args__ = (Index("ix_public_share_list", "list_id"),)
|
||||
|
||||
|
||||
class AppliedOp(Base):
|
||||
"""Quittung fuer eine bereits verarbeitete Operation aus einer
|
||||
Outbox-Warteschlange.
|
||||
|
||||
Der Client vergibt die `op_id` selbst, bevor er sendet. Kommt dieselbe
|
||||
Operation ein zweites Mal an - weil die Antwort unterwegs verloren
|
||||
ging, das Telefon neu gestartet wurde oder der Hintergrundversand sie
|
||||
erneut zugestellt hat -, wird sie hier erkannt und nicht noch einmal
|
||||
ausgefuehrt.
|
||||
|
||||
Ohne diese Tabelle wuerde aus einem verlorenen "Butter hinzufuegen"
|
||||
beim naechsten Versuch zweimal Butter.
|
||||
"""
|
||||
|
||||
__tablename__ = "applied_op"
|
||||
|
||||
op_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
list_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("shopping_list.id", ondelete="CASCADE")
|
||||
)
|
||||
user_id: Mapped[str | None] = mapped_column(
|
||||
String(36), ForeignKey("user.id", ondelete="SET NULL"), default=None
|
||||
)
|
||||
kind: Mapped[str] = mapped_column(String(32))
|
||||
# Bei item.create: die vergebene ID, damit der Client seinen
|
||||
# vorlaeufigen Eintrag zuordnen kann.
|
||||
result_id: Mapped[str | None] = mapped_column(String(36), default=None)
|
||||
rev: Mapped[int] = mapped_column(BigInteger, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
__table_args__ = (Index("ix_applied_op_list", "list_id", "created_at"),)
|
||||
|
||||
|
||||
class ProductCache(Base):
|
||||
"""Zwischenspeicher fuer Abfragen bei Open Food Facts.
|
||||
|
||||
Zweck ist nicht nur Geschwindigkeit: Ohne Zwischenspeicher ginge bei
|
||||
jedem Scan eine Anfrage nach draussen. So wird jeder Code hoechstens
|
||||
einmal je Gueltigkeitszeitraum abgefragt - und zwar von diesem
|
||||
Server, nicht vom Geraet des Nutzers. Dessen IP-Adresse erfaehrt der
|
||||
Dritte damit gar nicht erst.
|
||||
|
||||
Fehlschlaege werden ebenfalls vermerkt, sonst laeuft jeder Scan eines
|
||||
unbekannten Codes erneut ins Leere.
|
||||
"""
|
||||
|
||||
__tablename__ = "product_cache"
|
||||
|
||||
barcode: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
found: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
name: Mapped[str | None] = mapped_column(String(300), default=None)
|
||||
brand: Mapped[str | None] = mapped_column(String(200), default=None)
|
||||
# Rohtext wie "500 g", "1,5 l" oder "6 x 33 cl"
|
||||
package: Mapped[str | None] = mapped_column(String(120), default=None)
|
||||
# Aus dem Rohtext zerlegt: "6 x 33 cl" -> count 6, Gebinde 33 cl
|
||||
count: Mapped[int | None] = mapped_column(Integer, default=None)
|
||||
pack_size: Mapped[Decimal | None] = mapped_column(Numeric(10, 3), default=None)
|
||||
pack_unit: Mapped[str | None] = mapped_column(String(32), default=None)
|
||||
source: Mapped[str] = mapped_column(String(32), default="openfoodfacts")
|
||||
fetched_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class PricePoint(Base):
|
||||
"""Ein beobachteter Preis: Artikel, Markt, Betrag, Zeitpunkt.
|
||||
|
||||
Bewusst OHNE Nutzerbezug. Aus "wer hat wann wo was zu welchem Preis
|
||||
gekauft" liesse sich ein Bewegungs- und Konsumprofil bilden - genau
|
||||
das soll die Preisdatenbank nicht ermoeglichen. Fuer den Zweck
|
||||
(Preise vergleichen) genuegt Markt, Artikel und Zeitpunkt.
|
||||
|
||||
Menge und Einheit werden mitgefuehrt, weil ein Preis ohne
|
||||
Bezugsgroesse nicht vergleichbar ist: 1,29 EUR fuer einen Liter ist
|
||||
etwas anderes als 1,29 EUR fuer 200 Milliliter.
|
||||
"""
|
||||
|
||||
__tablename__ = "price_point"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
list_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("shopping_list.id", ondelete="CASCADE")
|
||||
)
|
||||
article_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("article.id", ondelete="CASCADE")
|
||||
)
|
||||
market_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("market.id", ondelete="CASCADE")
|
||||
)
|
||||
# Preis fuer EIN Gebinde.
|
||||
price_cents: Mapped[int] = mapped_column(Integer)
|
||||
pack_size: Mapped[Decimal | None] = mapped_column(Numeric(10, 3), default=None)
|
||||
pack_unit: Mapped[str | None] = mapped_column(String(32), default=None)
|
||||
recorded_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_price_article_market", "article_id", "market_id", "recorded_at"),
|
||||
Index("ix_price_list", "list_id", "recorded_at"),
|
||||
)
|
||||
|
||||
|
||||
class PushSubscription(Base):
|
||||
"""Anmeldung eines Geraets fuer Push-Benachrichtigungen.
|
||||
|
||||
Der Endpunkt ist eine vom Browserhersteller vergebene URL. Sie ist
|
||||
geraetebezogen, aber nicht personenbezogen im engeren Sinn - wir
|
||||
speichern sie, weil ohne sie keine Zustellung moeglich ist, und
|
||||
loeschen sie, sobald der Push-Dienst sie als ungueltig meldet.
|
||||
"""
|
||||
|
||||
__tablename__ = "push_subscription"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("user.id", ondelete="CASCADE")
|
||||
)
|
||||
endpoint: Mapped[str] = mapped_column(String(500), unique=True)
|
||||
# Schluesselmaterial fuer die Ende-zu-Ende-Verschluesselung der
|
||||
# Nachricht. Auch der Push-Dienst kann den Inhalt nicht lesen.
|
||||
p256dh: Mapped[str] = mapped_column(String(200))
|
||||
auth: Mapped[str] = mapped_column(String(100))
|
||||
# Grobe Geraetekennung, damit der Nutzer seine Anmeldungen
|
||||
# auseinanderhalten kann. Bewusst gekuerzt.
|
||||
label: Mapped[str | None] = mapped_column(String(80), default=None)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
last_success_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
|
||||
failure_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
__table_args__ = (Index("ix_push_subscription_user", "user_id"),)
|
||||
|
||||
|
||||
class NotifyState(Base):
|
||||
"""Wann wurde diese Person zuletzt ueber diese Liste benachrichtigt.
|
||||
|
||||
Grundlage der Drosselung: Wer gerade eine Liste abarbeitet, erzeugt
|
||||
Dutzende Aenderungen. Ohne diesen Vermerk bekaemen die anderen
|
||||
Mitglieder ebenso viele Benachrichtigungen.
|
||||
"""
|
||||
|
||||
__tablename__ = "notify_state"
|
||||
|
||||
list_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("shopping_list.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("user.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
last_notified_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class EmailChange(Base):
|
||||
"""Laufende Aenderung einer E-Mail-Adresse.
|
||||
|
||||
Bei gewoehnlichen Konten muss nur die NEUE Adresse bestaetigen; die
|
||||
alte bekommt eine Benachrichtigung, damit eine untergeschobene
|
||||
Aenderung auffaellt.
|
||||
|
||||
Bei Administratorkonten muessen BEIDE bestaetigen. Der Grund: Wer
|
||||
Zugriff auf ein Administratorkonto erlangt, koennte sonst die
|
||||
Adresse auf eine eigene umstellen und sich damit dauerhaft
|
||||
einnisten - der rechtmaessige Inhaber verloere den Weg zurueck ueber
|
||||
"Passwort vergessen". Die Bestaetigung von der alten Adresse macht
|
||||
das unmoeglich, solange der Angreifer nicht auch das Postfach hat.
|
||||
"""
|
||||
|
||||
__tablename__ = "email_change"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("user.id", ondelete="CASCADE")
|
||||
)
|
||||
old_email: Mapped[str] = mapped_column(String(255))
|
||||
new_email: Mapped[str] = mapped_column(String(255))
|
||||
|
||||
# Nur Hashes, wie bei allen Token in dieser Anwendung
|
||||
token_new_hash: Mapped[str] = mapped_column(String(64), unique=True)
|
||||
token_old_hash: Mapped[str | None] = mapped_column(
|
||||
String(64), unique=True, default=None
|
||||
)
|
||||
# True bei Administratorkonten
|
||||
requires_old: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
confirmed_new_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
|
||||
confirmed_old_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
|
||||
applied_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
|
||||
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
|
||||
|
||||
requested_by: Mapped[str | None] = mapped_column(
|
||||
String(36), ForeignKey("user.id", ondelete="SET NULL"), default=None
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime)
|
||||
|
||||
__table_args__ = (Index("ix_email_change_user", "user_id"),)
|
||||
80
backend/app/permissions.py
Normal file
80
backend/app/permissions.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Zugriffsrechte auf Listen und Fortschreibung des Revisionszählers.
|
||||
|
||||
Rollen, aufsteigend:
|
||||
viewer darf lesen
|
||||
editor darf Einträge, Artikel, Märkte und Warengruppen ändern
|
||||
owner darf zusätzlich die Liste umbenennen, löschen und
|
||||
Mitgliedschaften verwalten
|
||||
|
||||
Der Eigentümer steht doppelt fest: in `shopping_list.owner_id` und als
|
||||
Mitglied mit der Rolle "owner". Das ist bewusst redundant - die Spalte
|
||||
verhindert, dass eine Liste durch Löschen des letzten Mitglieds
|
||||
verwaist, die Mitgliedschaft macht Abfragen einheitlich.
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.deps import DbSession, VerifiedUser
|
||||
from app.models import ListMember, ShoppingList
|
||||
|
||||
ROLE_RANK = {"viewer": 1, "editor": 2, "owner": 3}
|
||||
|
||||
|
||||
def bump_rev(db: Session, list_id: str) -> int:
|
||||
"""Erhöht den Revisionszähler der Liste und gibt den neuen Wert zurück.
|
||||
|
||||
SELECT ... FOR UPDATE sperrt die Zeile bis zum Commit. Ohne die Sperre
|
||||
könnten zwei gleichzeitige Änderungen denselben Wert vergeben - dann
|
||||
verpasst ein synchronisierender Client eine davon.
|
||||
"""
|
||||
lst = db.scalar(
|
||||
select(ShoppingList).where(ShoppingList.id == list_id).with_for_update()
|
||||
)
|
||||
if lst is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Liste nicht gefunden")
|
||||
lst.rev += 1
|
||||
db.flush()
|
||||
return lst.rev
|
||||
|
||||
|
||||
def _access(db: Session, list_id: str, user_id: str, minimum: str) -> ShoppingList:
|
||||
lst = db.scalar(
|
||||
select(ShoppingList).where(
|
||||
ShoppingList.id == list_id, ShoppingList.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
membership = db.get(ListMember, (list_id, user_id))
|
||||
|
||||
# Gleiche Antwort für "gibt es nicht" und "darfst du nicht sehen".
|
||||
# Sonst ließe sich über die Statuscodes herausfinden, welche
|
||||
# Listen-IDs existieren.
|
||||
if lst is None or membership is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Liste nicht gefunden")
|
||||
|
||||
if ROLE_RANK.get(membership.role, 0) < ROLE_RANK[minimum]:
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
f"Für diese Aktion ist mindestens die Rolle '{minimum}' erforderlich.",
|
||||
)
|
||||
return lst
|
||||
|
||||
|
||||
def list_reader(list_id: str, db: DbSession, user: VerifiedUser) -> ShoppingList:
|
||||
return _access(db, list_id, user.id, "viewer")
|
||||
|
||||
|
||||
def list_editor(list_id: str, db: DbSession, user: VerifiedUser) -> ShoppingList:
|
||||
return _access(db, list_id, user.id, "editor")
|
||||
|
||||
|
||||
def list_owner(list_id: str, db: DbSession, user: VerifiedUser) -> ShoppingList:
|
||||
return _access(db, list_id, user.id, "owner")
|
||||
|
||||
|
||||
ReadableList = Annotated[ShoppingList, Depends(list_reader)]
|
||||
EditableList = Annotated[ShoppingList, Depends(list_editor)]
|
||||
OwnedList = Annotated[ShoppingList, Depends(list_owner)]
|
||||
232
backend/app/prices.py
Normal file
232
backend/app/prices.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""Preiserfassung und Preisvergleich.
|
||||
|
||||
Ein Preis wird festgehalten, sobald er an einem Eintrag steht, der einem
|
||||
Markt zugeordnet ist. Ohne Markt ergibt er keinen Vergleichswert und wird
|
||||
deshalb nicht aufgenommen.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Article, ListItem, Market, PricePoint
|
||||
from app.schemas_shopping import (
|
||||
ArticlePrices,
|
||||
MarketPrice,
|
||||
PriceEntry,
|
||||
PriceHint,
|
||||
PriceOverview,
|
||||
PriceOverviewRow,
|
||||
)
|
||||
from app.security import utcnow
|
||||
|
||||
# Innerhalb dieser Spanne gilt ein gleicher Preis am selben Markt als
|
||||
# derselbe Beobachtungswert. Ohne das entstünde bei jedem Tippen im
|
||||
# Preisfeld ein neuer Eintrag.
|
||||
DEDUPE_WINDOW = timedelta(hours=12)
|
||||
|
||||
|
||||
def record_price(db: Session, item: ListItem) -> None:
|
||||
"""Hält den Preis eines Eintrags fest, sofern sinnvoll.
|
||||
|
||||
Wird nach jeder Änderung an einem Eintrag aufgerufen; die Prüfungen
|
||||
hier entscheiden, ob daraus wirklich ein Datenpunkt wird.
|
||||
"""
|
||||
if not item.price_cents or not item.market_id:
|
||||
return
|
||||
|
||||
latest = db.scalar(
|
||||
select(PricePoint)
|
||||
.where(
|
||||
PricePoint.article_id == item.article_id,
|
||||
PricePoint.market_id == item.market_id,
|
||||
)
|
||||
.order_by(PricePoint.recorded_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
if latest is not None:
|
||||
same_price = latest.price_cents == item.price_cents
|
||||
same_size = (latest.pack_size == item.pack_size
|
||||
and latest.pack_unit == item.pack_unit)
|
||||
if same_price and same_size and utcnow() - latest.recorded_at < DEDUPE_WINDOW:
|
||||
return
|
||||
|
||||
db.add(
|
||||
PricePoint(
|
||||
list_id=item.list_id,
|
||||
article_id=item.article_id,
|
||||
market_id=item.market_id,
|
||||
price_cents=item.price_cents,
|
||||
pack_size=item.pack_size,
|
||||
pack_unit=item.pack_unit,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _unit_price(price_cents: int, pack_size: Decimal | None) -> int | None:
|
||||
"""Preis je Mengeneinheit des Gebindes, in Zehntelcent - sonst gingen
|
||||
bei kleinen Mengen zu viele Stellen verloren. Ohne Gebindeangabe
|
||||
nicht bestimmbar."""
|
||||
if not pack_size or pack_size <= 0:
|
||||
return None
|
||||
return int(round(price_cents * 10 / float(pack_size)))
|
||||
|
||||
|
||||
def article_prices(db: Session, list_id: str, article: Article) -> ArticlePrices:
|
||||
"""Preisverlauf eines Artikels, gegliedert nach Markt."""
|
||||
points = db.scalars(
|
||||
select(PricePoint)
|
||||
.where(PricePoint.article_id == article.id)
|
||||
.order_by(PricePoint.recorded_at.desc())
|
||||
.limit(500)
|
||||
).all()
|
||||
|
||||
markets = {
|
||||
m.id: m
|
||||
for m in db.scalars(
|
||||
select(Market).where(Market.list_id == list_id)
|
||||
).all()
|
||||
}
|
||||
|
||||
grouped: dict[str, list[PricePoint]] = {}
|
||||
for point in points:
|
||||
grouped.setdefault(point.market_id, []).append(point)
|
||||
|
||||
rows: list[MarketPrice] = []
|
||||
for market_id, entries in grouped.items():
|
||||
market = markets.get(market_id)
|
||||
if market is None or market.deleted_at is not None:
|
||||
continue
|
||||
|
||||
amounts = [e.price_cents for e in entries]
|
||||
newest = entries[0]
|
||||
rows.append(
|
||||
MarketPrice(
|
||||
market_id=market_id,
|
||||
market_name=market.name,
|
||||
latest_cents=newest.price_cents,
|
||||
latest_at=newest.recorded_at,
|
||||
latest_pack_size=newest.pack_size,
|
||||
latest_pack_unit=newest.pack_unit,
|
||||
unit_price_deci=_unit_price(newest.price_cents, newest.pack_size),
|
||||
min_cents=min(amounts),
|
||||
max_cents=max(amounts),
|
||||
observations=len(entries),
|
||||
)
|
||||
)
|
||||
|
||||
rows.sort(key=lambda r: r.latest_cents)
|
||||
|
||||
history = [
|
||||
PriceEntry(
|
||||
market_id=p.market_id,
|
||||
market_name=markets[p.market_id].name if p.market_id in markets else "?",
|
||||
price_cents=p.price_cents,
|
||||
pack_size=p.pack_size,
|
||||
pack_unit=p.pack_unit,
|
||||
recorded_at=p.recorded_at,
|
||||
)
|
||||
for p in points[:100]
|
||||
if p.market_id in markets
|
||||
]
|
||||
|
||||
return ArticlePrices(
|
||||
article_id=article.id,
|
||||
article_name=article.name,
|
||||
markets=rows,
|
||||
history=history,
|
||||
)
|
||||
|
||||
|
||||
def overview(db: Session, list_id: str) -> PriceOverview:
|
||||
"""Vergleichstabelle über alle Artikel mit erfassten Preisen."""
|
||||
points = db.scalars(
|
||||
select(PricePoint)
|
||||
.where(PricePoint.list_id == list_id)
|
||||
.order_by(PricePoint.recorded_at.desc())
|
||||
.limit(5000)
|
||||
).all()
|
||||
|
||||
markets = {
|
||||
m.id: m
|
||||
for m in db.scalars(
|
||||
select(Market).where(
|
||||
Market.list_id == list_id, Market.deleted_at.is_(None)
|
||||
).order_by(Market.sort_order, Market.name)
|
||||
).all()
|
||||
}
|
||||
articles = {
|
||||
a.id: a
|
||||
for a in db.scalars(
|
||||
select(Article).where(
|
||||
Article.list_id == list_id, Article.deleted_at.is_(None)
|
||||
)
|
||||
).all()
|
||||
}
|
||||
|
||||
# Je Artikel und Markt nur der jüngste Wert - die Liste ist bereits
|
||||
# absteigend sortiert, der erste Treffer gewinnt.
|
||||
latest: dict[tuple[str, str], PricePoint] = {}
|
||||
for point in points:
|
||||
key = (point.article_id, point.market_id)
|
||||
if key not in latest:
|
||||
latest[key] = point
|
||||
|
||||
rows: list[PriceOverviewRow] = []
|
||||
for article_id, article in articles.items():
|
||||
prices = {
|
||||
market_id: point.price_cents
|
||||
for (aid, market_id), point in latest.items()
|
||||
if aid == article_id and market_id in markets
|
||||
}
|
||||
if not prices:
|
||||
continue
|
||||
|
||||
cheapest = min(prices.values())
|
||||
best = [mid for mid, cents in prices.items() if cents == cheapest]
|
||||
dearest = max(prices.values())
|
||||
|
||||
rows.append(
|
||||
PriceOverviewRow(
|
||||
article_id=article_id,
|
||||
article_name=article.name,
|
||||
prices=prices,
|
||||
best_market_ids=best,
|
||||
best_cents=cheapest,
|
||||
spread_cents=dearest - cheapest,
|
||||
)
|
||||
)
|
||||
|
||||
rows.sort(key=lambda r: r.article_name.casefold())
|
||||
|
||||
return PriceOverview(
|
||||
markets=[
|
||||
MarketPrice(
|
||||
market_id=m.id, market_name=m.name, latest_cents=0,
|
||||
latest_at=utcnow(), min_cents=0, max_cents=0, observations=0,
|
||||
)
|
||||
for m in markets.values()
|
||||
],
|
||||
market_names={m.id: m.name for m in markets.values()},
|
||||
rows=rows,
|
||||
)
|
||||
|
||||
|
||||
def hints(db: Session, list_id: str) -> list[PriceHint]:
|
||||
"""Kurzhinweise für die Listenansicht: Wo war dieser Artikel zuletzt
|
||||
am günstigsten?"""
|
||||
data = overview(db, list_id)
|
||||
return [
|
||||
PriceHint(
|
||||
article_id=row.article_id,
|
||||
best_market_ids=row.best_market_ids,
|
||||
best_cents=row.best_cents,
|
||||
spread_cents=row.spread_cents,
|
||||
prices=row.prices,
|
||||
)
|
||||
for row in data.rows
|
||||
if row.spread_cents > 0
|
||||
]
|
||||
127
backend/app/print_view.py
Normal file
127
backend/app/print_view.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""Druckansicht als eigenständige HTML-Seite.
|
||||
|
||||
Warum serverseitig, obwohl die App schon `@media print` mitbringt: Der
|
||||
Ausdruck soll auch ohne geöffnete App möglich sein - aus einem Lesezeichen,
|
||||
über einen öffentlichen Link, oder von einem Rechner, auf dem niemand die
|
||||
PWA installiert hat. Die Gliederung stammt aus `app.list_view`, also
|
||||
derselben Quelle wie die Bildschirmansicht; ein zweiter Sortieralgorithmus
|
||||
würde mit der Zeit abweichen.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from jinja2 import Environment, select_autoescape
|
||||
|
||||
from app.config import settings
|
||||
from app.schemas_shopping import ListView
|
||||
|
||||
# autoescape ist hier keine Formsache: Artikelnamen und Notizen sind
|
||||
# freie Nutzereingaben und landen direkt im HTML.
|
||||
_env = Environment(autoescape=select_autoescape(default=True, default_for_string=True))
|
||||
|
||||
_TEMPLATE = _env.from_string("""<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{ view.list_name }} – {{ app_name }}</title>
|
||||
<!-- Stil und Skript liegen als eigene Dateien vor, nicht im Dokument:
|
||||
Die Content-Security-Policy erlaubt weder unsafe-inline noch
|
||||
Ereignisbehandler im Markup. Eingebetteter Stil käme unformatiert
|
||||
an, ohne dass irgendwo ein Fehler sichtbar wäre. -->
|
||||
<link rel="stylesheet" href="/print.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="toolbar">
|
||||
<button type="button" id="print-button">Drucken</button>
|
||||
</div>
|
||||
|
||||
<header>
|
||||
<h1>{{ view.list_name }}</h1>
|
||||
<div class="meta">
|
||||
Stand {{ printed_at }}
|
||||
{%- if open_count %} · {{ open_count }} offene Position{{ "en" if open_count != 1 }}{% endif %}
|
||||
{%- if not include_bought %} · gekaufte Artikel ausgeblendet{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{% if not view.markets %}
|
||||
<p class="empty">Die Liste ist leer.</p>
|
||||
{% endif %}
|
||||
|
||||
{% for market in view.markets %}
|
||||
<section class="market">
|
||||
<h2>
|
||||
<span>{{ market.market_name }}</span>
|
||||
{% if market.total_cents %}<span class="sum">{{ money(market.total_cents) }}</span>{% endif %}
|
||||
</h2>
|
||||
|
||||
{% for category in market.categories %}
|
||||
<div class="category">
|
||||
<h3>{{ category.category_name }}</h3>
|
||||
<table>
|
||||
<tbody>
|
||||
{% for item in category.items %}
|
||||
<tr class="{{ 'bought' if item.status == 'bought' }}">
|
||||
<td class="box"><span></span></td>
|
||||
<td class="count {{ 'many' if item.count > 1 }}">{{ item.count }}×</td>
|
||||
<td class="name">
|
||||
<span class="article">{{ item.article_name }}</span>
|
||||
{% set detail = [] %}
|
||||
{%- if item.pack_size %}{% set _ = detail.append(pack(item)) %}{% endif %}
|
||||
{%- if item.variant %}{% set _ = detail.append(item.variant) %}{% endif %}
|
||||
{%- if item.note %}{% set _ = detail.append(item.note) %}{% endif %}
|
||||
{%- if detail %}<span class="detail">{{ detail | join(" · ") }}</span>{% endif %}
|
||||
</td>
|
||||
<td class="price">{{ money(item.total_cents) if item.total_cents else "" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endfor %}
|
||||
|
||||
<footer>
|
||||
<span>Gesamt</span>
|
||||
<span class="total">{{ money(view.grand_total_cents) }}</span>
|
||||
</footer>
|
||||
|
||||
<p class="note">{{ app_name }} · erzeugt {{ printed_at }}</p>
|
||||
|
||||
<script src="/print.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
""")
|
||||
|
||||
|
||||
def _money(cents: int | None) -> str:
|
||||
value = (cents or 0) / 100
|
||||
return f"{value:,.2f} €".replace(",", "\u00a0").replace(".", ",")
|
||||
|
||||
|
||||
def _pack(item) -> str:
|
||||
"""Gebindegröße lesbar machen: 250 statt 250.000, 1,5 statt 1.500.
|
||||
|
||||
Nicht über rstrip("0"): Bei einem Wert ohne Dezimalpunkt - und den
|
||||
liefert Decimal("250") - würde das die letzte Null abschneiden und
|
||||
aus 250 g plötzlich 25 g machen. `normalize()` entfernt nur
|
||||
tatsächlich überflüssige Nachkommastellen.
|
||||
"""
|
||||
value = item.pack_size
|
||||
text = format(value.normalize(), "f") if hasattr(value, "normalize") else str(value)
|
||||
return f"{text.replace('.', ',')} {item.pack_unit or ''}".strip()
|
||||
|
||||
|
||||
def render_print(view: ListView, *, include_bought: bool = True) -> str:
|
||||
open_count = sum(market.open_count for market in view.markets)
|
||||
return _TEMPLATE.render(
|
||||
view=view,
|
||||
app_name=settings.app_name,
|
||||
printed_at=datetime.now().strftime("%d.%m.%Y, %H:%M"),
|
||||
open_count=open_count,
|
||||
include_bought=include_bought,
|
||||
money=_money,
|
||||
pack=_pack,
|
||||
)
|
||||
193
backend/app/product_lookup.py
Normal file
193
backend/app/product_lookup.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""Nachschlagen von Strichcodes bei Open Food Facts.
|
||||
|
||||
Bewusst über diesen Server statt direkt aus dem Browser:
|
||||
|
||||
* Die IP-Adresse der Nutzer geht nicht an einen Dritten. Bei einer
|
||||
Abfrage aus dem Browser wüsste Open Food Facts, wer wann welches
|
||||
Produkt scannt - genau die Art von Datenspur, die vermieden werden
|
||||
soll.
|
||||
* Die Content-Security-Policy bleibt bei `connect-src 'self'`. Eine
|
||||
Ausnahme für eine fremde Domain zu öffnen, wäre eine dauerhafte
|
||||
Schwächung für eine gelegentliche Abfrage.
|
||||
* Derselbe Code wird nur einmal abgefragt, egal wie viele Geräte ihn
|
||||
scannen. Das schont auch den fremden Dienst.
|
||||
|
||||
Abschaltbar über PRODUCT_LOOKUP=off in der .env. Dann bleibt der eigene
|
||||
Artikelstamm die einzige Quelle.
|
||||
|
||||
Die Daten stammen aus Open Food Facts und stehen unter der Open Database
|
||||
License (ODbL). Für die Verwendung in einer privaten Einkaufsliste ist
|
||||
das unproblematisch; wer sie weiterverbreitet, muss die Lizenz beachten.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.models import ProductCache
|
||||
from app.security import utcnow
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
API_URL = "https://world.openfoodfacts.org/api/v2/product/{barcode}.json"
|
||||
FIELDS = "product_name,product_name_de,generic_name_de,generic_name,brands,quantity"
|
||||
|
||||
# Open Food Facts verlangt eine aussagekraeftige Kennung. Ohne sie
|
||||
# werden Anfragen abgewiesen.
|
||||
def _user_agent() -> str:
|
||||
return f"{settings.app_name}/1.0 ({settings.public_base_url})"
|
||||
|
||||
|
||||
_UNITS = r"kg|g|mg|l|ml|cl|dl|stk|stück|st"
|
||||
|
||||
# Mehrstueckpackung zuerst pruefen: "6 x 33 cl", "4x500g"
|
||||
_MULTIPACK = re.compile(
|
||||
rf"(?P<count>\d+)\s*[x×*]\s*(?P<value>\d+(?:[.,]\d+)?)\s*(?P<unit>{_UNITS})\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# "500 g", "1,5 l", "250ml"
|
||||
_QUANTITY = re.compile(
|
||||
rf"(?P<value>\d+(?:[.,]\d+)?)\s*(?P<unit>{_UNITS})\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def parse_package(
|
||||
text: str | None,
|
||||
) -> tuple[int | None, Decimal | None, str | None]:
|
||||
"""Zerlegt eine Mengenangabe in Stueckzahl, Gebinde und Einheit.
|
||||
|
||||
"500 g" -> (None, 500, "g") eine Packung zu 500 g
|
||||
"6 x 33 cl" -> (6, 33, "cl") sechs Flaschen zu je 33 cl
|
||||
|
||||
Gibt (None, None, None) zurueck, wenn nichts Verwertbares drinsteht -
|
||||
dann werden die Felder nicht vorbelegt, statt zu raten.
|
||||
"""
|
||||
if not text:
|
||||
return None, None, None
|
||||
|
||||
def normalize(raw: str) -> str:
|
||||
return {"stück": "Stk", "stk": "Stk", "st": "Stk"}.get(raw.lower(), raw.lower())
|
||||
|
||||
# Mehrstueckpackung: Stueckzahl und Gebinde getrennt.
|
||||
multi = _MULTIPACK.search(text)
|
||||
if multi:
|
||||
try:
|
||||
count = int(multi.group("count"))
|
||||
size = Decimal(multi.group("value").replace(",", "."))
|
||||
except (InvalidOperation, ValueError):
|
||||
return None, None, None
|
||||
return count, size, normalize(multi.group("unit"))
|
||||
|
||||
match = _QUANTITY.search(text)
|
||||
if not match:
|
||||
return None, None, None
|
||||
|
||||
try:
|
||||
value = Decimal(match.group("value").replace(",", "."))
|
||||
except InvalidOperation:
|
||||
return None, None, None
|
||||
|
||||
return None, value, normalize(match.group("unit"))
|
||||
|
||||
|
||||
def _fetch(barcode: str) -> dict | None:
|
||||
"""Einzelne Abfrage. Gibt None zurueck, wenn nichts gefunden wurde
|
||||
oder der Dienst nicht erreichbar war - der Aufrufer unterscheidet
|
||||
das nicht, weil es fuer ihn dasselbe Ergebnis bedeutet."""
|
||||
try:
|
||||
response = httpx.get(
|
||||
API_URL.format(barcode=barcode),
|
||||
params={"fields": FIELDS},
|
||||
headers={"User-Agent": _user_agent()},
|
||||
timeout=settings.product_lookup_timeout,
|
||||
follow_redirects=True,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
log.warning("Produktabfrage für %s fehlgeschlagen: %s", barcode, exc)
|
||||
return None
|
||||
|
||||
if response.status_code != 200:
|
||||
return None
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if data.get("status") != 1:
|
||||
return None
|
||||
|
||||
product = data.get("product") or {}
|
||||
# Deutsche Bezeichnung bevorzugen, dann die allgemeine.
|
||||
name = (
|
||||
product.get("product_name_de")
|
||||
or product.get("product_name")
|
||||
or product.get("generic_name_de")
|
||||
or product.get("generic_name")
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
if not name:
|
||||
return None
|
||||
|
||||
brands = (product.get("brands") or "").split(",")[0].strip()
|
||||
package = (product.get("quantity") or "").strip()
|
||||
|
||||
return {
|
||||
"name": name[:300],
|
||||
"brand": brands[:200] or None,
|
||||
"package": package[:120] or None,
|
||||
}
|
||||
|
||||
|
||||
def lookup(db: Session, barcode: str) -> ProductCache | None:
|
||||
"""Nachschlagen mit Zwischenspeicher.
|
||||
|
||||
@returns None, wenn die Funktion abgeschaltet ist. Sonst immer einen
|
||||
Datensatz - auch bei Misserfolg, damit derselbe Code nicht bei jedem
|
||||
Scan erneut nach draußen geht.
|
||||
"""
|
||||
if settings.product_lookup == "off":
|
||||
return None
|
||||
|
||||
barcode = barcode.strip()
|
||||
if not barcode.isdigit() or not 6 <= len(barcode) <= 20:
|
||||
return None
|
||||
|
||||
cached = db.get(ProductCache, barcode)
|
||||
if cached is not None:
|
||||
age = utcnow() - cached.fetched_at
|
||||
max_age = timedelta(
|
||||
days=settings.product_cache_days if cached.found
|
||||
else settings.product_miss_days
|
||||
)
|
||||
if age < max_age:
|
||||
return cached
|
||||
|
||||
result = _fetch(barcode)
|
||||
count, pack_size, pack_unit = parse_package(
|
||||
result.get("package") if result else None)
|
||||
|
||||
if cached is None:
|
||||
cached = ProductCache(barcode=barcode)
|
||||
db.add(cached)
|
||||
|
||||
cached.found = result is not None
|
||||
cached.name = result["name"] if result else None
|
||||
cached.brand = result["brand"] if result else None
|
||||
cached.package = result["package"] if result else None
|
||||
cached.count = count
|
||||
cached.pack_size = pack_size
|
||||
cached.pack_unit = pack_unit
|
||||
cached.source = "openfoodfacts"
|
||||
cached.fetched_at = utcnow()
|
||||
db.commit()
|
||||
db.refresh(cached)
|
||||
return cached
|
||||
153
backend/app/push.py
Normal file
153
backend/app/push.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""Push-Benachrichtigungen mit Zwei-Stunden-Drosselung.
|
||||
|
||||
Regel: Wer eine Liste ändert, löst bei allen anderen Mitgliedern eine
|
||||
Benachrichtigung aus - aber höchstens eine je Liste und Person innerhalb
|
||||
des eingestellten Zeitraums.
|
||||
|
||||
Der Grund: Wer im Laden steht und abhakt, erzeugt in wenigen Minuten
|
||||
Dutzende Änderungen. Ohne Drosselung bekämen die anderen ebenso viele
|
||||
Meldungen und würden die Funktion nach dem ersten Einkauf abschalten.
|
||||
|
||||
Was in der Nachricht steht, ist bewusst knapp: Listenname und wer sie
|
||||
geändert hat. Keine Artikelnamen. Eine Benachrichtigung erscheint auf
|
||||
dem gesperrten Bildschirm, und was dort steht, sieht jeder, der das
|
||||
Gerät gerade in der Hand hält.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.models import ListMember, NotifyState, PushSubscription, ShoppingList, User
|
||||
from app.security import utcnow
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Fehlercodes, bei denen die Anmeldung endgültig ungültig ist.
|
||||
_GONE = (404, 410)
|
||||
|
||||
|
||||
def _send_one(subscription: PushSubscription, payload: dict) -> str:
|
||||
"""Zustellversuch an einen Endpunkt.
|
||||
|
||||
@returns "ok" | "gone" | "error"
|
||||
"""
|
||||
try:
|
||||
from pywebpush import WebPushException, webpush
|
||||
except ImportError:
|
||||
log.error("pywebpush ist nicht installiert - Push nicht möglich.")
|
||||
return "error"
|
||||
|
||||
try:
|
||||
webpush(
|
||||
subscription_info={
|
||||
"endpoint": subscription.endpoint,
|
||||
"keys": {"p256dh": subscription.p256dh, "auth": subscription.auth},
|
||||
},
|
||||
data=json.dumps(payload),
|
||||
vapid_private_key=settings.vapid_private_key,
|
||||
vapid_claims={"sub": settings.vapid_contact},
|
||||
timeout=settings.push_timeout,
|
||||
ttl=3600,
|
||||
)
|
||||
return "ok"
|
||||
except WebPushException as exc:
|
||||
status = getattr(exc.response, "status_code", None)
|
||||
if status in _GONE:
|
||||
# Der Browser hat die Anmeldung verworfen - typisch nach
|
||||
# Deinstallation oder Löschen der Websitedaten.
|
||||
return "gone"
|
||||
log.warning("Push an %s… fehlgeschlagen (%s): %s",
|
||||
subscription.endpoint[:40], status, exc)
|
||||
return "error"
|
||||
except Exception:
|
||||
log.exception("Push an %s… fehlgeschlagen", subscription.endpoint[:40])
|
||||
return "error"
|
||||
|
||||
|
||||
def send_to_user(db: Session, user_id: str, payload: dict) -> int:
|
||||
"""Zustellung an alle Geräte einer Person. Gibt die Zahl der
|
||||
erfolgreichen Zustellungen zurück."""
|
||||
if not settings.push_enabled:
|
||||
return 0
|
||||
|
||||
subscriptions = db.scalars(
|
||||
select(PushSubscription).where(PushSubscription.user_id == user_id)
|
||||
).all()
|
||||
|
||||
delivered = 0
|
||||
for subscription in subscriptions:
|
||||
result = _send_one(subscription, payload)
|
||||
if result == "ok":
|
||||
subscription.last_success_at = utcnow()
|
||||
subscription.failure_count = 0
|
||||
delivered += 1
|
||||
elif result == "gone":
|
||||
db.delete(subscription)
|
||||
else:
|
||||
subscription.failure_count += 1
|
||||
# Nach genug Fehlschlägen aufräumen: Ein Endpunkt, der
|
||||
# dauerhaft nicht antwortet, wird nicht wieder gut.
|
||||
if subscription.failure_count >= 10:
|
||||
db.delete(subscription)
|
||||
|
||||
db.commit()
|
||||
return delivered
|
||||
|
||||
|
||||
def notify_list_changed(list_id: str, actor_id: str) -> None:
|
||||
"""Benachrichtigt die übrigen Mitglieder über eine Änderung.
|
||||
|
||||
Läuft im Hintergrund (BackgroundTasks) und öffnet dafür eine eigene
|
||||
Sitzung: Die Sitzung des Requests ist zu dem Zeitpunkt bereits
|
||||
geschlossen.
|
||||
"""
|
||||
if not settings.push_enabled:
|
||||
return
|
||||
|
||||
with SessionLocal() as db:
|
||||
lst = db.get(ShoppingList, list_id)
|
||||
if lst is None or lst.deleted_at is not None:
|
||||
return
|
||||
|
||||
actor = db.get(User, actor_id)
|
||||
actor_name = (
|
||||
(actor.display_name or actor.email.split("@")[0]) if actor else "Jemand"
|
||||
)
|
||||
|
||||
members = db.scalars(
|
||||
select(ListMember).where(
|
||||
ListMember.list_id == list_id, ListMember.user_id != actor_id
|
||||
)
|
||||
).all()
|
||||
|
||||
threshold = utcnow() - timedelta(hours=settings.push_throttle_hours)
|
||||
|
||||
for member in members:
|
||||
state = db.get(NotifyState, (list_id, member.user_id))
|
||||
if state is not None and state.last_notified_at > threshold:
|
||||
continue # innerhalb der Sperrfrist
|
||||
|
||||
payload = {
|
||||
"title": lst.name,
|
||||
"body": f"{actor_name} hat die Liste geändert.",
|
||||
"list_id": list_id,
|
||||
"tag": f"list-{list_id}",
|
||||
}
|
||||
delivered = send_to_user(db, member.user_id, payload)
|
||||
if delivered == 0:
|
||||
# Nichts zugestellt - dann auch nicht die Sperrfrist
|
||||
# starten, sonst verpasst die Person die nächste Änderung.
|
||||
continue
|
||||
|
||||
if state is None:
|
||||
db.add(NotifyState(list_id=list_id, user_id=member.user_id,
|
||||
last_notified_at=utcnow()))
|
||||
else:
|
||||
state.last_notified_at = utcnow()
|
||||
db.commit()
|
||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
458
backend/app/routers/admin.py
Normal file
458
backend/app/routers/admin.py
Normal file
@@ -0,0 +1,458 @@
|
||||
"""Administration: Einstellungen, Mailprüfung, Benutzerverwaltung."""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import settings
|
||||
from app.deps import (
|
||||
AdminUser,
|
||||
DbSession,
|
||||
client_ip,
|
||||
get_setting,
|
||||
registration_locked_by_env,
|
||||
self_registration_enabled,
|
||||
set_setting,
|
||||
)
|
||||
from app.mail import (
|
||||
check_connection,
|
||||
send_email_change_notice,
|
||||
send_email_change_verify,
|
||||
send_email_change_verify_old,
|
||||
send_test_mail,
|
||||
send_welcome,
|
||||
)
|
||||
from app.maintenance import describe, run_cleanup
|
||||
from app.models import EmailChange, User
|
||||
from app.schemas import MailCheckOut, MailTestIn, MessageOut
|
||||
from app.schemas_admin import (
|
||||
AdminSettingsIn,
|
||||
AdminSettingsOut,
|
||||
AdminStatsOut,
|
||||
AdminUserOut,
|
||||
DeleteUserIn,
|
||||
EmailChangeIn,
|
||||
UserCreateIn,
|
||||
)
|
||||
from app.security import check_rate_limit, normalize_email, utcnow
|
||||
from app.users import (
|
||||
EMAIL_CHANGE_HOURS,
|
||||
WELCOME_DAYS,
|
||||
create_user,
|
||||
deactivate,
|
||||
delete_user,
|
||||
issue_token,
|
||||
membership_counts,
|
||||
months_setting,
|
||||
owned_list_counts,
|
||||
reactivate,
|
||||
start_email_change,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
DEFAULT_DEACTIVATE_MONTHS = 12
|
||||
DEFAULT_DELETE_MONTHS = 12
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Einstellungen
|
||||
# ==========================================================================
|
||||
|
||||
def _settings_out(db) -> AdminSettingsOut:
|
||||
return AdminSettingsOut(
|
||||
allow_self_registration=self_registration_enabled(db),
|
||||
locked_by_env=registration_locked_by_env(),
|
||||
auto_deactivate_months=months_setting(
|
||||
db, "auto_deactivate_months", DEFAULT_DEACTIVATE_MONTHS
|
||||
),
|
||||
auto_delete_months=months_setting(
|
||||
db, "auto_delete_months", DEFAULT_DELETE_MONTHS
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/settings", response_model=AdminSettingsOut)
|
||||
def read_settings(db: DbSession, admin: AdminUser):
|
||||
return _settings_out(db)
|
||||
|
||||
|
||||
@router.put("/settings", response_model=AdminSettingsOut)
|
||||
def write_settings(payload: AdminSettingsIn, db: DbSession, admin: AdminUser):
|
||||
if payload.allow_self_registration is not None:
|
||||
if registration_locked_by_env():
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"Die Selbstregistrierung ist über die Umgebungsvariable "
|
||||
f"ALLOW_SELF_REGISTRATION={settings.allow_self_registration} "
|
||||
"festgelegt und lässt sich hier nicht ändern. Setze die Variable "
|
||||
"auf 'admin', um sie über diese Schnittstelle steuerbar zu machen.",
|
||||
)
|
||||
set_setting(
|
||||
db, "allow_self_registration",
|
||||
"true" if payload.allow_self_registration else "false",
|
||||
)
|
||||
|
||||
if payload.auto_deactivate_months is not None:
|
||||
set_setting(db, "auto_deactivate_months", str(payload.auto_deactivate_months))
|
||||
if payload.auto_delete_months is not None:
|
||||
set_setting(db, "auto_delete_months", str(payload.auto_delete_months))
|
||||
|
||||
db.commit()
|
||||
return _settings_out(db)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Benutzerübersicht
|
||||
# ==========================================================================
|
||||
|
||||
def _user_out(
|
||||
user: User,
|
||||
owned: dict[str, int],
|
||||
memberships: dict[str, int],
|
||||
pending: dict[str, str],
|
||||
) -> AdminUserOut:
|
||||
return AdminUserOut(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
display_name=user.display_name,
|
||||
is_admin=user.is_admin,
|
||||
is_active=user.is_active,
|
||||
verified=user.verified_at is not None,
|
||||
last_seen_at=user.last_seen_at,
|
||||
deactivated_at=user.deactivated_at,
|
||||
created_at=user.created_at,
|
||||
owned_lists=owned.get(user.id, 0),
|
||||
memberships=memberships.get(user.id, 0),
|
||||
pending_email=pending.get(user.id),
|
||||
)
|
||||
|
||||
|
||||
def _pending_changes(db) -> dict[str, str]:
|
||||
rows = db.scalars(
|
||||
select(EmailChange).where(
|
||||
EmailChange.applied_at.is_(None),
|
||||
EmailChange.cancelled_at.is_(None),
|
||||
EmailChange.expires_at > utcnow(),
|
||||
)
|
||||
).all()
|
||||
return {row.user_id: row.new_email for row in rows}
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[AdminUserOut])
|
||||
def list_users(db: DbSession, admin: AdminUser):
|
||||
users = db.scalars(select(User).order_by(User.created_at)).all()
|
||||
owned = owned_list_counts(db)
|
||||
memberships = membership_counts(db)
|
||||
pending = _pending_changes(db)
|
||||
return [_user_out(u, owned, memberships, pending) for u in users]
|
||||
|
||||
|
||||
@router.get("/stats", response_model=AdminStatsOut)
|
||||
def stats(db: DbSession, admin: AdminUser):
|
||||
users = db.scalars(select(User)).all()
|
||||
now = utcnow()
|
||||
|
||||
deactivate_months = months_setting(
|
||||
db, "auto_deactivate_months", DEFAULT_DEACTIVATE_MONTHS)
|
||||
delete_months = months_setting(db, "auto_delete_months", DEFAULT_DELETE_MONTHS)
|
||||
|
||||
due_deactivation = 0
|
||||
due_deletion = 0
|
||||
for user in users:
|
||||
# Administratorkonten unterliegen der Automatik nicht.
|
||||
if user.is_admin:
|
||||
continue
|
||||
if (
|
||||
deactivate_months
|
||||
and user.is_active
|
||||
and user.last_seen_at
|
||||
and user.last_seen_at < now - timedelta(days=deactivate_months * 30)
|
||||
):
|
||||
due_deactivation += 1
|
||||
if (
|
||||
delete_months
|
||||
and not user.is_active
|
||||
and user.deactivated_at
|
||||
and user.deactivated_at < now - timedelta(days=delete_months * 30)
|
||||
):
|
||||
due_deletion += 1
|
||||
|
||||
return AdminStatsOut(
|
||||
total=len(users),
|
||||
active=sum(1 for u in users if u.is_active),
|
||||
inactive=sum(1 for u in users if not u.is_active),
|
||||
unverified=sum(1 for u in users if u.verified_at is None),
|
||||
admins=sum(1 for u in users if u.is_admin),
|
||||
due_deactivation=due_deactivation,
|
||||
due_deletion=due_deletion,
|
||||
)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Benutzer anlegen
|
||||
# ==========================================================================
|
||||
|
||||
def _target(db, user_id: str) -> User:
|
||||
user = db.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Konto nicht gefunden")
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/users", response_model=AdminUserOut, status_code=status.HTTP_201_CREATED)
|
||||
def add_user(
|
||||
payload: UserCreateIn,
|
||||
request: Request,
|
||||
db: DbSession,
|
||||
admin: AdminUser,
|
||||
background: BackgroundTasks,
|
||||
):
|
||||
if not check_rate_limit(
|
||||
db, f"admin-create:{client_ip(request)}", limit=30, window_minutes=60
|
||||
):
|
||||
db.commit()
|
||||
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Anlagen.")
|
||||
|
||||
email = normalize_email(payload.email)
|
||||
if db.scalar(select(User).where(User.email == email)) is not None:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, "Unter dieser Adresse existiert bereits ein Konto."
|
||||
)
|
||||
|
||||
user, token = create_user(
|
||||
db, email=email, display_name=payload.display_name, is_admin=payload.is_admin
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
background.add_task(
|
||||
send_welcome, email, token,
|
||||
admin.display_name or admin.email.split("@")[0], WELCOME_DAYS,
|
||||
)
|
||||
return _user_out(user, {}, {}, {})
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/welcome", response_model=MessageOut)
|
||||
def resend_welcome(
|
||||
user_id: str,
|
||||
db: DbSession,
|
||||
admin: AdminUser,
|
||||
background: BackgroundTasks,
|
||||
):
|
||||
"""Erneuter Versand der Willkommensnachricht.
|
||||
|
||||
Nur solange das Konto noch nicht bestätigt ist. Danach wäre es kein
|
||||
Willkommensgruß mehr, sondern ein vom Administrator ausgelöstes
|
||||
Zurücksetzen des Passworts - das soll vom Kontoinhaber ausgehen und
|
||||
läuft über "Passwort vergessen".
|
||||
"""
|
||||
user = _target(db, user_id)
|
||||
if user.verified_at is not None:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"Dieses Konto ist bereits eingerichtet. Für ein neues Passwort "
|
||||
"nutzt die Person „Passwort vergessen“ auf der Anmeldeseite.",
|
||||
)
|
||||
|
||||
if not check_rate_limit(db, f"admin-welcome:{user.id}", limit=5, window_minutes=60):
|
||||
db.commit()
|
||||
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Versuche.")
|
||||
|
||||
token = issue_token(db, user, "welcome", hours=WELCOME_DAYS * 24)
|
||||
db.commit()
|
||||
|
||||
background.add_task(
|
||||
send_welcome, user.email, token,
|
||||
admin.display_name or admin.email.split("@")[0], WELCOME_DAYS,
|
||||
)
|
||||
return MessageOut(detail=f"Willkommensnachricht erneut an {user.email} versendet.")
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Adressänderung
|
||||
# ==========================================================================
|
||||
|
||||
@router.post("/users/{user_id}/email", response_model=MessageOut)
|
||||
def change_email(
|
||||
user_id: str,
|
||||
payload: EmailChangeIn,
|
||||
db: DbSession,
|
||||
admin: AdminUser,
|
||||
background: BackgroundTasks,
|
||||
):
|
||||
user = _target(db, user_id)
|
||||
target = normalize_email(payload.new_email)
|
||||
|
||||
if target == user.email:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, "Das ist bereits die aktuelle Adresse."
|
||||
)
|
||||
if db.scalar(select(User).where(User.email == target)) is not None:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, "Unter dieser Adresse existiert bereits ein Konto."
|
||||
)
|
||||
|
||||
old_email = user.email
|
||||
change, raw_new, raw_old = start_email_change(db, user, target, admin.id)
|
||||
db.commit()
|
||||
|
||||
background.add_task(
|
||||
send_email_change_verify, target, raw_new, old_email, EMAIL_CHANGE_HOURS
|
||||
)
|
||||
if change.requires_old and raw_old:
|
||||
background.add_task(
|
||||
send_email_change_verify_old, old_email, raw_old, target, EMAIL_CHANGE_HOURS
|
||||
)
|
||||
detail = (
|
||||
f"Bestätigungslinks an {target} und {old_email} versendet. "
|
||||
"Bei Administratorkonten müssen beide Adressen zustimmen; die "
|
||||
"Änderung wird erst danach wirksam."
|
||||
)
|
||||
else:
|
||||
background.add_task(
|
||||
send_email_change_notice, old_email, target, EMAIL_CHANGE_HOURS
|
||||
)
|
||||
detail = (
|
||||
f"Bestätigungslink an {target} versendet, Hinweis an {old_email}. "
|
||||
"Die Änderung wird wirksam, sobald die neue Adresse bestätigt hat."
|
||||
)
|
||||
|
||||
return MessageOut(detail=detail)
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}/email", response_model=MessageOut)
|
||||
def cancel_email_change(user_id: str, db: DbSession, admin: AdminUser):
|
||||
user = _target(db, user_id)
|
||||
count = 0
|
||||
for change in db.scalars(
|
||||
select(EmailChange).where(
|
||||
EmailChange.user_id == user.id,
|
||||
EmailChange.applied_at.is_(None),
|
||||
EmailChange.cancelled_at.is_(None),
|
||||
)
|
||||
).all():
|
||||
change.cancelled_at = utcnow()
|
||||
count += 1
|
||||
db.commit()
|
||||
return MessageOut(
|
||||
detail=f"{count} offene(r) Adresswechsel zurückgezogen."
|
||||
if count else "Es lief kein Adresswechsel."
|
||||
)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Deaktivieren, Reaktivieren, Löschen
|
||||
# ==========================================================================
|
||||
|
||||
def _guard_admin_target(user: User, admin: User, action: str) -> None:
|
||||
"""Administratorkonten sind vor Deaktivierung und Löschung geschützt.
|
||||
|
||||
Sonst könnte ein Administrator alle anderen aussperren - oder sich
|
||||
selbst, und dann käme niemand mehr an die Verwaltung. Wer einen
|
||||
Administrator entfernen will, nimmt ihm zuerst die Rechte; das geht
|
||||
bewusst nur direkt in der Datenbank und ist damit ein Schritt, den
|
||||
man nicht versehentlich tut.
|
||||
"""
|
||||
if user.is_admin:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
f"Administratorkonten können nicht {action} werden. Entziehe die "
|
||||
"Administratorrechte zuerst - das geht bewusst nur direkt in der "
|
||||
"Datenbank.",
|
||||
)
|
||||
if user.id == admin.id:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, "Das eigene Konto lässt sich nicht ändern."
|
||||
)
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/deactivate", response_model=AdminUserOut)
|
||||
def deactivate_user(user_id: str, db: DbSession, admin: AdminUser):
|
||||
user = _target(db, user_id)
|
||||
_guard_admin_target(user, admin, "deaktiviert")
|
||||
deactivate(db, user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return _user_out(user, owned_list_counts(db), membership_counts(db), {})
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/activate", response_model=AdminUserOut)
|
||||
def activate_user(user_id: str, db: DbSession, admin: AdminUser):
|
||||
user = _target(db, user_id)
|
||||
reactivate(db, user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return _user_out(user, owned_list_counts(db), membership_counts(db), {})
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/delete", response_model=MessageOut)
|
||||
def remove_user(user_id: str, payload: DeleteUserIn, db: DbSession, admin: AdminUser):
|
||||
"""Löschen als POST mit Rumpf, nicht als DELETE.
|
||||
|
||||
Der Vorgang braucht zwei Angaben: was mit den Listen geschehen soll
|
||||
und eine Bestätigung der Adresse. Ein DELETE mit Rumpf ist in
|
||||
Zwischenschichten unzuverlässig.
|
||||
"""
|
||||
user = _target(db, user_id)
|
||||
_guard_admin_target(user, admin, "gelöscht")
|
||||
|
||||
if normalize_email(payload.confirm_email) != user.email:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"Die Bestätigungsadresse stimmt nicht mit dem Konto überein.",
|
||||
)
|
||||
|
||||
owned = owned_list_counts(db).get(user.id, 0)
|
||||
if owned and payload.lists == "refuse":
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
f"Diesem Konto gehören {owned} Liste(n). Wähle „übergeben“, um "
|
||||
"geteilte Listen an das dienstälteste andere Mitglied zu "
|
||||
"übertragen und die übrigen zu löschen.",
|
||||
)
|
||||
|
||||
email = user.email
|
||||
counts = delete_user(db, user)
|
||||
db.commit()
|
||||
return MessageOut(
|
||||
detail=f"Konto {email} gelöscht. "
|
||||
f"{counts['übertragene Listen']} Liste(n) übertragen, "
|
||||
f"{counts['gelöschte Listen']} gelöscht."
|
||||
)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Betrieb
|
||||
# ==========================================================================
|
||||
|
||||
@router.post("/cleanup", response_model=MessageOut)
|
||||
def trigger_cleanup(db: DbSession, admin: AdminUser):
|
||||
"""Räumt sofort auf, statt auf den täglichen Durchlauf zu warten."""
|
||||
counts = run_cleanup(db)
|
||||
return MessageOut(detail=f"Aufgeräumt: {describe(counts)}.")
|
||||
|
||||
|
||||
@router.get("/mail/check", response_model=MailCheckOut)
|
||||
def mail_check(admin: AdminUser):
|
||||
"""Verbindungstest zum Relay, ohne eine Nachricht zu versenden."""
|
||||
ok, detail = check_connection()
|
||||
return MailCheckOut(
|
||||
ok=ok,
|
||||
detail=detail,
|
||||
host=settings.smtp_host,
|
||||
port=settings.smtp_port,
|
||||
security=settings.smtp_security,
|
||||
envelope_from=settings.envelope_from,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/mail/test", response_model=MessageOut,
|
||||
status_code=status.HTTP_202_ACCEPTED)
|
||||
def mail_test(payload: MailTestIn, background: BackgroundTasks, admin: AdminUser):
|
||||
background.add_task(send_test_mail, str(payload.to))
|
||||
return MessageOut(
|
||||
detail=f"Testnachricht an {payload.to} in Auftrag gegeben. "
|
||||
"Ergebnis steht im Log des api-Containers."
|
||||
)
|
||||
56
backend/app/routers/appinfo.py
Normal file
56
backend/app/routers/appinfo.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Angaben zur Anwendung, die die Oberflaeche zur Laufzeit braucht.
|
||||
|
||||
Die statischen Dateien im web-Container kennen den Anwendungsnamen nicht -
|
||||
er steht in der .env. Statt ihn beim Bauen einzusetzen (was ein
|
||||
Neubau-Erfordernis bei jeder Umbenennung bedeutete), liefert ihn die API.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter(tags=["app"])
|
||||
|
||||
|
||||
@router.get("/api/config")
|
||||
def public_config():
|
||||
"""Ohne Anmeldung erreichbar: Das Anmeldeformular braucht den Namen,
|
||||
bevor jemand angemeldet ist. Enthaelt bewusst nichts Vertrauliches."""
|
||||
return {
|
||||
"app_name": settings.app_name,
|
||||
"app_short_name": settings.short_name,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/manifest.webmanifest", include_in_schema=False)
|
||||
def manifest():
|
||||
"""Erzeugt statt statisch ausgeliefert, damit der Name aus der .env
|
||||
auch auf dem Startbildschirm erscheint."""
|
||||
return JSONResponse(
|
||||
{
|
||||
"name": settings.app_name,
|
||||
"short_name": settings.short_name,
|
||||
"description": "Gemeinsame Einkaufslisten mit Märkten, "
|
||||
"Warengruppen und Preisen.",
|
||||
"lang": "de",
|
||||
"dir": "ltr",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait-primary",
|
||||
"background_color": "#f6f6f4",
|
||||
"theme_color": "#2f6f4e",
|
||||
"categories": ["shopping", "productivity"],
|
||||
"icons": [
|
||||
{"src": "/icons/icon-192.png", "sizes": "192x192",
|
||||
"type": "image/png", "purpose": "any"},
|
||||
{"src": "/icons/icon-512.png", "sizes": "512x512",
|
||||
"type": "image/png", "purpose": "any"},
|
||||
{"src": "/icons/icon-maskable-512.png", "sizes": "512x512",
|
||||
"type": "image/png", "purpose": "maskable"},
|
||||
],
|
||||
},
|
||||
media_type="application/manifest+json",
|
||||
headers={"Cache-Control": "no-cache"},
|
||||
)
|
||||
396
backend/app/routers/auth.py
Normal file
396
backend/app/routers/auth.py
Normal file
@@ -0,0 +1,396 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import settings
|
||||
from app.deps import (
|
||||
CSRF_COOKIE,
|
||||
SESSION_COOKIE,
|
||||
CurrentUser,
|
||||
DbSession,
|
||||
client_ip,
|
||||
create_session,
|
||||
destroy_session,
|
||||
self_registration_enabled,
|
||||
)
|
||||
from app.mail import send_password_reset, send_verification
|
||||
from app.models import EmailChange, EmailToken, User, UserSession
|
||||
from app.schemas_admin import WelcomeCompleteIn, WelcomePreviewOut
|
||||
from app.schemas import (
|
||||
LoginIn,
|
||||
MessageOut,
|
||||
PasswordChangeIn,
|
||||
PasswordResetIn,
|
||||
PasswordResetRequestIn,
|
||||
ProfileUpdateIn,
|
||||
RegisterIn,
|
||||
UserOut,
|
||||
)
|
||||
from app.users import apply_if_complete
|
||||
from app.security import (
|
||||
check_rate_limit,
|
||||
hash_password,
|
||||
hash_token,
|
||||
needs_rehash,
|
||||
new_token,
|
||||
normalize_email,
|
||||
utcnow,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
# Bewusst identische Antwort fuer "Konto existiert" und "Konto existiert nicht".
|
||||
_NEUTRAL = "Falls die Adresse bei uns registriert ist, wurde eine E-Mail versendet."
|
||||
|
||||
|
||||
def _issue_token(db, user: User, purpose: str, hours: int) -> str:
|
||||
# Aeltere, noch offene Token desselben Zwecks entwerten.
|
||||
for old in db.scalars(
|
||||
select(EmailToken).where(
|
||||
EmailToken.user_id == user.id,
|
||||
EmailToken.purpose == purpose,
|
||||
EmailToken.used_at.is_(None),
|
||||
)
|
||||
).all():
|
||||
old.used_at = utcnow()
|
||||
|
||||
raw = new_token()
|
||||
db.add(
|
||||
EmailToken(
|
||||
token_hash=hash_token(raw),
|
||||
user_id=user.id,
|
||||
purpose=purpose,
|
||||
expires_at=utcnow() + timedelta(hours=hours),
|
||||
)
|
||||
)
|
||||
return raw
|
||||
|
||||
|
||||
def _consume_token(db, raw: str, purpose: str) -> User | None:
|
||||
row = db.scalar(
|
||||
select(EmailToken).where(
|
||||
EmailToken.token_hash == hash_token(raw),
|
||||
EmailToken.purpose == purpose,
|
||||
)
|
||||
)
|
||||
if row is None or row.used_at is not None or row.expires_at <= utcnow():
|
||||
return None
|
||||
row.used_at = utcnow()
|
||||
return db.get(User, row.user_id)
|
||||
|
||||
|
||||
@router.post("/register", response_model=MessageOut, status_code=status.HTTP_202_ACCEPTED)
|
||||
def register(
|
||||
payload: RegisterIn,
|
||||
request: Request,
|
||||
db: DbSession,
|
||||
background: BackgroundTasks,
|
||||
):
|
||||
if not self_registration_enabled(db):
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
"Die Selbstregistrierung ist deaktiviert. Bitte lass dich einladen.",
|
||||
)
|
||||
|
||||
if not check_rate_limit(
|
||||
db, f"register:{client_ip(request)}", limit=5, window_minutes=60
|
||||
):
|
||||
db.commit()
|
||||
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Versuche.")
|
||||
|
||||
email = normalize_email(payload.email)
|
||||
existing = db.scalar(select(User).where(User.email == email))
|
||||
|
||||
if existing is None:
|
||||
user = User(
|
||||
email=email,
|
||||
display_name=(payload.display_name or None),
|
||||
password_hash=hash_password(payload.password),
|
||||
is_admin=(email == normalize_email(settings.admin_email)),
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
raw = _issue_token(db, user, "verify", hours=24)
|
||||
background.add_task(send_verification, email, raw)
|
||||
elif existing.verified_at is None:
|
||||
# Unbestaetigtes Konto: neuen Link schicken, statt zu verraten,
|
||||
# dass die Adresse schon vergeben ist.
|
||||
raw = _issue_token(db, existing, "verify", hours=24)
|
||||
background.add_task(send_verification, email, raw)
|
||||
# Bestaetigtes Konto: nichts tun, aber gleiche Antwort geben.
|
||||
|
||||
db.commit()
|
||||
return MessageOut(detail=_NEUTRAL)
|
||||
|
||||
|
||||
@router.get("/verify", include_in_schema=True)
|
||||
def verify(token: str, db: DbSession):
|
||||
"""Ziel des Links aus der Bestätigungsmail.
|
||||
|
||||
Antwortet mit einer Weiterleitung auf die Oberfläche, nicht mit JSON:
|
||||
Der Nutzer klickt hier im Browser, nicht per API-Aufruf.
|
||||
"""
|
||||
user = _consume_token(db, token, "verify")
|
||||
if user is None:
|
||||
db.commit()
|
||||
return RedirectResponse("/?verified=invalid", status_code=303)
|
||||
|
||||
if user.verified_at is None:
|
||||
user.verified_at = utcnow()
|
||||
db.commit()
|
||||
return RedirectResponse("/?verified=ok", status_code=303)
|
||||
|
||||
|
||||
@router.post("/login", response_model=UserOut)
|
||||
def login(payload: LoginIn, request: Request, response: Response, db: DbSession):
|
||||
email = normalize_email(payload.email)
|
||||
|
||||
ip_ok = check_rate_limit(db, f"login-ip:{client_ip(request)}", limit=20, window_minutes=15)
|
||||
acct_ok = check_rate_limit(db, f"login-acct:{email}", limit=8, window_minutes=15)
|
||||
if not (ip_ok and acct_ok):
|
||||
db.commit()
|
||||
raise HTTPException(
|
||||
status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
"Zu viele Anmeldeversuche. Bitte warte einen Moment.",
|
||||
)
|
||||
|
||||
user = db.scalar(select(User).where(User.email == email))
|
||||
# Laeuft auch ohne Treffer gegen einen Dummy-Hash - gleiche Laufzeit.
|
||||
ok = verify_password(payload.password, user.password_hash if user else None)
|
||||
|
||||
if not ok or user is None:
|
||||
db.commit()
|
||||
raise HTTPException(
|
||||
status.HTTP_401_UNAUTHORIZED, "E-Mail-Adresse oder Passwort ist falsch."
|
||||
)
|
||||
|
||||
# Erst NACH erfolgreicher Passwortprüfung über die Deaktivierung
|
||||
# informieren. Vorher wäre es ein Hinweis darauf, dass es das Konto
|
||||
# gibt - danach weiß die Person das ohnehin, weil sie das Passwort
|
||||
# kennt. Und wer nur "falsches Passwort" liest, obwohl es stimmt,
|
||||
# sucht den Fehler an der falschen Stelle.
|
||||
if not user.is_active:
|
||||
db.commit()
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
"Dieses Konto ist deaktiviert. Deine Listen bleiben erhalten – "
|
||||
"wende dich an die Administration, wenn du den Zugang wieder "
|
||||
"brauchst.",
|
||||
)
|
||||
|
||||
if user.verified_at is None:
|
||||
db.commit()
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
"Bitte bestätige zuerst deine E-Mail-Adresse.",
|
||||
)
|
||||
|
||||
if needs_rehash(user.password_hash):
|
||||
user.password_hash = hash_password(payload.password)
|
||||
|
||||
# Grundlage der automatischen Deaktivierung nach langer Untätigkeit.
|
||||
user.last_seen_at = utcnow()
|
||||
|
||||
create_session(db, user, response)
|
||||
db.commit()
|
||||
return UserOut.of(user)
|
||||
|
||||
|
||||
@router.post("/logout", response_model=MessageOut)
|
||||
def logout(request: Request, response: Response, db: DbSession, user: CurrentUser):
|
||||
destroy_session(db, request, response)
|
||||
db.commit()
|
||||
return MessageOut(detail="Abgemeldet.")
|
||||
|
||||
|
||||
@router.post("/logout-all", response_model=MessageOut)
|
||||
def logout_all(request: Request, response: Response, db: DbSession, user: CurrentUser):
|
||||
"""Meldet alle Geräte ab - z.B. nach Verdacht auf Kompromittierung."""
|
||||
for sess in db.scalars(
|
||||
select(UserSession).where(UserSession.user_id == user.id)
|
||||
).all():
|
||||
db.delete(sess)
|
||||
response.delete_cookie(SESSION_COOKIE, path="/")
|
||||
response.delete_cookie(CSRF_COOKIE, path="/")
|
||||
db.commit()
|
||||
return MessageOut(detail="Auf allen Geräten abgemeldet.")
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
def me(user: CurrentUser):
|
||||
return UserOut.of(user)
|
||||
|
||||
|
||||
@router.patch("/me", response_model=UserOut)
|
||||
def update_me(payload: ProfileUpdateIn, db: DbSession, user: CurrentUser):
|
||||
user.display_name = payload.display_name or None
|
||||
db.commit()
|
||||
return UserOut.of(user)
|
||||
|
||||
|
||||
@router.post("/password/change", response_model=MessageOut)
|
||||
def change_password(
|
||||
payload: PasswordChangeIn, request: Request, response: Response,
|
||||
db: DbSession, user: CurrentUser,
|
||||
):
|
||||
if not verify_password(payload.current_password, user.password_hash):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Aktuelles Passwort ist falsch.")
|
||||
|
||||
if verify_password(payload.new_password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"Das neue Passwort muss sich vom bisherigen unterscheiden.",
|
||||
)
|
||||
|
||||
user.password_hash = hash_password(payload.new_password)
|
||||
user.must_change_password = False
|
||||
# Alle bestehenden Sessions verwerfen und eine neue ausstellen.
|
||||
for sess in db.scalars(
|
||||
select(UserSession).where(UserSession.user_id == user.id)
|
||||
).all():
|
||||
db.delete(sess)
|
||||
db.flush()
|
||||
create_session(db, user, response)
|
||||
db.commit()
|
||||
return MessageOut(detail="Passwort geändert. Andere Geräte wurden abgemeldet.")
|
||||
|
||||
|
||||
@router.post("/password/reset-request", response_model=MessageOut)
|
||||
def reset_request(
|
||||
payload: PasswordResetRequestIn,
|
||||
request: Request,
|
||||
db: DbSession,
|
||||
background: BackgroundTasks,
|
||||
):
|
||||
if not check_rate_limit(
|
||||
db, f"reset:{client_ip(request)}", limit=5, window_minutes=60
|
||||
):
|
||||
db.commit()
|
||||
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Versuche.")
|
||||
|
||||
email = normalize_email(payload.email)
|
||||
user = db.scalar(select(User).where(User.email == email))
|
||||
if user is not None and user.is_active and user.verified_at is not None:
|
||||
raw = _issue_token(db, user, "reset", hours=1)
|
||||
background.add_task(send_password_reset, email, raw)
|
||||
|
||||
db.commit()
|
||||
return MessageOut(detail=_NEUTRAL)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Willkommensnachricht: Passwort setzen und Konto freischalten
|
||||
# ==========================================================================
|
||||
|
||||
@router.get("/welcome/{token}", response_model=WelcomePreviewOut)
|
||||
def welcome_preview(token: str, db: DbSession):
|
||||
"""Zeigt, für welche Adresse der Link gilt - damit die Person sieht,
|
||||
worauf sie sich einlässt, bevor sie ein Passwort vergibt."""
|
||||
row = db.scalar(
|
||||
select(EmailToken).where(
|
||||
EmailToken.token_hash == hash_token(token),
|
||||
EmailToken.purpose == "welcome",
|
||||
)
|
||||
)
|
||||
if row is None or row.used_at is not None or row.expires_at <= utcnow():
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"Der Link ist ungültig oder abgelaufen. Bitte die Administration "
|
||||
"um eine neue Einladung.",
|
||||
)
|
||||
user = db.get(User, row.user_id)
|
||||
if user is None or not user.is_active:
|
||||
raise HTTPException(status.HTTP_410_GONE, "Das Konto steht nicht mehr bereit.")
|
||||
|
||||
return WelcomePreviewOut(email=user.email, display_name=user.display_name)
|
||||
|
||||
|
||||
@router.post("/welcome/complete", response_model=MessageOut)
|
||||
def welcome_complete(payload: WelcomeCompleteIn, db: DbSession):
|
||||
"""Setzt das erste Passwort und bestätigt die Adresse in einem Schritt.
|
||||
|
||||
Der Klick auf den Link aus der Willkommensnachricht beweist, dass das
|
||||
Postfach erreichbar ist - eine zusätzliche Verifikationsmail wäre nur
|
||||
ein weiterer Schritt ohne Erkenntnisgewinn.
|
||||
"""
|
||||
user = _consume_token(db, payload.token, "welcome")
|
||||
if user is None:
|
||||
db.commit()
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, "Der Link ist ungültig oder abgelaufen."
|
||||
)
|
||||
if not user.is_active:
|
||||
db.commit()
|
||||
raise HTTPException(status.HTTP_410_GONE, "Das Konto steht nicht mehr bereit.")
|
||||
|
||||
user.password_hash = hash_password(payload.password)
|
||||
user.must_change_password = False
|
||||
user.verified_at = user.verified_at or utcnow()
|
||||
user.last_seen_at = utcnow()
|
||||
if payload.display_name is not None:
|
||||
user.display_name = payload.display_name.strip() or None
|
||||
|
||||
db.commit()
|
||||
return MessageOut(detail="Zugang eingerichtet. Du kannst dich jetzt anmelden.")
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Bestätigung einer Adressänderung
|
||||
# ==========================================================================
|
||||
|
||||
@router.get("/email-change/{token}", include_in_schema=False)
|
||||
def confirm_email_change(token: str, db: DbSession):
|
||||
"""Ziel der Links aus den Bestätigungsmails.
|
||||
|
||||
Antwortet mit einer Weiterleitung auf die Oberfläche - hier klickt
|
||||
jemand im Browser, nicht per API-Aufruf.
|
||||
"""
|
||||
digest = hash_token(token)
|
||||
change = db.scalar(
|
||||
select(EmailChange).where(
|
||||
(EmailChange.token_new_hash == digest)
|
||||
| (EmailChange.token_old_hash == digest)
|
||||
)
|
||||
)
|
||||
|
||||
if change is None:
|
||||
return RedirectResponse("/?adresswechsel=unbekannt", status_code=303)
|
||||
if change.cancelled_at or change.applied_at:
|
||||
return RedirectResponse("/?adresswechsel=erledigt", status_code=303)
|
||||
if change.expires_at <= utcnow():
|
||||
return RedirectResponse("/?adresswechsel=abgelaufen", status_code=303)
|
||||
|
||||
if change.token_new_hash == digest:
|
||||
change.confirmed_new_at = change.confirmed_new_at or utcnow()
|
||||
else:
|
||||
change.confirmed_old_at = change.confirmed_old_at or utcnow()
|
||||
|
||||
done = apply_if_complete(db, change)
|
||||
db.commit()
|
||||
|
||||
if done:
|
||||
return RedirectResponse("/?adresswechsel=fertig", status_code=303)
|
||||
# Bei Administratorkonten fehlt jetzt noch die zweite Bestätigung.
|
||||
return RedirectResponse("/?adresswechsel=teilweise", status_code=303)
|
||||
|
||||
|
||||
@router.post("/password/reset", response_model=MessageOut)
|
||||
def reset_password(payload: PasswordResetIn, db: DbSession):
|
||||
user = _consume_token(db, payload.token, "reset")
|
||||
if user is None:
|
||||
db.commit()
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, "Der Link ist ungültig oder abgelaufen."
|
||||
)
|
||||
|
||||
user.password_hash = hash_password(payload.password)
|
||||
user.must_change_password = False
|
||||
for sess in db.scalars(
|
||||
select(UserSession).where(UserSession.user_id == user.id)
|
||||
).all():
|
||||
db.delete(sess)
|
||||
db.commit()
|
||||
return MessageOut(detail="Passwort gesetzt. Du kannst dich jetzt anmelden.")
|
||||
413
backend/app/routers/catalog.py
Normal file
413
backend/app/routers/catalog.py
Normal file
@@ -0,0 +1,413 @@
|
||||
"""Katalogdaten einer Liste: Märkte, Warengruppen, Artikel.
|
||||
|
||||
Alle Schreibzugriffe erhöhen den Revisionszähler der Liste und schreiben
|
||||
den neuen Wert in `row_rev` der geänderten Zeile.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.deps import DbSession
|
||||
from app.models import (
|
||||
Article,
|
||||
ArticleAttribute,
|
||||
ArticleMarket,
|
||||
Category,
|
||||
ListItem,
|
||||
Market,
|
||||
)
|
||||
from app.permissions import EditableList, ReadableList, bump_rev
|
||||
from app.schemas import MessageOut
|
||||
from app.product_lookup import lookup
|
||||
from app.schemas_shopping import (
|
||||
ArticleIn,
|
||||
ArticleOut,
|
||||
ArticleUpdateIn,
|
||||
AttributeOut,
|
||||
CategoryIn,
|
||||
CategoryOut,
|
||||
MarketIn,
|
||||
MarketOut,
|
||||
ProductLookupOut,
|
||||
)
|
||||
from app.security import utcnow
|
||||
|
||||
router = APIRouter(prefix="/api/lists/{list_id}", tags=["catalog"])
|
||||
|
||||
|
||||
def _check_belongs(db: Session, model, obj_id: str | None, list_id: str, label: str):
|
||||
"""Verhindert, dass eine Liste auf Objekte einer fremden Liste zeigt.
|
||||
Ohne diese Prüfung könnte ein Mitglied durch Angabe einer fremden ID
|
||||
Rückschlüsse auf andere Listen ziehen."""
|
||||
if obj_id is None:
|
||||
return None
|
||||
obj = db.get(model, obj_id)
|
||||
if obj is None or obj.list_id != list_id or obj.deleted_at is not None:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"{label} nicht gefunden")
|
||||
return obj
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Märkte
|
||||
# ==========================================================================
|
||||
|
||||
@router.get("/markets", response_model=list[MarketOut])
|
||||
def get_markets(lst: ReadableList, db: DbSession):
|
||||
rows = db.scalars(
|
||||
select(Market)
|
||||
.where(Market.list_id == lst.id, Market.deleted_at.is_(None))
|
||||
.order_by(Market.sort_order, Market.name)
|
||||
).all()
|
||||
return [MarketOut.model_validate(m) for m in rows]
|
||||
|
||||
|
||||
@router.post("/markets", response_model=MarketOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_market(payload: MarketIn, lst: EditableList, db: DbSession):
|
||||
rev = bump_rev(db, lst.id)
|
||||
market = Market(
|
||||
list_id=lst.id, name=payload.name.strip(),
|
||||
sort_order=payload.sort_order, row_rev=rev,
|
||||
)
|
||||
db.add(market)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, "Ein Markt mit diesem Namen existiert bereits."
|
||||
) from None
|
||||
return MarketOut.model_validate(market)
|
||||
|
||||
|
||||
@router.put("/markets/{market_id}", response_model=MarketOut)
|
||||
def update_market(market_id: str, payload: MarketIn, lst: EditableList, db: DbSession):
|
||||
market = _check_belongs(db, Market, market_id, lst.id, "Markt")
|
||||
market.name = payload.name.strip()
|
||||
market.sort_order = payload.sort_order
|
||||
market.row_rev = bump_rev(db, lst.id)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, "Ein Markt mit diesem Namen existiert bereits."
|
||||
) from None
|
||||
return MarketOut.model_validate(market)
|
||||
|
||||
|
||||
@router.delete("/markets/{market_id}", response_model=MessageOut)
|
||||
def delete_market(market_id: str, lst: EditableList, db: DbSession):
|
||||
market = _check_belongs(db, Market, market_id, lst.id, "Markt")
|
||||
rev = bump_rev(db, lst.id)
|
||||
market.deleted_at = utcnow()
|
||||
market.row_rev = rev
|
||||
|
||||
# Einträge nicht mitlöschen, nur den Marktbezug lösen - sie landen
|
||||
# dann in der Gruppe "Ohne Markt" und gehen nicht verloren.
|
||||
for item in db.scalars(
|
||||
select(ListItem).where(
|
||||
ListItem.list_id == lst.id,
|
||||
ListItem.market_id == market_id,
|
||||
ListItem.deleted_at.is_(None),
|
||||
)
|
||||
).all():
|
||||
item.market_id = None
|
||||
item.row_rev = rev
|
||||
|
||||
db.commit()
|
||||
return MessageOut(detail="Markt gelöscht. Betroffene Einträge sind ohne Markt.")
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Warengruppen
|
||||
# ==========================================================================
|
||||
|
||||
@router.get("/categories", response_model=list[CategoryOut])
|
||||
def get_categories(lst: ReadableList, db: DbSession):
|
||||
rows = db.scalars(
|
||||
select(Category)
|
||||
.where(Category.list_id == lst.id, Category.deleted_at.is_(None))
|
||||
.order_by(Category.sort_order, Category.name)
|
||||
).all()
|
||||
return [CategoryOut.model_validate(c) for c in rows]
|
||||
|
||||
|
||||
@router.post("/categories", response_model=CategoryOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_category(payload: CategoryIn, lst: EditableList, db: DbSession):
|
||||
rev = bump_rev(db, lst.id)
|
||||
cat = Category(
|
||||
list_id=lst.id, name=payload.name.strip(),
|
||||
sort_order=payload.sort_order, row_rev=rev,
|
||||
)
|
||||
db.add(cat)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, "Diese Warengruppe existiert bereits."
|
||||
) from None
|
||||
return CategoryOut.model_validate(cat)
|
||||
|
||||
|
||||
@router.put("/categories/{category_id}", response_model=CategoryOut)
|
||||
def update_category(
|
||||
category_id: str, payload: CategoryIn, lst: EditableList, db: DbSession
|
||||
):
|
||||
cat = _check_belongs(db, Category, category_id, lst.id, "Warengruppe")
|
||||
cat.name = payload.name.strip()
|
||||
cat.sort_order = payload.sort_order
|
||||
cat.row_rev = bump_rev(db, lst.id)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, "Diese Warengruppe existiert bereits."
|
||||
) from None
|
||||
return CategoryOut.model_validate(cat)
|
||||
|
||||
|
||||
@router.delete("/categories/{category_id}", response_model=MessageOut)
|
||||
def delete_category(category_id: str, lst: EditableList, db: DbSession):
|
||||
cat = _check_belongs(db, Category, category_id, lst.id, "Warengruppe")
|
||||
rev = bump_rev(db, lst.id)
|
||||
cat.deleted_at = utcnow()
|
||||
cat.row_rev = rev
|
||||
|
||||
for item in db.scalars(
|
||||
select(ListItem).where(
|
||||
ListItem.list_id == lst.id,
|
||||
ListItem.category_id == category_id,
|
||||
ListItem.deleted_at.is_(None),
|
||||
)
|
||||
).all():
|
||||
item.category_id = None
|
||||
item.row_rev = rev
|
||||
|
||||
db.commit()
|
||||
return MessageOut(detail="Warengruppe gelöscht.")
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Artikel
|
||||
# ==========================================================================
|
||||
|
||||
def _article_out(a: Article) -> ArticleOut:
|
||||
return ArticleOut(
|
||||
id=a.id, name=a.name, barcode=a.barcode, note=a.note,
|
||||
default_market_id=a.default_market_id,
|
||||
default_category_id=a.default_category_id,
|
||||
attributes=[
|
||||
AttributeOut(name=x.attr_name, value=x.attr_value) for x in a.attributes
|
||||
],
|
||||
available_market_ids=[x.market_id for x in a.availability],
|
||||
row_rev=a.row_rev,
|
||||
)
|
||||
|
||||
|
||||
def _apply_attributes(db: Session, article: Article, attributes) -> None:
|
||||
"""Ersetzt die Attributmenge vollständig."""
|
||||
for old in list(article.attributes):
|
||||
db.delete(old)
|
||||
article.attributes = []
|
||||
db.flush()
|
||||
seen = set()
|
||||
for attr in attributes:
|
||||
name = attr.name.strip()
|
||||
if not name or name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
db.add(
|
||||
ArticleAttribute(
|
||||
article_id=article.id, attr_name=name, attr_value=attr.value.strip()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _apply_availability(db: Session, article: Article, market_ids, list_id: str) -> None:
|
||||
for old in db.scalars(
|
||||
select(ArticleMarket).where(ArticleMarket.article_id == article.id)
|
||||
).all():
|
||||
db.delete(old)
|
||||
db.flush()
|
||||
for mid in dict.fromkeys(market_ids):
|
||||
_check_belongs(db, Market, mid, list_id, "Markt")
|
||||
db.add(ArticleMarket(article_id=article.id, market_id=mid))
|
||||
|
||||
|
||||
@router.get("/articles", response_model=list[ArticleOut])
|
||||
def get_articles(
|
||||
lst: ReadableList,
|
||||
db: DbSession,
|
||||
q: str | None = None,
|
||||
with_barcode: bool | None = None,
|
||||
):
|
||||
stmt = select(Article).where(
|
||||
Article.list_id == lst.id, Article.deleted_at.is_(None)
|
||||
)
|
||||
if q:
|
||||
# Nur der LIKE-Operator, kein zusammengebautes SQL - der Suchtext
|
||||
# geht als gebundener Parameter in die Abfrage. Sonderzeichen von
|
||||
# LIKE werden maskiert, damit "50%" nicht alles findet.
|
||||
needle = q.strip().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
stmt = stmt.where(Article.name.like(f"%{needle}%", escape="\\"))
|
||||
if with_barcode is True:
|
||||
stmt = stmt.where(Article.barcode.is_not(None))
|
||||
elif with_barcode is False:
|
||||
stmt = stmt.where(Article.barcode.is_(None))
|
||||
|
||||
rows = db.scalars(stmt.order_by(Article.name).limit(500)).all()
|
||||
return [_article_out(a) for a in rows]
|
||||
|
||||
|
||||
@router.get("/barcode/{barcode}", response_model=ProductLookupOut)
|
||||
def resolve_barcode(barcode: str, lst: ReadableList, db: DbSession):
|
||||
"""Strichcode auflösen - erst im eigenen Bestand, dann außerhalb.
|
||||
|
||||
Die Reihenfolge ist wichtig: Was hier schon gepflegt wurde, ist
|
||||
verlässlicher als eine Fremdquelle, und ein eigener Name soll nicht
|
||||
von einer Datenbank überschrieben werden.
|
||||
"""
|
||||
article = db.scalar(
|
||||
select(Article).where(
|
||||
Article.list_id == lst.id,
|
||||
Article.barcode == barcode,
|
||||
Article.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
if article is not None:
|
||||
return ProductLookupOut(
|
||||
barcode=barcode, found=True, source="catalog",
|
||||
article_id=article.id, name=article.name,
|
||||
)
|
||||
|
||||
hit = lookup(db, barcode)
|
||||
if hit is None or not hit.found:
|
||||
return ProductLookupOut(barcode=barcode, found=False, source="none")
|
||||
|
||||
return ProductLookupOut(
|
||||
barcode=barcode,
|
||||
found=True,
|
||||
source="openfoodfacts",
|
||||
name=hit.name,
|
||||
brand=hit.brand,
|
||||
package=hit.package,
|
||||
count=hit.count,
|
||||
pack_size=hit.pack_size,
|
||||
pack_unit=hit.pack_unit,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/articles/by-barcode/{barcode}", response_model=ArticleOut)
|
||||
def article_by_barcode(barcode: str, lst: ReadableList, db: DbSession):
|
||||
article = db.scalar(
|
||||
select(Article).where(
|
||||
Article.list_id == lst.id,
|
||||
Article.barcode == barcode,
|
||||
Article.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
if article is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND, "Kein Artikel mit diesem Barcode"
|
||||
)
|
||||
return _article_out(article)
|
||||
|
||||
|
||||
@router.post("/articles", response_model=ArticleOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_article(payload: ArticleIn, lst: EditableList, db: DbSession):
|
||||
_check_belongs(db, Market, payload.default_market_id, lst.id, "Markt")
|
||||
_check_belongs(db, Category, payload.default_category_id, lst.id, "Warengruppe")
|
||||
|
||||
rev = bump_rev(db, lst.id)
|
||||
article = Article(
|
||||
list_id=lst.id, name=payload.name.strip(),
|
||||
barcode=(payload.barcode or None), note=(payload.note or None),
|
||||
default_market_id=payload.default_market_id,
|
||||
default_category_id=payload.default_category_id,
|
||||
row_rev=rev,
|
||||
)
|
||||
db.add(article)
|
||||
db.flush()
|
||||
_apply_attributes(db, article, payload.attributes)
|
||||
_apply_availability(db, article, payload.available_market_ids, lst.id)
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, "Ein Artikel mit diesem Namen existiert bereits."
|
||||
) from None
|
||||
|
||||
db.refresh(article)
|
||||
return _article_out(article)
|
||||
|
||||
|
||||
@router.patch("/articles/{article_id}", response_model=ArticleOut)
|
||||
def update_article(
|
||||
article_id: str, payload: ArticleUpdateIn, lst: EditableList, db: DbSession
|
||||
):
|
||||
article = _check_belongs(db, Article, article_id, lst.id, "Artikel")
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
|
||||
if "default_market_id" in data:
|
||||
_check_belongs(db, Market, data["default_market_id"], lst.id, "Markt")
|
||||
article.default_market_id = data["default_market_id"]
|
||||
if "default_category_id" in data:
|
||||
_check_belongs(db, Category, data["default_category_id"], lst.id, "Warengruppe")
|
||||
article.default_category_id = data["default_category_id"]
|
||||
if "name" in data and data["name"]:
|
||||
article.name = data["name"].strip()
|
||||
if "barcode" in data:
|
||||
article.barcode = data["barcode"] or None
|
||||
if "note" in data:
|
||||
article.note = data["note"] or None
|
||||
|
||||
if payload.attributes is not None:
|
||||
_apply_attributes(db, article, payload.attributes)
|
||||
if payload.available_market_ids is not None:
|
||||
_apply_availability(db, article, payload.available_market_ids, lst.id)
|
||||
|
||||
article.row_rev = bump_rev(db, lst.id)
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, "Ein Artikel mit diesem Namen existiert bereits."
|
||||
) from None
|
||||
|
||||
db.refresh(article)
|
||||
return _article_out(article)
|
||||
|
||||
|
||||
@router.delete("/articles/{article_id}", response_model=MessageOut)
|
||||
def delete_article(article_id: str, lst: EditableList, db: DbSession):
|
||||
article = _check_belongs(db, Article, article_id, lst.id, "Artikel")
|
||||
rev = bump_rev(db, lst.id)
|
||||
article.deleted_at = utcnow()
|
||||
article.row_rev = rev
|
||||
|
||||
# Offene Einträge dieses Artikels verschwinden mit. Alles andere
|
||||
# hinterließe Einträge, die auf einen gelöschten Artikel zeigen.
|
||||
removed = 0
|
||||
for item in db.scalars(
|
||||
select(ListItem).where(
|
||||
ListItem.list_id == lst.id,
|
||||
ListItem.article_id == article_id,
|
||||
ListItem.deleted_at.is_(None),
|
||||
)
|
||||
).all():
|
||||
item.deleted_at = utcnow()
|
||||
item.row_rev = rev
|
||||
removed += 1
|
||||
|
||||
db.commit()
|
||||
return MessageOut(
|
||||
detail=f"Artikel gelöscht, dazu {removed} Eintrag/Einträge auf der Liste."
|
||||
)
|
||||
245
backend/app/routers/items.py
Normal file
245
backend/app/routers/items.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""Listeneinträge: anlegen, ändern, verschieben, zurückstellen, löschen.
|
||||
|
||||
Dazu die gruppierte Ansicht Markt -> Warengruppe -> Artikel, die sowohl
|
||||
die App als auch der Druck in Phase 9 verwendet. Die Sortierlogik liegt
|
||||
bewusst nur hier, damit Bildschirm und Papier nicht auseinanderlaufen.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, status
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.deps import DbSession, VerifiedUser
|
||||
from app.models import Article, Category, ListItem, Market
|
||||
from app.list_view import build_view, item_out
|
||||
from app.print_view import render_print
|
||||
from app.permissions import EditableList, ReadableList, bump_rev
|
||||
from app.prices import record_price
|
||||
from app.push import notify_list_changed
|
||||
from app.routers.catalog import _check_belongs
|
||||
from app.schemas import MessageOut
|
||||
from app.schemas_shopping import (
|
||||
ClearBoughtOut,
|
||||
ItemCreateIn,
|
||||
ItemOut,
|
||||
ItemStatus,
|
||||
ItemUpdateIn,
|
||||
ListView,
|
||||
)
|
||||
from app.security import utcnow
|
||||
|
||||
router = APIRouter(prefix="/api/lists/{list_id}", tags=["items"])
|
||||
|
||||
|
||||
@router.get("/items", response_model=list[ItemOut])
|
||||
def get_items(
|
||||
lst: ReadableList,
|
||||
db: DbSession,
|
||||
status_filter: ItemStatus | None = Query(default=None, alias="status"),
|
||||
):
|
||||
stmt = select(ListItem).where(
|
||||
ListItem.list_id == lst.id, ListItem.deleted_at.is_(None)
|
||||
)
|
||||
if status_filter:
|
||||
stmt = stmt.where(ListItem.status == status_filter)
|
||||
rows = db.scalars(stmt.order_by(ListItem.created_at)).all()
|
||||
return [item_out(i) for i in rows]
|
||||
|
||||
|
||||
@router.post("/items", response_model=ItemOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_item(
|
||||
payload: ItemCreateIn,
|
||||
lst: EditableList,
|
||||
db: DbSession,
|
||||
user: VerifiedUser,
|
||||
background: BackgroundTasks,
|
||||
):
|
||||
if not payload.article_id and not payload.article_name:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"Entweder article_id oder article_name angeben.",
|
||||
)
|
||||
|
||||
rev = bump_rev(db, lst.id)
|
||||
|
||||
if payload.article_id:
|
||||
article = _check_belongs(db, Article, payload.article_id, lst.id, "Artikel")
|
||||
else:
|
||||
name = payload.article_name.strip()
|
||||
# Vorhandenen Artikel wiederverwenden. Der Vergleich ist dank
|
||||
# utf8mb4_unicode_ci von Haus aus unabhaengig von Gross- und
|
||||
# Kleinschreibung.
|
||||
article = db.scalar(
|
||||
select(Article).where(
|
||||
Article.list_id == lst.id,
|
||||
Article.name == name,
|
||||
Article.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
if article is None:
|
||||
article = Article(list_id=lst.id, name=name, row_rev=rev)
|
||||
db.add(article)
|
||||
db.flush()
|
||||
|
||||
_check_belongs(db, Market, payload.market_id, lst.id, "Markt")
|
||||
_check_belongs(db, Category, payload.category_id, lst.id, "Warengruppe")
|
||||
|
||||
item = ListItem(
|
||||
list_id=lst.id,
|
||||
article_id=article.id,
|
||||
# Ohne Angabe die Vorgaben aus dem Artikel übernehmen.
|
||||
market_id=payload.market_id or article.default_market_id,
|
||||
category_id=payload.category_id or article.default_category_id,
|
||||
count=payload.count,
|
||||
pack_size=payload.pack_size,
|
||||
pack_unit=(payload.pack_unit or None),
|
||||
variant=(payload.variant or None),
|
||||
note=(payload.note or None),
|
||||
status="open",
|
||||
created_by=user.id,
|
||||
row_rev=rev,
|
||||
)
|
||||
db.add(item)
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, "Eintrag konnte nicht angelegt werden."
|
||||
) from None
|
||||
|
||||
db.refresh(item)
|
||||
background.add_task(notify_list_changed, lst.id, user.id)
|
||||
return item_out(item)
|
||||
|
||||
|
||||
@router.patch("/items/{item_id}", response_model=ItemOut)
|
||||
def update_item(
|
||||
item_id: str,
|
||||
payload: ItemUpdateIn,
|
||||
lst: EditableList,
|
||||
db: DbSession,
|
||||
user: VerifiedUser,
|
||||
background: BackgroundTasks,
|
||||
):
|
||||
"""Deckt alles ab, was während des Einkaufs passiert: abhaken,
|
||||
zurückstellen, Menge ändern, Preis erfassen, in einen anderen Markt
|
||||
verschieben."""
|
||||
item = db.get(ListItem, item_id)
|
||||
if item is None or item.list_id != lst.id or item.deleted_at is not None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Eintrag nicht gefunden")
|
||||
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
|
||||
if payload.clear_market:
|
||||
item.market_id = None
|
||||
elif "market_id" in data:
|
||||
_check_belongs(db, Market, data["market_id"], lst.id, "Markt")
|
||||
item.market_id = data["market_id"]
|
||||
|
||||
if payload.clear_category:
|
||||
item.category_id = None
|
||||
elif "category_id" in data:
|
||||
_check_belongs(db, Category, data["category_id"], lst.id, "Warengruppe")
|
||||
item.category_id = data["category_id"]
|
||||
|
||||
for field in ("count", "pack_size", "pack_unit", "variant", "note",
|
||||
"status", "price_cents"):
|
||||
if field in data:
|
||||
setattr(item, field, data[field])
|
||||
|
||||
item.row_rev = bump_rev(db, lst.id)
|
||||
# Nach dem Setzen der Felder: Der Preis wird nur festgehalten, wenn
|
||||
# auch ein Markt zugeordnet ist - ohne den ergibt er keinen
|
||||
# Vergleichswert.
|
||||
record_price(db, item)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
background.add_task(notify_list_changed, lst.id, user.id)
|
||||
return item_out(item)
|
||||
|
||||
|
||||
@router.delete("/items/{item_id}", response_model=MessageOut)
|
||||
def delete_item(
|
||||
item_id: str, lst: EditableList, db: DbSession, user: VerifiedUser,
|
||||
background: BackgroundTasks,
|
||||
):
|
||||
item = db.get(ListItem, item_id)
|
||||
if item is None or item.list_id != lst.id or item.deleted_at is not None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Eintrag nicht gefunden")
|
||||
|
||||
item.deleted_at = utcnow()
|
||||
item.row_rev = bump_rev(db, lst.id)
|
||||
db.commit()
|
||||
background.add_task(notify_list_changed, lst.id, user.id)
|
||||
return MessageOut(detail="Eintrag gelöscht.")
|
||||
|
||||
|
||||
@router.post("/items/clear-bought", response_model=ClearBoughtOut)
|
||||
def clear_bought(
|
||||
lst: EditableList, db: DbSession, user: VerifiedUser,
|
||||
background: BackgroundTasks,
|
||||
):
|
||||
"""Entfernt alle als gekauft markierten Einträge in einem Zug.
|
||||
|
||||
Ein einziger Revisionsschritt für den gesamten Vorgang - so sieht ein
|
||||
synchronisierender Client eine geschlossene Änderung statt dutzender
|
||||
einzelner.
|
||||
"""
|
||||
rev = bump_rev(db, lst.id)
|
||||
now = utcnow()
|
||||
rows = db.scalars(
|
||||
select(ListItem).where(
|
||||
ListItem.list_id == lst.id,
|
||||
ListItem.status == "bought",
|
||||
ListItem.deleted_at.is_(None),
|
||||
)
|
||||
).all()
|
||||
for item in rows:
|
||||
item.deleted_at = now
|
||||
item.row_rev = rev
|
||||
db.commit()
|
||||
if rows:
|
||||
background.add_task(notify_list_changed, lst.id, user.id)
|
||||
return ClearBoughtOut(removed=len(rows), rev=rev)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Gruppierte Ansicht
|
||||
# ==========================================================================
|
||||
|
||||
@router.get("/print", response_class=HTMLResponse, include_in_schema=True)
|
||||
def print_view(
|
||||
lst: ReadableList,
|
||||
db: DbSession,
|
||||
include_bought: bool = Query(default=True),
|
||||
include_deferred: bool = Query(default=False),
|
||||
):
|
||||
"""Druckfertige Seite auf A4.
|
||||
|
||||
Standardmäßig ohne zurückgestellte Artikel: Wer ausdruckt, will die
|
||||
Einkaufsliste, nicht die Merkliste.
|
||||
"""
|
||||
view = build_view(
|
||||
db, lst, include_bought=include_bought, include_deferred=include_deferred
|
||||
)
|
||||
return HTMLResponse(render_print(view, include_bought=include_bought))
|
||||
|
||||
|
||||
@router.get("/view", response_model=ListView)
|
||||
def grouped_view(
|
||||
lst: ReadableList,
|
||||
db: DbSession,
|
||||
include_bought: bool = Query(default=True),
|
||||
include_deferred: bool = Query(default=True),
|
||||
):
|
||||
"""Markt -> Warengruppe -> Artikel, alphabetisch innerhalb der Gruppe.
|
||||
|
||||
Die Aufbereitung liegt in app/list_view.py, weil der oeffentliche Link
|
||||
und der Ausdruck dieselbe Gliederung brauchen.
|
||||
"""
|
||||
return build_view(
|
||||
db, lst, include_bought=include_bought, include_deferred=include_deferred
|
||||
)
|
||||
205
backend/app/routers/lists.py
Normal file
205
backend/app/routers/lists.py
Normal file
@@ -0,0 +1,205 @@
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.deps import DbSession, VerifiedUser
|
||||
from app.models import Category, ListMember, Market, ShoppingList, User
|
||||
from app.permissions import OwnedList, ReadableList
|
||||
from app.schemas import MessageOut
|
||||
from app.list_view import build_view
|
||||
from app.schemas_shopping import (
|
||||
CategoryOut,
|
||||
ListCreateIn,
|
||||
ListOut,
|
||||
ListSnapshot,
|
||||
ListUpdateIn,
|
||||
MarketOut,
|
||||
MemberOut,
|
||||
MemberRoleIn,
|
||||
)
|
||||
from app.security import utcnow
|
||||
|
||||
router = APIRouter(prefix="/api/lists", tags=["lists"])
|
||||
|
||||
MAX_LISTS_PER_USER = 100
|
||||
|
||||
|
||||
def _to_out(db, lst: ShoppingList, role: str, may_share: bool = False) -> ListOut:
|
||||
count = db.scalar(
|
||||
select(func.count()).select_from(ListMember).where(ListMember.list_id == lst.id)
|
||||
)
|
||||
return ListOut(
|
||||
id=lst.id, name=lst.name, owner_id=lst.owner_id, rev=lst.rev,
|
||||
role=role, may_share_public=(role == "owner") or may_share,
|
||||
member_count=count or 0, created_at=lst.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[ListOut])
|
||||
def my_lists(db: DbSession, user: VerifiedUser):
|
||||
rows = db.execute(
|
||||
select(ShoppingList, ListMember.role, ListMember.may_share_public)
|
||||
.join(ListMember, ListMember.list_id == ShoppingList.id)
|
||||
.where(ListMember.user_id == user.id, ShoppingList.deleted_at.is_(None))
|
||||
.order_by(ShoppingList.created_at)
|
||||
).all()
|
||||
return [_to_out(db, lst, role, may) for lst, role, may in rows]
|
||||
|
||||
|
||||
@router.post("", response_model=ListOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_list(payload: ListCreateIn, db: DbSession, user: VerifiedUser):
|
||||
owned = db.scalar(
|
||||
select(func.count()).select_from(ShoppingList).where(
|
||||
ShoppingList.owner_id == user.id, ShoppingList.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
if (owned or 0) >= MAX_LISTS_PER_USER:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
f"Höchstens {MAX_LISTS_PER_USER} eigene Listen.",
|
||||
)
|
||||
|
||||
lst = ShoppingList(name=payload.name.strip(), owner_id=user.id, rev=1)
|
||||
db.add(lst)
|
||||
db.flush()
|
||||
db.add(ListMember(
|
||||
list_id=lst.id, user_id=user.id, role="owner", may_share_public=True
|
||||
))
|
||||
db.commit()
|
||||
return _to_out(db, lst, "owner")
|
||||
|
||||
|
||||
@router.get("/{list_id}", response_model=ListOut)
|
||||
def read_list(lst: ReadableList, db: DbSession, user: VerifiedUser):
|
||||
member = db.get(ListMember, (lst.id, user.id))
|
||||
return _to_out(db, lst, member.role, member.may_share_public)
|
||||
|
||||
|
||||
@router.get("/{list_id}/snapshot", response_model=ListSnapshot)
|
||||
def snapshot(lst: ReadableList, db: DbSession, user: VerifiedUser):
|
||||
"""Gesamtstand in einem Aufruf: Liste, gruppierte Ansicht, Märkte,
|
||||
Warengruppen. Ersetzt vier einzelne Abfragen."""
|
||||
member = db.get(ListMember, (lst.id, user.id))
|
||||
markets = db.scalars(
|
||||
select(Market)
|
||||
.where(Market.list_id == lst.id, Market.deleted_at.is_(None))
|
||||
.order_by(Market.sort_order, Market.name)
|
||||
).all()
|
||||
categories = db.scalars(
|
||||
select(Category)
|
||||
.where(Category.list_id == lst.id, Category.deleted_at.is_(None))
|
||||
.order_by(Category.sort_order, Category.name)
|
||||
).all()
|
||||
|
||||
return ListSnapshot(
|
||||
list=_to_out(db, lst, member.role, member.may_share_public),
|
||||
view=build_view(db, lst),
|
||||
markets=[MarketOut.model_validate(m) for m in markets],
|
||||
categories=[CategoryOut.model_validate(c) for c in categories],
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{list_id}", response_model=ListOut)
|
||||
def rename_list(payload: ListUpdateIn, lst: OwnedList, db: DbSession, user: VerifiedUser):
|
||||
lst.name = payload.name.strip()
|
||||
lst.rev += 1
|
||||
db.commit()
|
||||
return _to_out(db, lst, "owner")
|
||||
|
||||
|
||||
@router.delete("/{list_id}", response_model=MessageOut)
|
||||
def delete_list(lst: OwnedList, db: DbSession):
|
||||
# Soft Delete: offline-Clients sollen das Verschwinden noch mitbekommen.
|
||||
lst.deleted_at = utcnow()
|
||||
lst.rev += 1
|
||||
db.commit()
|
||||
return MessageOut(detail="Liste gelöscht.")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Mitgliedschaften
|
||||
# Einladungen per Mail und QR-Code folgen in Phase 5. Hier lassen sich
|
||||
# bestehende Mitgliedschaften nur ansehen, ändern und beenden.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@router.get("/{list_id}/members", response_model=list[MemberOut])
|
||||
def list_members(lst: ReadableList, db: DbSession):
|
||||
rows = db.execute(
|
||||
select(ListMember, User)
|
||||
.join(User, User.id == ListMember.user_id)
|
||||
.where(ListMember.list_id == lst.id)
|
||||
.order_by(ListMember.joined_at)
|
||||
).all()
|
||||
return [
|
||||
MemberOut(
|
||||
user_id=u.id, email=u.email, display_name=u.display_name,
|
||||
role=m.role, may_share_public=m.may_share_public,
|
||||
joined_at=m.joined_at,
|
||||
)
|
||||
for m, u in rows
|
||||
]
|
||||
|
||||
|
||||
@router.put("/{list_id}/members/{user_id}", response_model=MemberOut)
|
||||
def set_member_role(
|
||||
user_id: str, payload: MemberRoleIn, lst: OwnedList, db: DbSession,
|
||||
user: VerifiedUser,
|
||||
):
|
||||
if user_id == lst.owner_id:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"Die Rolle des Eigentümers lässt sich nicht ändern. "
|
||||
"Übertrage stattdessen die Liste.",
|
||||
)
|
||||
member = db.get(ListMember, (lst.id, user_id))
|
||||
if member is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Kein Mitglied dieser Liste")
|
||||
|
||||
member.role = payload.role
|
||||
if payload.may_share_public is not None:
|
||||
member.may_share_public = payload.may_share_public
|
||||
lst.rev += 1
|
||||
db.commit()
|
||||
|
||||
target = db.get(User, user_id)
|
||||
return MemberOut(
|
||||
user_id=target.id, email=target.email, display_name=target.display_name,
|
||||
role=member.role, may_share_public=member.may_share_public,
|
||||
joined_at=member.joined_at,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{list_id}/members/{user_id}", response_model=MessageOut)
|
||||
def remove_member(user_id: str, list_id: str, db: DbSession, user: VerifiedUser):
|
||||
"""Der Eigentümer entzieht anderen den Zugriff; jedes Mitglied kann
|
||||
sich selbst entfernen (bequemer über POST /api/lists/{id}/leave).
|
||||
|
||||
Der Eigentümer kann sich nicht selbst entfernen - die Liste stünde
|
||||
sonst ohne Verwaltung da. Dafür gibt es den Eigentümerwechsel."""
|
||||
lst = db.scalar(
|
||||
select(ShoppingList).where(
|
||||
ShoppingList.id == list_id, ShoppingList.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
own = db.get(ListMember, (list_id, user.id))
|
||||
if lst is None or own is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Liste nicht gefunden")
|
||||
|
||||
if user_id != user.id and own.role != "owner":
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN, "Nur der Eigentümer kann andere entfernen."
|
||||
)
|
||||
if user_id == lst.owner_id:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"Der Eigentümer kann die Liste nicht verlassen. Lösche sie oder "
|
||||
"übertrage sie zuvor.",
|
||||
)
|
||||
|
||||
member = db.get(ListMember, (list_id, user_id))
|
||||
if member is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Kein Mitglied dieser Liste")
|
||||
|
||||
db.delete(member)
|
||||
lst.rev += 1
|
||||
db.commit()
|
||||
return MessageOut(detail="Mitgliedschaft beendet.")
|
||||
39
backend/app/routers/prices.py
Normal file
39
backend/app/routers/prices.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Preisvergleich: Übersicht, Verlauf je Artikel, Kurzhinweise."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.deps import DbSession
|
||||
from app.models import Article
|
||||
from app.permissions import ReadableList
|
||||
from app.prices import article_prices, hints, overview
|
||||
from app.schemas_shopping import ArticlePrices, PriceHint, PriceOverview
|
||||
|
||||
router = APIRouter(prefix="/api/lists/{list_id}", tags=["prices"])
|
||||
|
||||
|
||||
@router.get("/prices", response_model=PriceOverview)
|
||||
def price_overview(lst: ReadableList, db: DbSession):
|
||||
"""Vergleichstabelle: je Artikel der jüngste Preis in jedem Markt."""
|
||||
return overview(db, lst.id)
|
||||
|
||||
|
||||
@router.get("/price-hints", response_model=list[PriceHint])
|
||||
def price_hints(lst: ReadableList, db: DbSession):
|
||||
"""Kompakte Fassung für die Listenansicht - nur Artikel, bei denen
|
||||
sich die Märkte tatsächlich unterscheiden."""
|
||||
return hints(db, lst.id)
|
||||
|
||||
|
||||
@router.get("/articles/{article_id}/prices", response_model=ArticlePrices)
|
||||
def article_price_history(article_id: str, lst: ReadableList, db: DbSession):
|
||||
article = db.scalar(
|
||||
select(Article).where(
|
||||
Article.id == article_id,
|
||||
Article.list_id == lst.id,
|
||||
Article.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
if article is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Artikel nicht gefunden")
|
||||
return article_prices(db, lst.id, article)
|
||||
297
backend/app/routers/public.py
Normal file
297
backend/app/routers/public.py
Normal file
@@ -0,0 +1,297 @@
|
||||
"""Öffentliche Ansichtslinks.
|
||||
|
||||
Wer den Link hat, darf die Liste sehen und - sofern erlaubt - Artikel
|
||||
abhaken. Sonst nichts: kein Anlegen, kein Löschen, keine Preise ändern,
|
||||
keine Mitgliederliste, keine Namen.
|
||||
|
||||
Sicherheitsmodell: Der Link *ist* die Berechtigung. Wer ihn
|
||||
weitergibt, gibt den Zugriff weiter. Deshalb ist ein Ablaufdatum Pflicht,
|
||||
der Widerruf jederzeit möglich, und in der Datenbank steht nur der Hash
|
||||
des Tokens.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import settings
|
||||
from app.deps import DbSession, VerifiedUser, client_ip
|
||||
from app.list_view import build_view
|
||||
from app.print_view import render_print
|
||||
from app.models import ListItem, ListMember, PublicShare, ShoppingList
|
||||
from app.permissions import bump_rev
|
||||
from app.schemas import MessageOut
|
||||
from app.schemas_shopping import (
|
||||
PublicShareCreatedOut,
|
||||
PublicShareIn,
|
||||
PublicShareOut,
|
||||
PublicToggleIn,
|
||||
PublicViewOut,
|
||||
)
|
||||
from app.security import check_rate_limit, hash_token, new_token, utcnow
|
||||
|
||||
router = APIRouter(tags=["public"])
|
||||
|
||||
MAX_DAYS = 365
|
||||
MAX_ACTIVE_LINKS = 10
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Verwaltung (angemeldet)
|
||||
# ==========================================================================
|
||||
|
||||
def sharer(list_id: str, db: DbSession, user: VerifiedUser) -> ShoppingList:
|
||||
"""Eigentümer oder Mitglied mit dem Zusatzrecht `may_share_public`."""
|
||||
lst = db.scalar(
|
||||
select(ShoppingList).where(
|
||||
ShoppingList.id == list_id, ShoppingList.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
member = db.get(ListMember, (list_id, user.id))
|
||||
if lst is None or member is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Liste nicht gefunden")
|
||||
|
||||
if member.role != "owner" and not member.may_share_public:
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
"Für öffentliche Links fehlt dir die Berechtigung. Der Eigentümer "
|
||||
"der Liste kann sie dir erteilen.",
|
||||
)
|
||||
return lst
|
||||
|
||||
|
||||
SharableList = Annotated[ShoppingList, Depends(sharer)]
|
||||
|
||||
|
||||
def _status(share: PublicShare) -> str:
|
||||
if share.revoked_at:
|
||||
return "revoked"
|
||||
if share.expires_at <= utcnow():
|
||||
return "expired"
|
||||
return "active"
|
||||
|
||||
|
||||
def _share_out(share: PublicShare) -> PublicShareOut:
|
||||
creator = share.creator
|
||||
return PublicShareOut(
|
||||
id=share.id,
|
||||
label=share.label,
|
||||
allow_check=share.allow_check,
|
||||
status=_status(share),
|
||||
created_by_name=(
|
||||
(creator.display_name or creator.email.split("@")[0]) if creator else None
|
||||
),
|
||||
created_at=share.created_at,
|
||||
expires_at=share.expires_at,
|
||||
last_access_at=share.last_access_at,
|
||||
access_count=share.access_count,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/lists/{list_id}/public-links", response_model=list[PublicShareOut])
|
||||
def get_public_links(lst: SharableList, db: DbSession):
|
||||
rows = db.scalars(
|
||||
select(PublicShare)
|
||||
.where(PublicShare.list_id == lst.id)
|
||||
.order_by(PublicShare.created_at.desc())
|
||||
).all()
|
||||
return [_share_out(s) for s in rows]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/lists/{list_id}/public-links",
|
||||
response_model=PublicShareCreatedOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_public_link(
|
||||
payload: PublicShareIn, lst: SharableList, db: DbSession, user: VerifiedUser
|
||||
):
|
||||
expires = payload.expires_at.replace(tzinfo=None)
|
||||
now = utcnow()
|
||||
|
||||
if expires <= now:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, "Das Ablaufdatum muss in der Zukunft liegen."
|
||||
)
|
||||
if expires > now + timedelta(days=MAX_DAYS):
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
f"Ein Link darf höchstens {MAX_DAYS} Tage gültig sein.",
|
||||
)
|
||||
|
||||
active = [
|
||||
s
|
||||
for s in db.scalars(
|
||||
select(PublicShare).where(PublicShare.list_id == lst.id)
|
||||
).all()
|
||||
if _status(s) == "active"
|
||||
]
|
||||
if len(active) >= MAX_ACTIVE_LINKS:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
f"Es sind bereits {MAX_ACTIVE_LINKS} Links aktiv. Widerrufe zuerst "
|
||||
"einen davon.",
|
||||
)
|
||||
|
||||
raw = new_token()
|
||||
share = PublicShare(
|
||||
list_id=lst.id,
|
||||
token_hash=hash_token(raw),
|
||||
label=(payload.label or None),
|
||||
allow_check=payload.allow_check,
|
||||
created_by=user.id,
|
||||
expires_at=expires,
|
||||
)
|
||||
db.add(share)
|
||||
db.commit()
|
||||
db.refresh(share)
|
||||
|
||||
return PublicShareCreatedOut(
|
||||
share=_share_out(share),
|
||||
url=f"{settings.public_base_url.rstrip('/')}/s/{raw}",
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/api/lists/{list_id}/public-links/{share_id}", response_model=MessageOut)
|
||||
def revoke_public_link(share_id: str, lst: SharableList, db: DbSession):
|
||||
share = db.get(PublicShare, share_id)
|
||||
if share is None or share.list_id != lst.id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Link nicht gefunden")
|
||||
if share.revoked_at is None:
|
||||
share.revoked_at = utcnow()
|
||||
db.commit()
|
||||
return MessageOut(detail="Link widerrufen. Er funktioniert ab sofort nicht mehr.")
|
||||
|
||||
|
||||
@router.post("/api/lists/{list_id}/public-links/revoke-all", response_model=MessageOut)
|
||||
def revoke_all_public_links(lst: SharableList, db: DbSession):
|
||||
count = 0
|
||||
for share in db.scalars(
|
||||
select(PublicShare).where(
|
||||
PublicShare.list_id == lst.id, PublicShare.revoked_at.is_(None)
|
||||
)
|
||||
).all():
|
||||
share.revoked_at = utcnow()
|
||||
count += 1
|
||||
db.commit()
|
||||
return MessageOut(detail=f"{count} Link(s) widerrufen.")
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Zugriff ohne Konto
|
||||
# ==========================================================================
|
||||
|
||||
def _load_share(db: DbSession, token: str, request: Request) -> PublicShare:
|
||||
# Ohne Anmeldung ist der Endpunkt für jeden erreichbar - deshalb
|
||||
# bremsen, bevor jemand Token durchprobiert. Bei 256 Bit Zufall ist
|
||||
# Raten aussichtslos, aber die Last soll trotzdem begrenzt bleiben.
|
||||
if not check_rate_limit(
|
||||
db, f"public:{client_ip(request)}", limit=120, window_minutes=15
|
||||
):
|
||||
db.commit()
|
||||
raise HTTPException(
|
||||
status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Anfragen. Bitte kurz warten."
|
||||
)
|
||||
|
||||
share = db.scalar(
|
||||
select(PublicShare).where(PublicShare.token_hash == hash_token(token))
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# Gleiche Antwort für "gibt es nicht" und "abgelaufen"? Nein: Der
|
||||
# Empfänger soll erfahren, warum der Link nicht mehr geht, sonst
|
||||
# sucht er den Fehler bei sich.
|
||||
if share is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Dieser Link ist unbekannt.")
|
||||
state = _status(share)
|
||||
if state == "revoked":
|
||||
raise HTTPException(status.HTTP_410_GONE, "Dieser Link wurde widerrufen.")
|
||||
if state == "expired":
|
||||
raise HTTPException(status.HTTP_410_GONE, "Dieser Link ist abgelaufen.")
|
||||
return share
|
||||
|
||||
|
||||
@router.get("/api/public/{token}", response_model=PublicViewOut)
|
||||
def public_view(token: str, request: Request, response: Response, db: DbSession):
|
||||
share = _load_share(db, token, request)
|
||||
|
||||
lst = db.get(ShoppingList, share.list_id)
|
||||
if lst is None or lst.deleted_at is not None:
|
||||
raise HTTPException(status.HTTP_410_GONE, "Die Liste existiert nicht mehr.")
|
||||
|
||||
# Grobe Nutzungsanzeige für den Eigentümer: nur Zähler und Zeitpunkt,
|
||||
# keine IP-Adresse, keine Zeitreihe. Erkennbar soll sein, DASS der
|
||||
# Link benutzt wird, nicht von wem.
|
||||
share.access_count += 1
|
||||
share.last_access_at = utcnow()
|
||||
db.commit()
|
||||
|
||||
response.headers["X-Robots-Tag"] = "noindex, nofollow"
|
||||
|
||||
view = build_view(db, lst, anonymous=True)
|
||||
return PublicViewOut(
|
||||
list_name=view.list_name,
|
||||
rev=view.rev,
|
||||
allow_check=share.allow_check,
|
||||
expires_at=share.expires_at,
|
||||
markets=view.markets,
|
||||
grand_total_cents=view.grand_total_cents,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/public/{token}/print", response_class=HTMLResponse)
|
||||
def public_print(token: str, request: Request, db: DbSession):
|
||||
"""Druckansicht über den öffentlichen Link - ohne Konto.
|
||||
|
||||
Wie die Bildschirmansicht anonymisiert: Wer welchen Artikel
|
||||
eingetragen hat, steht dort ohnehin nicht drin, und im Ausdruck
|
||||
erst recht nicht.
|
||||
"""
|
||||
share = _load_share(db, token, request)
|
||||
|
||||
lst = db.get(ShoppingList, share.list_id)
|
||||
if lst is None or lst.deleted_at is not None:
|
||||
raise HTTPException(status.HTTP_410_GONE, "Die Liste existiert nicht mehr.")
|
||||
|
||||
share.access_count += 1
|
||||
share.last_access_at = utcnow()
|
||||
db.commit()
|
||||
|
||||
view = build_view(db, lst, include_deferred=False, anonymous=True)
|
||||
return HTMLResponse(
|
||||
render_print(view),
|
||||
headers={"X-Robots-Tag": "noindex, nofollow"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/public/{token}/items/{item_id}", response_model=MessageOut)
|
||||
def public_toggle(
|
||||
token: str,
|
||||
item_id: str,
|
||||
payload: PublicToggleIn,
|
||||
request: Request,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Die einzige Änderung, die über einen öffentlichen Link möglich ist.
|
||||
|
||||
Absichtlich auf offen/gekauft begrenzt: Wer mit dem Link einkaufen
|
||||
geht, soll abhaken können. Alles Weitere - anlegen, löschen, Preise,
|
||||
Märkte - bleibt den Mitgliedern vorbehalten.
|
||||
"""
|
||||
share = _load_share(db, token, request)
|
||||
if not share.allow_check:
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN, "Über diesen Link ist nur Ansehen erlaubt."
|
||||
)
|
||||
|
||||
item = db.get(ListItem, item_id)
|
||||
if item is None or item.list_id != share.list_id or item.deleted_at is not None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Eintrag nicht gefunden")
|
||||
|
||||
item.status = payload.status
|
||||
item.row_rev = bump_rev(db, share.list_id)
|
||||
db.commit()
|
||||
return MessageOut(detail="Gespeichert.")
|
||||
160
backend/app/routers/push.py
Normal file
160
backend/app/routers/push.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""An- und Abmeldung von Geräten für Push-Benachrichtigungen."""
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import settings
|
||||
from app.deps import DbSession, VerifiedUser
|
||||
from app.models import PushSubscription
|
||||
from app.push import send_to_user
|
||||
from app.schemas import MessageOut
|
||||
from app.schemas_push import (
|
||||
PushConfigOut,
|
||||
PushSubscribeIn,
|
||||
PushSubscriptionOut,
|
||||
PushUnsubscribeIn,
|
||||
)
|
||||
from app.security import utcnow
|
||||
|
||||
router = APIRouter(prefix="/api/push", tags=["push"])
|
||||
|
||||
MAX_DEVICES_PER_USER = 20
|
||||
|
||||
|
||||
@router.get("/config", response_model=PushConfigOut)
|
||||
def push_config():
|
||||
"""Ohne Anmeldung erreichbar: Der öffentliche Schlüssel ist dafür da,
|
||||
veröffentlicht zu werden."""
|
||||
return PushConfigOut(
|
||||
enabled=settings.push_enabled,
|
||||
public_key=settings.vapid_public_key or None,
|
||||
throttle_hours=settings.push_throttle_hours,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/subscriptions", response_model=list[PushSubscriptionOut])
|
||||
def my_subscriptions(db: DbSession, user: VerifiedUser):
|
||||
rows = db.scalars(
|
||||
select(PushSubscription)
|
||||
.where(PushSubscription.user_id == user.id)
|
||||
.order_by(PushSubscription.created_at)
|
||||
).all()
|
||||
return [
|
||||
PushSubscriptionOut(
|
||||
id=s.id, label=s.label, created_at=s.created_at,
|
||||
last_success_at=s.last_success_at,
|
||||
)
|
||||
for s in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("/subscribe", response_model=PushSubscriptionOut,
|
||||
status_code=status.HTTP_201_CREATED)
|
||||
def subscribe(payload: PushSubscribeIn, db: DbSession, user: VerifiedUser):
|
||||
if not settings.push_enabled:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
"Push-Benachrichtigungen sind auf diesem Server nicht eingerichtet.",
|
||||
)
|
||||
|
||||
existing = db.scalar(
|
||||
select(PushSubscription).where(PushSubscription.endpoint == payload.endpoint)
|
||||
)
|
||||
if existing is not None:
|
||||
# Derselbe Endpunkt kann nach einem Kontowechsel auf demselben
|
||||
# Geraet auftauchen - dann uebernehmen statt abweisen.
|
||||
existing.user_id = user.id
|
||||
existing.p256dh = payload.keys.p256dh
|
||||
existing.auth = payload.keys.auth
|
||||
existing.label = payload.label or existing.label
|
||||
existing.failure_count = 0
|
||||
db.commit()
|
||||
db.refresh(existing)
|
||||
return PushSubscriptionOut(
|
||||
id=existing.id, label=existing.label, created_at=existing.created_at,
|
||||
last_success_at=existing.last_success_at,
|
||||
)
|
||||
|
||||
count = len(db.scalars(
|
||||
select(PushSubscription).where(PushSubscription.user_id == user.id)
|
||||
).all())
|
||||
if count >= MAX_DEVICES_PER_USER:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
f"Höchstens {MAX_DEVICES_PER_USER} Geräte je Konto.",
|
||||
)
|
||||
|
||||
subscription = PushSubscription(
|
||||
user_id=user.id,
|
||||
endpoint=payload.endpoint,
|
||||
p256dh=payload.keys.p256dh,
|
||||
auth=payload.keys.auth,
|
||||
label=(payload.label or None),
|
||||
)
|
||||
db.add(subscription)
|
||||
db.commit()
|
||||
db.refresh(subscription)
|
||||
return PushSubscriptionOut(
|
||||
id=subscription.id, label=subscription.label,
|
||||
created_at=subscription.created_at, last_success_at=None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/unsubscribe", response_model=MessageOut)
|
||||
def unsubscribe(payload: PushUnsubscribeIn, db: DbSession, user: VerifiedUser):
|
||||
subscription = db.scalar(
|
||||
select(PushSubscription).where(
|
||||
PushSubscription.endpoint == payload.endpoint,
|
||||
PushSubscription.user_id == user.id,
|
||||
)
|
||||
)
|
||||
if subscription is not None:
|
||||
db.delete(subscription)
|
||||
db.commit()
|
||||
# Auch wenn nichts gefunden wurde: Fuer den Aufrufer ist das Ziel
|
||||
# erreicht - dieses Geraet bekommt keine Benachrichtigungen mehr.
|
||||
return MessageOut(detail="Benachrichtigungen für dieses Gerät abgeschaltet.")
|
||||
|
||||
|
||||
@router.delete("/subscriptions/{subscription_id}", response_model=MessageOut)
|
||||
def remove_subscription(subscription_id: str, db: DbSession, user: VerifiedUser):
|
||||
subscription = db.get(PushSubscription, subscription_id)
|
||||
if subscription is None or subscription.user_id != user.id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gerät nicht gefunden")
|
||||
db.delete(subscription)
|
||||
db.commit()
|
||||
return MessageOut(detail="Gerät entfernt.")
|
||||
|
||||
|
||||
@router.post("/test", response_model=MessageOut, status_code=status.HTTP_202_ACCEPTED)
|
||||
def send_test(background: BackgroundTasks, db: DbSession, user: VerifiedUser):
|
||||
"""Testnachricht an die eigenen Geräte - umgeht die Drosselung."""
|
||||
if not settings.push_enabled:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
"Push-Benachrichtigungen sind auf diesem Server nicht eingerichtet.",
|
||||
)
|
||||
|
||||
count = len(db.scalars(
|
||||
select(PushSubscription).where(PushSubscription.user_id == user.id)
|
||||
).all())
|
||||
if count == 0:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"Für dieses Konto ist kein Gerät angemeldet.",
|
||||
)
|
||||
|
||||
def run() -> None:
|
||||
from app.db import SessionLocal
|
||||
with SessionLocal() as session:
|
||||
send_to_user(session, user.id, {
|
||||
"title": settings.app_name,
|
||||
"body": "Testbenachrichtigung – die Zustellung funktioniert.",
|
||||
"tag": "test",
|
||||
})
|
||||
|
||||
background.add_task(run)
|
||||
return MessageOut(
|
||||
detail=f"Testnachricht an {count} Gerät(e) in Auftrag gegeben. "
|
||||
"Ergebnis steht im Log des api-Containers."
|
||||
)
|
||||
412
backend/app/routers/sharing.py
Normal file
412
backend/app/routers/sharing.py
Normal file
@@ -0,0 +1,412 @@
|
||||
"""Teilen von Listen: Einladungen, Widerruf, Eigentümerwechsel.
|
||||
|
||||
Ablauf einer Einladung:
|
||||
|
||||
1. Der Eigentümer gibt eine E-Mail-Adresse ein.
|
||||
2. Es entsteht ein Datensatz mit Zufallstoken; versendet wird ein Link
|
||||
auf /invite?token=…
|
||||
3. Der Empfänger meldet sich an oder registriert sich und nimmt an.
|
||||
4. Beim Annehmen entsteht die Mitgliedschaft.
|
||||
|
||||
Bewusst unabhängig davon, ob unter der Adresse schon ein Konto besteht.
|
||||
Damit verrät das Einladen nicht, wer registriert ist, und neue wie
|
||||
bestehende Nutzer durchlaufen denselben Weg.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.deps import DbSession, VerifiedUser, client_ip
|
||||
from app.mail import send_invitation
|
||||
from app.models import ListInvite, ListMember, ShoppingList, User
|
||||
from app.permissions import OwnedList, ReadableList
|
||||
from app.schemas import MessageOut
|
||||
from app.schemas_shopping import (
|
||||
InviteCreateIn,
|
||||
InviteOut,
|
||||
InvitePreviewOut,
|
||||
MemberOut,
|
||||
TransferOwnershipIn,
|
||||
)
|
||||
from app.security import (
|
||||
check_rate_limit,
|
||||
hash_token,
|
||||
new_token,
|
||||
normalize_email,
|
||||
utcnow,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["sharing"])
|
||||
|
||||
INVITE_DAYS = 14
|
||||
MAX_OPEN_INVITES = 50
|
||||
|
||||
|
||||
def _status(invite: ListInvite) -> str:
|
||||
if invite.revoked_at:
|
||||
return "revoked"
|
||||
if invite.accepted_at:
|
||||
return "accepted"
|
||||
if invite.expires_at <= utcnow():
|
||||
return "expired"
|
||||
return "pending"
|
||||
|
||||
|
||||
def _invite_out(invite: ListInvite) -> InviteOut:
|
||||
inviter = invite.inviter
|
||||
return InviteOut(
|
||||
id=invite.id,
|
||||
email=invite.email,
|
||||
role=invite.role,
|
||||
status=_status(invite),
|
||||
invited_by_name=(
|
||||
(inviter.display_name or inviter.email.split("@")[0]) if inviter else None
|
||||
),
|
||||
created_at=invite.created_at,
|
||||
last_sent_at=invite.last_sent_at,
|
||||
send_count=invite.send_count,
|
||||
expires_at=invite.expires_at,
|
||||
)
|
||||
|
||||
|
||||
def _guard_invite_rate(db: DbSession, request: Request, list_id: str) -> None:
|
||||
"""Zwei Bremsen: pro Liste und pro absendender IP.
|
||||
|
||||
Ohne die zweite ließe sich die Anwendung als Versandhilfe für
|
||||
unerwünschte Mails missbrauchen - jede Einladung erzeugt schließlich
|
||||
eine Nachricht an eine frei wählbare Adresse.
|
||||
"""
|
||||
ok_list = check_rate_limit(db, f"invite-list:{list_id}", limit=20, window_minutes=60)
|
||||
ok_ip = check_rate_limit(db, f"invite-ip:{client_ip(request)}", limit=30, window_minutes=60)
|
||||
if not (ok_list and ok_ip):
|
||||
db.commit()
|
||||
raise HTTPException(
|
||||
status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
"Zu viele Einladungen in kurzer Zeit. Bitte später erneut versuchen.",
|
||||
)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Einladungen verwalten (nur Eigentümer)
|
||||
# ==========================================================================
|
||||
|
||||
@router.get("/api/lists/{list_id}/invites", response_model=list[InviteOut])
|
||||
def get_invites(lst: OwnedList, db: DbSession):
|
||||
rows = db.scalars(
|
||||
select(ListInvite)
|
||||
.where(ListInvite.list_id == lst.id)
|
||||
.order_by(ListInvite.created_at.desc())
|
||||
).all()
|
||||
return [_invite_out(i) for i in rows]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/lists/{list_id}/invites",
|
||||
response_model=InviteOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_invite(
|
||||
payload: InviteCreateIn,
|
||||
request: Request,
|
||||
lst: OwnedList,
|
||||
db: DbSession,
|
||||
user: VerifiedUser,
|
||||
background: BackgroundTasks,
|
||||
):
|
||||
email = normalize_email(payload.email)
|
||||
|
||||
if email == normalize_email(user.email):
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, "Du bist bereits Eigentümer dieser Liste."
|
||||
)
|
||||
|
||||
# Ist unter der Adresse jemand bereits Mitglied?
|
||||
existing_user = db.scalar(select(User).where(User.email == email))
|
||||
if existing_user is not None:
|
||||
member = db.get(ListMember, (lst.id, existing_user.id))
|
||||
if member is not None:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"Diese Person hat bereits Zugriff auf die Liste.",
|
||||
)
|
||||
|
||||
open_count = sum(
|
||||
1
|
||||
for i in db.scalars(
|
||||
select(ListInvite).where(ListInvite.list_id == lst.id)
|
||||
).all()
|
||||
if _status(i) == "pending"
|
||||
)
|
||||
if open_count >= MAX_OPEN_INVITES:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
f"Es sind bereits {MAX_OPEN_INVITES} Einladungen offen.",
|
||||
)
|
||||
|
||||
_guard_invite_rate(db, request, lst.id)
|
||||
|
||||
# Eine noch offene Einladung an dieselbe Adresse ersetzen, statt eine
|
||||
# zweite danebenzulegen.
|
||||
for old in db.scalars(
|
||||
select(ListInvite).where(
|
||||
ListInvite.list_id == lst.id,
|
||||
ListInvite.email == email,
|
||||
ListInvite.accepted_at.is_(None),
|
||||
ListInvite.revoked_at.is_(None),
|
||||
)
|
||||
).all():
|
||||
old.revoked_at = utcnow()
|
||||
|
||||
raw = new_token()
|
||||
invite = ListInvite(
|
||||
list_id=lst.id,
|
||||
email=email,
|
||||
role=payload.role,
|
||||
token_hash=hash_token(raw),
|
||||
invited_by=user.id,
|
||||
expires_at=utcnow() + timedelta(days=INVITE_DAYS),
|
||||
)
|
||||
db.add(invite)
|
||||
db.commit()
|
||||
db.refresh(invite)
|
||||
|
||||
background.add_task(
|
||||
send_invitation, email, raw,
|
||||
lst.name, user.display_name or user.email.split("@")[0], INVITE_DAYS,
|
||||
)
|
||||
return _invite_out(invite)
|
||||
|
||||
|
||||
@router.post("/api/lists/{list_id}/invites/{invite_id}/resend", response_model=InviteOut)
|
||||
def resend_invite(
|
||||
invite_id: str,
|
||||
request: Request,
|
||||
lst: OwnedList,
|
||||
db: DbSession,
|
||||
user: VerifiedUser,
|
||||
background: BackgroundTasks,
|
||||
):
|
||||
"""Erneuter Versand erzeugt ein NEUES Token und entwertet das alte.
|
||||
|
||||
In der Datenbank steht nur der Hash, der Klartext des ursprünglichen
|
||||
Tokens lässt sich also nicht rekonstruieren. Nebeneffekt: Ein Link,
|
||||
der versehentlich weitergeleitet wurde, verliert damit seine Gültigkeit.
|
||||
"""
|
||||
invite = db.get(ListInvite, invite_id)
|
||||
if invite is None or invite.list_id != lst.id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Einladung nicht gefunden")
|
||||
if invite.accepted_at:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, "Diese Einladung wurde bereits angenommen."
|
||||
)
|
||||
|
||||
_guard_invite_rate(db, request, lst.id)
|
||||
|
||||
raw = new_token()
|
||||
invite.token_hash = hash_token(raw)
|
||||
invite.revoked_at = None
|
||||
invite.expires_at = utcnow() + timedelta(days=INVITE_DAYS)
|
||||
invite.last_sent_at = utcnow()
|
||||
invite.send_count += 1
|
||||
db.commit()
|
||||
db.refresh(invite)
|
||||
|
||||
background.add_task(
|
||||
send_invitation, invite.email, raw,
|
||||
lst.name, user.display_name or user.email.split("@")[0], INVITE_DAYS,
|
||||
)
|
||||
return _invite_out(invite)
|
||||
|
||||
|
||||
@router.delete("/api/lists/{list_id}/invites/{invite_id}", response_model=MessageOut)
|
||||
def revoke_invite(invite_id: str, lst: OwnedList, db: DbSession):
|
||||
invite = db.get(ListInvite, invite_id)
|
||||
if invite is None or invite.list_id != lst.id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Einladung nicht gefunden")
|
||||
if invite.accepted_at:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"Diese Einladung wurde bereits angenommen. Entziehe stattdessen "
|
||||
"die Mitgliedschaft.",
|
||||
)
|
||||
invite.revoked_at = utcnow()
|
||||
db.commit()
|
||||
return MessageOut(detail="Einladung widerrufen. Der Link funktioniert nicht mehr.")
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Freigabe insgesamt aufheben
|
||||
# ==========================================================================
|
||||
|
||||
@router.post("/api/lists/{list_id}/unshare", response_model=MessageOut)
|
||||
def unshare(lst: OwnedList, db: DbSession):
|
||||
"""Entfernt alle Mitglieder außer dem Eigentümer und widerruft alle
|
||||
offenen Einladungen."""
|
||||
members = db.scalars(
|
||||
select(ListMember).where(
|
||||
ListMember.list_id == lst.id, ListMember.user_id != lst.owner_id
|
||||
)
|
||||
).all()
|
||||
for member in members:
|
||||
db.delete(member)
|
||||
|
||||
revoked = 0
|
||||
for invite in db.scalars(
|
||||
select(ListInvite).where(
|
||||
ListInvite.list_id == lst.id,
|
||||
ListInvite.accepted_at.is_(None),
|
||||
ListInvite.revoked_at.is_(None),
|
||||
)
|
||||
).all():
|
||||
invite.revoked_at = utcnow()
|
||||
revoked += 1
|
||||
|
||||
lst.rev += 1
|
||||
db.commit()
|
||||
return MessageOut(
|
||||
detail=f"{len(members)} Zugriff(e) entzogen, {revoked} offene Einladung(en) "
|
||||
"widerrufen."
|
||||
)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Eigentümerwechsel
|
||||
# ==========================================================================
|
||||
|
||||
@router.post("/api/lists/{list_id}/transfer", response_model=list[MemberOut])
|
||||
def transfer_ownership(
|
||||
payload: TransferOwnershipIn, lst: OwnedList, db: DbSession, user: VerifiedUser
|
||||
):
|
||||
"""Der bisherige Eigentümer wird zum Bearbeiter - so verliert er nicht
|
||||
versehentlich den Zugang zu einer Liste, an der er mitarbeitet."""
|
||||
if payload.user_id == lst.owner_id:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, "Diese Person ist bereits Eigentümer."
|
||||
)
|
||||
|
||||
target = db.get(ListMember, (lst.id, payload.user_id))
|
||||
if target is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
"Diese Person ist kein Mitglied der Liste. Lade sie zuerst ein.",
|
||||
)
|
||||
|
||||
previous = db.get(ListMember, (lst.id, lst.owner_id))
|
||||
if previous is not None:
|
||||
previous.role = "editor"
|
||||
|
||||
target.role = "owner"
|
||||
target.may_share_public = True
|
||||
lst.owner_id = payload.user_id
|
||||
lst.rev += 1
|
||||
db.commit()
|
||||
|
||||
rows = db.execute(
|
||||
select(ListMember, User)
|
||||
.join(User, User.id == ListMember.user_id)
|
||||
.where(ListMember.list_id == lst.id)
|
||||
.order_by(ListMember.joined_at)
|
||||
).all()
|
||||
return [
|
||||
MemberOut(
|
||||
user_id=u.id, email=u.email, display_name=u.display_name,
|
||||
role=m.role, may_share_public=m.may_share_public,
|
||||
joined_at=m.joined_at,
|
||||
)
|
||||
for m, u in rows
|
||||
]
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Einladung ansehen und annehmen (Empfängerseite)
|
||||
# ==========================================================================
|
||||
|
||||
def _load_invite(db: DbSession, token: str) -> ListInvite:
|
||||
invite = db.scalar(
|
||||
select(ListInvite).where(ListInvite.token_hash == hash_token(token))
|
||||
)
|
||||
if invite is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND, "Diese Einladung ist unbekannt."
|
||||
)
|
||||
state = _status(invite)
|
||||
if state == "revoked":
|
||||
raise HTTPException(status.HTTP_410_GONE, "Diese Einladung wurde widerrufen.")
|
||||
if state == "expired":
|
||||
raise HTTPException(
|
||||
status.HTTP_410_GONE,
|
||||
"Diese Einladung ist abgelaufen. Bitte um eine neue.",
|
||||
)
|
||||
if state == "accepted":
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, "Diese Einladung wurde bereits angenommen."
|
||||
)
|
||||
return invite
|
||||
|
||||
|
||||
@router.get("/api/invites/{token}", response_model=InvitePreviewOut)
|
||||
def preview_invite(token: str, db: DbSession, user: VerifiedUser):
|
||||
invite = _load_invite(db, token)
|
||||
lst = db.get(ShoppingList, invite.list_id)
|
||||
if lst is None or lst.deleted_at is not None:
|
||||
raise HTTPException(status.HTTP_410_GONE, "Die Liste existiert nicht mehr.")
|
||||
|
||||
inviter = invite.inviter
|
||||
return InvitePreviewOut(
|
||||
list_name=lst.name,
|
||||
invited_by_name=(
|
||||
(inviter.display_name or inviter.email.split("@")[0]) if inviter else None
|
||||
),
|
||||
role=invite.role,
|
||||
email=invite.email,
|
||||
matches_current_user=normalize_email(user.email) == invite.email,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/invites/{token}/accept", response_model=MessageOut)
|
||||
def accept_invite(token: str, db: DbSession, user: VerifiedUser):
|
||||
invite = _load_invite(db, token)
|
||||
|
||||
# Die Adresse muss zum angemeldeten Konto passen. Sonst würde ein
|
||||
# weitergeleiteter Link jedem den Zugang öffnen.
|
||||
if normalize_email(user.email) != invite.email:
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
f"Diese Einladung ist an {invite.email} gerichtet. "
|
||||
"Melde dich mit diesem Konto an oder bitte um eine neue Einladung.",
|
||||
)
|
||||
|
||||
lst = db.get(ShoppingList, invite.list_id)
|
||||
if lst is None or lst.deleted_at is not None:
|
||||
raise HTTPException(status.HTTP_410_GONE, "Die Liste existiert nicht mehr.")
|
||||
|
||||
if db.get(ListMember, (lst.id, user.id)) is None:
|
||||
db.add(ListMember(list_id=lst.id, user_id=user.id, role=invite.role))
|
||||
lst.rev += 1
|
||||
|
||||
invite.accepted_at = utcnow()
|
||||
invite.accepted_by = user.id
|
||||
db.commit()
|
||||
return MessageOut(detail=f"Du hast jetzt Zugriff auf „{lst.name}“.")
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Eigene Mitgliedschaft beenden
|
||||
# ==========================================================================
|
||||
|
||||
@router.post("/api/lists/{list_id}/leave", response_model=MessageOut)
|
||||
def leave_list(lst: ReadableList, db: DbSession, user: VerifiedUser):
|
||||
if lst.owner_id == user.id:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"Als Eigentümer kannst du die Liste nicht verlassen. Übertrage sie "
|
||||
"zuvor oder lösche sie.",
|
||||
)
|
||||
member = db.get(ListMember, (lst.id, user.id))
|
||||
if member is not None:
|
||||
db.delete(member)
|
||||
lst.rev += 1
|
||||
db.commit()
|
||||
return MessageOut(detail="Du hast die Liste verlassen.")
|
||||
354
backend/app/routers/sync.py
Normal file
354
backend/app/routers/sync.py
Normal file
@@ -0,0 +1,354 @@
|
||||
"""Synchronisation: Stapelverarbeitung aus der Outbox und Ereigniskanal.
|
||||
|
||||
Zwei Endpunkte:
|
||||
|
||||
POST /api/lists/{id}/ops Der Client schickt seine gesammelten
|
||||
Operationen. Jede trägt eine selbst
|
||||
vergebene `op_id`; bereits verarbeitete
|
||||
werden erkannt und übersprungen.
|
||||
|
||||
GET /api/lists/{id}/events Server-Sent Events. Meldet nur den neuen
|
||||
Revisionsstand, keine Nutzdaten - der
|
||||
Client holt die Ansicht dann selbst.
|
||||
|
||||
Warum kein Delta-Endpunkt: Eine Einkaufsliste hat Dutzende Einträge, keine
|
||||
Zehntausende. Die vollständige Ansicht neu zu holen kostet ein paar
|
||||
Kilobyte und spart eine ganze Klasse von Fehlern, die beim Zusammensetzen
|
||||
von Teilständen entsteht. Der Revisionszähler sorgt dafür, dass das nur
|
||||
passiert, wenn sich wirklich etwas geändert hat.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
import anyio
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import SessionLocal
|
||||
from app.deps import DbSession, VerifiedUser, current_user
|
||||
from app.list_view import build_view
|
||||
from app.models import AppliedOp, Article, Category, ListItem, ListMember, Market, ShoppingList
|
||||
from app.permissions import EditableList, ReadableList, bump_rev
|
||||
from app.prices import record_price
|
||||
from app.push import notify_list_changed
|
||||
from app.schemas_sync import OpBatchIn, OpBatchOut, OpResult
|
||||
from app.security import utcnow
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["sync"])
|
||||
|
||||
MAX_OPS_PER_BATCH = 200
|
||||
POLL_SECONDS = 2
|
||||
HEARTBEAT_SECONDS = 20
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Operationen anwenden
|
||||
# ==========================================================================
|
||||
|
||||
def _ref(db: Session, model, obj_id: str | None, list_id: str, label: str):
|
||||
if obj_id is None:
|
||||
return None
|
||||
obj = db.get(model, obj_id)
|
||||
if obj is None or obj.list_id != list_id or obj.deleted_at is not None:
|
||||
raise ValueError(f"{label} nicht gefunden")
|
||||
return obj
|
||||
|
||||
|
||||
def _apply(db: Session, lst: ShoppingList, user_id: str, op, rev: int) -> str | None:
|
||||
"""Führt eine einzelne Operation aus und gibt bei item.create die
|
||||
vergebene ID zurück. Wirft ValueError bei fachlichen Fehlern - die
|
||||
lässt der Aufrufer als abgelehnte Operation durchgehen, ohne den
|
||||
ganzen Stapel scheitern zu lassen."""
|
||||
data = op.payload
|
||||
kind = op.kind
|
||||
|
||||
if kind == "item.create":
|
||||
article = None
|
||||
|
||||
# Bekannter Artikel, etwa nach einem Strichcode-Treffer.
|
||||
if data.get("article_id"):
|
||||
article = db.get(Article, data["article_id"])
|
||||
if (article is None or article.list_id != lst.id
|
||||
or article.deleted_at is not None):
|
||||
raise ValueError("Artikel nicht gefunden")
|
||||
|
||||
# Sonst über den Barcode suchen ...
|
||||
if article is None and data.get("barcode"):
|
||||
article = db.scalar(
|
||||
select(Article).where(
|
||||
Article.list_id == lst.id,
|
||||
Article.barcode == data["barcode"],
|
||||
Article.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
|
||||
# ... und zuletzt über den Namen.
|
||||
if article is None:
|
||||
name = (data.get("article_name") or "").strip()
|
||||
if not name:
|
||||
raise ValueError("Artikelname fehlt")
|
||||
|
||||
article = db.scalar(
|
||||
select(Article).where(
|
||||
Article.list_id == lst.id,
|
||||
Article.name == name,
|
||||
Article.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
if article is None:
|
||||
article = Article(
|
||||
list_id=lst.id,
|
||||
name=name[:200],
|
||||
barcode=(data.get("barcode") or None),
|
||||
row_rev=rev,
|
||||
)
|
||||
db.add(article)
|
||||
db.flush()
|
||||
elif data.get("barcode") and not article.barcode:
|
||||
# Der Artikel war schon da, hatte aber noch keinen Code -
|
||||
# beim nächsten Scan wird er direkt gefunden.
|
||||
article.barcode = data["barcode"]
|
||||
article.row_rev = rev
|
||||
|
||||
market = _ref(db, Market, data.get("market_id"), lst.id, "Markt")
|
||||
category = _ref(db, Category, data.get("category_id"), lst.id, "Warengruppe")
|
||||
|
||||
item = ListItem(
|
||||
list_id=lst.id,
|
||||
article_id=article.id,
|
||||
market_id=(market.id if market else article.default_market_id),
|
||||
category_id=(category.id if category else article.default_category_id),
|
||||
count=int(data.get("count") or 1),
|
||||
# "quantity"/"unit" sind die alten Feldnamen. Ein Geraet, das
|
||||
# zum Zeitpunkt der Umstellung noch Operationen in der Outbox
|
||||
# hatte, schickt sie weiterhin - die landen jetzt als Gebinde,
|
||||
# was der bisherigen Bedeutung entspricht.
|
||||
pack_size=data.get("pack_size", data.get("quantity")),
|
||||
pack_unit=(data.get("pack_unit") or data.get("unit") or None),
|
||||
variant=(data.get("variant") or None),
|
||||
note=(data.get("note") or None),
|
||||
status="open",
|
||||
created_by=user_id,
|
||||
row_rev=rev,
|
||||
)
|
||||
db.add(item)
|
||||
db.flush()
|
||||
return item.id
|
||||
|
||||
if kind == "item.update":
|
||||
item = db.get(ListItem, data.get("item_id"))
|
||||
if item is None or item.list_id != lst.id or item.deleted_at is not None:
|
||||
raise ValueError("Eintrag nicht gefunden")
|
||||
|
||||
if data.get("clear_market"):
|
||||
item.market_id = None
|
||||
elif "market_id" in data:
|
||||
market = _ref(db, Market, data["market_id"], lst.id, "Markt")
|
||||
item.market_id = market.id if market else None
|
||||
|
||||
if data.get("clear_category"):
|
||||
item.category_id = None
|
||||
elif "category_id" in data:
|
||||
category = _ref(db, Category, data["category_id"], lst.id, "Warengruppe")
|
||||
item.category_id = category.id if category else None
|
||||
|
||||
# Alte Feldnamen aus einer Outbox von vor der Umstellung.
|
||||
if "quantity" in data and "pack_size" not in data:
|
||||
data["pack_size"] = data["quantity"]
|
||||
if "unit" in data and "pack_unit" not in data:
|
||||
data["pack_unit"] = data["unit"]
|
||||
|
||||
for field in ("count", "pack_size", "pack_unit", "variant", "note",
|
||||
"status", "price_cents"):
|
||||
if field not in data:
|
||||
continue
|
||||
# count und status sind nicht leerbar - ein Eintrag ohne
|
||||
# Stückzahl oder Status waere unvollstaendig.
|
||||
if field in ("count", "status") and data[field] is None:
|
||||
continue
|
||||
setattr(item, field, data[field])
|
||||
|
||||
item.row_rev = rev
|
||||
record_price(db, item)
|
||||
return item.id
|
||||
|
||||
if kind == "item.delete":
|
||||
item = db.get(ListItem, data.get("item_id"))
|
||||
if item is None or item.list_id != lst.id:
|
||||
raise ValueError("Eintrag nicht gefunden")
|
||||
if item.deleted_at is None:
|
||||
item.deleted_at = utcnow()
|
||||
item.row_rev = rev
|
||||
return item.id
|
||||
|
||||
if kind == "items.clear_bought":
|
||||
now = utcnow()
|
||||
for item in db.scalars(
|
||||
select(ListItem).where(
|
||||
ListItem.list_id == lst.id,
|
||||
ListItem.status == "bought",
|
||||
ListItem.deleted_at.is_(None),
|
||||
)
|
||||
).all():
|
||||
item.deleted_at = now
|
||||
item.row_rev = rev
|
||||
return None
|
||||
|
||||
raise ValueError(f"Unbekannte Operation: {kind}")
|
||||
|
||||
|
||||
@router.post("/api/lists/{list_id}/ops", response_model=OpBatchOut)
|
||||
def apply_ops(
|
||||
payload: OpBatchIn,
|
||||
lst: EditableList,
|
||||
db: DbSession,
|
||||
user: VerifiedUser,
|
||||
background: BackgroundTasks,
|
||||
):
|
||||
"""Verarbeitet einen Stapel Operationen.
|
||||
|
||||
Der gesamte Stapel bekommt EINEN Revisionsschritt. So sieht ein
|
||||
anderes Gerät eine geschlossene Änderung statt dutzender einzelner -
|
||||
und die Anzeige flackert nicht.
|
||||
|
||||
Fachlich fehlgeschlagene Operationen werden einzeln als abgelehnt
|
||||
zurückgemeldet, statt den ganzen Stapel scheitern zu lassen. Sonst
|
||||
würde ein Eintrag, den jemand anders inzwischen gelöscht hat, alle
|
||||
übrigen Änderungen des Geräts blockieren.
|
||||
"""
|
||||
if len(payload.ops) > MAX_OPS_PER_BATCH:
|
||||
raise HTTPException(
|
||||
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
f"Höchstens {MAX_OPS_PER_BATCH} Operationen je Aufruf.",
|
||||
)
|
||||
|
||||
results: list[OpResult] = []
|
||||
rev = lst.rev
|
||||
changed = False
|
||||
|
||||
for op in payload.ops:
|
||||
known = db.get(AppliedOp, op.op_id)
|
||||
if known is not None:
|
||||
# Schon verarbeitet - Quittung wiederholen, nichts tun.
|
||||
results.append(
|
||||
OpResult(op_id=op.op_id, status="duplicate", result_id=known.result_id)
|
||||
)
|
||||
continue
|
||||
|
||||
if not changed:
|
||||
rev = bump_rev(db, lst.id)
|
||||
changed = True
|
||||
|
||||
try:
|
||||
result_id = _apply(db, lst, user.id, op, rev)
|
||||
except ValueError as exc:
|
||||
db.rollback()
|
||||
# Nach dem Rollback ist der Revisionsschritt weg - beim
|
||||
# nächsten erfolgreichen Vorgang wird neu gezogen.
|
||||
changed = False
|
||||
results.append(OpResult(op_id=op.op_id, status="rejected", error=str(exc)))
|
||||
continue
|
||||
|
||||
db.add(
|
||||
AppliedOp(
|
||||
op_id=op.op_id, list_id=lst.id, user_id=user.id,
|
||||
kind=op.kind, result_id=result_id, rev=rev,
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
results.append(OpResult(op_id=op.op_id, status="applied", result_id=result_id))
|
||||
|
||||
db.commit()
|
||||
db.refresh(lst)
|
||||
|
||||
# Nur benachrichtigen, wenn wirklich etwas passiert ist. Ein Stapel,
|
||||
# der nur aus Wiederholungen bestand, aendert nichts.
|
||||
if any(r.status == "applied" for r in results):
|
||||
background.add_task(notify_list_changed, lst.id, user.id)
|
||||
|
||||
return OpBatchOut(rev=lst.rev, results=results)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Ereigniskanal
|
||||
# ==========================================================================
|
||||
|
||||
def _read_rev(list_id: str, user_id: str) -> int | None:
|
||||
"""Läuft im Threadpool: SQLAlchemy ist hier synchron konfiguriert,
|
||||
und ein blockierender Aufruf in der Ereignisschleife würde den
|
||||
gesamten Server anhalten."""
|
||||
with SessionLocal() as db:
|
||||
if db.get(ListMember, (list_id, user_id)) is None:
|
||||
return None
|
||||
lst = db.scalar(
|
||||
select(ShoppingList).where(
|
||||
ShoppingList.id == list_id, ShoppingList.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
return lst.rev if lst else None
|
||||
|
||||
|
||||
@router.get("/api/lists/{list_id}/events")
|
||||
async def events(list_id: str, request: Request, lst: ReadableList, user: VerifiedUser):
|
||||
"""Meldet Änderungen an der Liste als Server-Sent Events.
|
||||
|
||||
Ohne Redis wird der Revisionsstand abgefragt statt verteilt - bei
|
||||
dieser Größenordnung völlig ausreichend. Wichtig ist nur, dass die
|
||||
Abfrage im Threadpool läuft.
|
||||
|
||||
Achtung beim Reverse Proxy: `proxy_buffering off` ist Pflicht, sonst
|
||||
sammelt nginx die Ereignisse und liefert sie gebündelt aus.
|
||||
"""
|
||||
user_id = user.id
|
||||
last_rev = lst.rev
|
||||
|
||||
async def stream():
|
||||
nonlocal last_rev
|
||||
# Erstes Ereignis sofort, damit der Client seinen Stand abgleicht.
|
||||
yield f"event: rev\ndata: {json.dumps({'rev': last_rev})}\n\n"
|
||||
# Wiederverbindungsabstand für den Browser.
|
||||
yield "retry: 5000\n\n"
|
||||
|
||||
since_heartbeat = 0.0
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
|
||||
await asyncio.sleep(POLL_SECONDS)
|
||||
since_heartbeat += POLL_SECONDS
|
||||
|
||||
try:
|
||||
rev = await anyio.to_thread.run_sync(_read_rev, list_id, user_id)
|
||||
except Exception:
|
||||
log.exception("Ereigniskanal: Abfrage fehlgeschlagen")
|
||||
break
|
||||
|
||||
if rev is None:
|
||||
# Liste gelöscht oder Zugriff entzogen.
|
||||
yield "event: gone\ndata: {}\n\n"
|
||||
break
|
||||
|
||||
if rev != last_rev:
|
||||
last_rev = rev
|
||||
since_heartbeat = 0.0
|
||||
yield f"event: rev\ndata: {json.dumps({'rev': rev})}\n\n"
|
||||
elif since_heartbeat >= HEARTBEAT_SECONDS:
|
||||
since_heartbeat = 0.0
|
||||
# Kommentarzeile: hält die Verbindung durch Proxys offen,
|
||||
# löst beim Client aber kein Ereignis aus.
|
||||
yield ": ping\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
74
backend/app/schemas.py
Normal file
74
backend/app/schemas.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
|
||||
|
||||
class RegisterIn(BaseModel):
|
||||
email: EmailStr
|
||||
# Untergrenze nach BSI/NIST-Empfehlung: Laenge statt Zeichenklassen.
|
||||
password: str = Field(min_length=12, max_length=256)
|
||||
display_name: str | None = Field(default=None, max_length=80)
|
||||
|
||||
|
||||
class LoginIn(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(max_length=256)
|
||||
|
||||
|
||||
class PasswordResetRequestIn(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class PasswordResetIn(BaseModel):
|
||||
token: str = Field(max_length=128)
|
||||
password: str = Field(min_length=12, max_length=256)
|
||||
|
||||
|
||||
class PasswordChangeIn(BaseModel):
|
||||
current_password: str = Field(max_length=256)
|
||||
new_password: str = Field(min_length=12, max_length=256)
|
||||
|
||||
|
||||
class ProfileUpdateIn(BaseModel):
|
||||
display_name: str | None = Field(default=None, max_length=80)
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
email: EmailStr
|
||||
display_name: str | None
|
||||
is_admin: bool
|
||||
verified: bool
|
||||
must_change_password: bool
|
||||
created_at: datetime
|
||||
|
||||
@classmethod
|
||||
def of(cls, user) -> "UserOut":
|
||||
return cls(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
display_name=user.display_name,
|
||||
is_admin=user.is_admin,
|
||||
verified=user.verified_at is not None,
|
||||
must_change_password=user.must_change_password,
|
||||
created_at=user.created_at,
|
||||
)
|
||||
|
||||
|
||||
class MessageOut(BaseModel):
|
||||
detail: str
|
||||
|
||||
|
||||
class MailCheckOut(BaseModel):
|
||||
ok: bool
|
||||
detail: str
|
||||
host: str
|
||||
port: int
|
||||
security: str
|
||||
envelope_from: str
|
||||
|
||||
|
||||
class MailTestIn(BaseModel):
|
||||
to: EmailStr
|
||||
83
backend/app/schemas_admin.py
Normal file
83
backend/app/schemas_admin.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
class AdminUserOut(BaseModel):
|
||||
id: str
|
||||
email: EmailStr
|
||||
display_name: str | None
|
||||
is_admin: bool
|
||||
is_active: bool
|
||||
verified: bool
|
||||
# Nie angemeldet, oder seit wann nicht mehr
|
||||
last_seen_at: datetime | None
|
||||
deactivated_at: datetime | None
|
||||
created_at: datetime
|
||||
# Wie viele Listen gehoeren dem Konto, in wie vielen ist es Mitglied
|
||||
owned_lists: int
|
||||
memberships: int
|
||||
# Laeuft gerade eine Adressaenderung?
|
||||
pending_email: str | None = None
|
||||
|
||||
|
||||
class AdminStatsOut(BaseModel):
|
||||
total: int
|
||||
active: int
|
||||
inactive: int
|
||||
unverified: int
|
||||
admins: int
|
||||
# Konten, die beim naechsten Durchlauf deaktiviert bzw. geloescht
|
||||
# wuerden - damit die Automatik nicht ueberrascht.
|
||||
due_deactivation: int
|
||||
due_deletion: int
|
||||
|
||||
|
||||
class UserCreateIn(BaseModel):
|
||||
email: EmailStr
|
||||
display_name: str | None = Field(default=None, max_length=80)
|
||||
is_admin: bool = False
|
||||
|
||||
|
||||
class EmailChangeIn(BaseModel):
|
||||
new_email: EmailStr
|
||||
|
||||
|
||||
class DeleteUserIn(BaseModel):
|
||||
"""Wie mit den Listen des Kontos verfahren werden soll.
|
||||
|
||||
"refuse" bricht ab, wenn dem Konto Listen gehoeren - die
|
||||
Voreinstellung, damit nichts unbeabsichtigt verschwindet.
|
||||
"handover" uebertraegt geteilte Listen an das diensthaelteste andere
|
||||
Mitglied und loescht nur die, die niemand sonst nutzt.
|
||||
"""
|
||||
|
||||
lists: Literal["refuse", "handover"] = "refuse"
|
||||
# Sicherheitsabfrage: die Adresse muss zum Konto passen
|
||||
confirm_email: EmailStr
|
||||
|
||||
|
||||
class AdminSettingsOut(BaseModel):
|
||||
allow_self_registration: bool
|
||||
locked_by_env: bool
|
||||
# 0 = abgeschaltet
|
||||
auto_deactivate_months: int
|
||||
auto_delete_months: int
|
||||
|
||||
|
||||
class AdminSettingsIn(BaseModel):
|
||||
allow_self_registration: bool | None = None
|
||||
auto_deactivate_months: int | None = Field(default=None, ge=0, le=600)
|
||||
auto_delete_months: int | None = Field(default=None, ge=0, le=600)
|
||||
|
||||
|
||||
class WelcomeCompleteIn(BaseModel):
|
||||
token: str = Field(max_length=128)
|
||||
password: str = Field(min_length=12, max_length=256)
|
||||
display_name: str | None = Field(default=None, max_length=80)
|
||||
|
||||
|
||||
class WelcomePreviewOut(BaseModel):
|
||||
email: EmailStr
|
||||
display_name: str | None
|
||||
34
backend/app/schemas_push.py
Normal file
34
backend/app/schemas_push.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PushConfigOut(BaseModel):
|
||||
enabled: bool
|
||||
# Der oeffentliche VAPID-Schluessel, base64url ohne Auffuellzeichen.
|
||||
public_key: str | None = None
|
||||
throttle_hours: int
|
||||
|
||||
|
||||
class PushKeys(BaseModel):
|
||||
p256dh: str = Field(max_length=200)
|
||||
auth: str = Field(max_length=100)
|
||||
|
||||
|
||||
class PushSubscribeIn(BaseModel):
|
||||
# Vom Push-Dienst des Browserherstellers vergebene URL.
|
||||
endpoint: str = Field(min_length=10, max_length=500)
|
||||
keys: PushKeys
|
||||
# Grobe Geraetebezeichnung zur Unterscheidung mehrerer Anmeldungen.
|
||||
label: str | None = Field(default=None, max_length=80)
|
||||
|
||||
|
||||
class PushUnsubscribeIn(BaseModel):
|
||||
endpoint: str = Field(min_length=10, max_length=500)
|
||||
|
||||
|
||||
class PushSubscriptionOut(BaseModel):
|
||||
id: str
|
||||
label: str | None
|
||||
created_at: datetime
|
||||
last_success_at: datetime | None
|
||||
407
backend/app/schemas_shopping.py
Normal file
407
backend/app/schemas_shopping.py
Normal file
@@ -0,0 +1,407 @@
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
|
||||
Role = Literal["viewer", "editor", "owner"]
|
||||
ItemStatus = Literal["open", "bought", "deferred"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Listen
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class ListCreateIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
|
||||
|
||||
class ListUpdateIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
|
||||
|
||||
class ListOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
name: str
|
||||
owner_id: str
|
||||
rev: int
|
||||
role: Role
|
||||
may_share_public: bool
|
||||
member_count: int
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class MemberOut(BaseModel):
|
||||
user_id: str
|
||||
email: str
|
||||
display_name: str | None
|
||||
role: Role
|
||||
may_share_public: bool
|
||||
joined_at: datetime
|
||||
|
||||
|
||||
class MemberRoleIn(BaseModel):
|
||||
role: Role
|
||||
# Zusatzrecht "darf öffentliche Links erzeugen". Nicht gesetzt =
|
||||
# unverändert lassen.
|
||||
may_share_public: bool | None = None
|
||||
|
||||
|
||||
class TransferOwnershipIn(BaseModel):
|
||||
"""Der bisherige Eigentümer wird dabei zum Bearbeiter."""
|
||||
|
||||
user_id: str = Field(min_length=36, max_length=36)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Einladungen
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class InviteCreateIn(BaseModel):
|
||||
email: EmailStr
|
||||
role: Literal["viewer", "editor"] = "editor"
|
||||
|
||||
|
||||
class InviteOut(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
role: Role
|
||||
status: Literal["pending", "accepted", "revoked", "expired"]
|
||||
invited_by_name: str | None
|
||||
created_at: datetime
|
||||
last_sent_at: datetime
|
||||
send_count: int
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
class InvitePreviewOut(BaseModel):
|
||||
"""Was der Eingeladene vor dem Annehmen sieht."""
|
||||
|
||||
list_name: str
|
||||
invited_by_name: str | None
|
||||
role: Role
|
||||
email: str
|
||||
# Passt die Einladung zum angemeldeten Konto?
|
||||
matches_current_user: bool
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Märkte und Warengruppen
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class MarketIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
sort_order: int = Field(default=0, ge=-9999, le=9999)
|
||||
|
||||
|
||||
class MarketOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
name: str
|
||||
sort_order: int
|
||||
row_rev: int
|
||||
|
||||
|
||||
class CategoryIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
sort_order: int = Field(default=0, ge=-9999, le=9999)
|
||||
|
||||
|
||||
class CategoryOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
name: str
|
||||
sort_order: int
|
||||
row_rev: int
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Artikel
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class AttributeIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=80)
|
||||
value: str = Field(max_length=300)
|
||||
|
||||
|
||||
class AttributeOut(BaseModel):
|
||||
name: str
|
||||
value: str
|
||||
|
||||
|
||||
class ArticleIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=200)
|
||||
barcode: str | None = Field(default=None, max_length=64)
|
||||
note: str | None = Field(default=None, max_length=500)
|
||||
default_market_id: str | None = Field(default=None, max_length=36)
|
||||
default_category_id: str | None = Field(default=None, max_length=36)
|
||||
attributes: list[AttributeIn] = Field(default_factory=list, max_length=40)
|
||||
# Märkte, in denen es den Artikel gibt.
|
||||
available_market_ids: list[str] = Field(default_factory=list, max_length=60)
|
||||
|
||||
|
||||
class ArticleUpdateIn(BaseModel):
|
||||
"""Alle Felder optional - nur Gesetztes wird geändert.
|
||||
`attributes` und `available_market_ids` ersetzen jeweils die
|
||||
komplette bisherige Menge, sie werden nicht zusammengeführt."""
|
||||
|
||||
name: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
barcode: str | None = Field(default=None, max_length=64)
|
||||
note: str | None = Field(default=None, max_length=500)
|
||||
default_market_id: str | None = Field(default=None, max_length=36)
|
||||
default_category_id: str | None = Field(default=None, max_length=36)
|
||||
attributes: list[AttributeIn] | None = Field(default=None, max_length=40)
|
||||
available_market_ids: list[str] | None = Field(default=None, max_length=60)
|
||||
|
||||
|
||||
class ArticleOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
barcode: str | None
|
||||
note: str | None
|
||||
default_market_id: str | None
|
||||
default_category_id: str | None
|
||||
attributes: list[AttributeOut]
|
||||
available_market_ids: list[str]
|
||||
row_rev: int
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Listeneinträge
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class ItemCreateIn(BaseModel):
|
||||
"""Entweder `article_id` für einen bekannten Artikel, oder
|
||||
`article_name` - dann wird der Artikel bei Bedarf angelegt."""
|
||||
|
||||
article_id: str | None = Field(default=None, max_length=36)
|
||||
article_name: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
market_id: str | None = Field(default=None, max_length=36)
|
||||
category_id: str | None = Field(default=None, max_length=36)
|
||||
# Stückzahl - der Preis gilt für EIN Gebinde.
|
||||
count: int = Field(default=1, ge=1, le=999)
|
||||
# Gebinde, z. B. 500 ml
|
||||
pack_size: Decimal | None = Field(default=None, ge=0, le=999999)
|
||||
pack_unit: str | None = Field(default=None, max_length=32)
|
||||
variant: str | None = Field(default=None, max_length=200)
|
||||
note: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class ItemUpdateIn(BaseModel):
|
||||
market_id: str | None = Field(default=None, max_length=36)
|
||||
category_id: str | None = Field(default=None, max_length=36)
|
||||
count: int | None = Field(default=None, ge=1, le=999)
|
||||
pack_size: Decimal | None = Field(default=None, ge=0, le=999999)
|
||||
pack_unit: str | None = Field(default=None, max_length=32)
|
||||
variant: str | None = Field(default=None, max_length=200)
|
||||
note: str | None = Field(default=None, max_length=500)
|
||||
status: ItemStatus | None = None
|
||||
# Preis in Cent. Die Preishistorie kommt in Phase 7.
|
||||
price_cents: int | None = Field(default=None, ge=0, le=100_000_000)
|
||||
# Explizit auf null setzen statt "unverändert lassen":
|
||||
clear_market: bool = False
|
||||
clear_category: bool = False
|
||||
|
||||
|
||||
class ItemOut(BaseModel):
|
||||
id: str
|
||||
article_id: str
|
||||
article_name: str
|
||||
created_by: str | None
|
||||
created_by_name: str | None
|
||||
market_id: str | None
|
||||
category_id: str | None
|
||||
count: int
|
||||
pack_size: Decimal | None
|
||||
pack_unit: str | None
|
||||
variant: str | None
|
||||
note: str | None
|
||||
status: ItemStatus
|
||||
# Preis für EIN Gebinde
|
||||
price_cents: int | None
|
||||
# Preis mal Stückzahl - vom Server gerechnet, damit App, öffentliche
|
||||
# Ansicht und Ausdruck nicht auseinanderlaufen.
|
||||
total_cents: int | None
|
||||
row_rev: int
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ClearBoughtOut(BaseModel):
|
||||
removed: int
|
||||
rev: int
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Gruppierte Ansicht (Markt -> Warengruppe -> Artikel)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class ViewCategory(BaseModel):
|
||||
category_id: str | None
|
||||
category_name: str
|
||||
items: list[ItemOut]
|
||||
|
||||
|
||||
class ViewMarket(BaseModel):
|
||||
market_id: str | None
|
||||
market_name: str
|
||||
categories: list[ViewCategory]
|
||||
open_count: int
|
||||
total_cents: int
|
||||
|
||||
|
||||
class ListView(BaseModel):
|
||||
list_id: str
|
||||
list_name: str
|
||||
rev: int
|
||||
markets: list[ViewMarket]
|
||||
grand_total_cents: int
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Öffentliche Ansichtslinks
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class PublicShareIn(BaseModel):
|
||||
label: str | None = Field(default=None, max_length=120)
|
||||
# Pflicht: ein unbefristeter Link wäre ein dauerhaft offenes Fenster.
|
||||
expires_at: datetime
|
||||
allow_check: bool = True
|
||||
|
||||
|
||||
class PublicShareOut(BaseModel):
|
||||
id: str
|
||||
label: str | None
|
||||
allow_check: bool
|
||||
status: Literal["active", "expired", "revoked"]
|
||||
created_by_name: str | None
|
||||
created_at: datetime
|
||||
expires_at: datetime
|
||||
last_access_at: datetime | None
|
||||
access_count: int
|
||||
|
||||
|
||||
class PublicShareCreatedOut(BaseModel):
|
||||
"""Antwort beim Erzeugen - die einzige Gelegenheit, den Link zu sehen.
|
||||
|
||||
In der Datenbank steht nur der Hash; später lässt sich die Adresse
|
||||
nicht mehr anzeigen, nur ein neuer Link erzeugen.
|
||||
"""
|
||||
|
||||
share: PublicShareOut
|
||||
url: str
|
||||
|
||||
|
||||
class PublicViewOut(BaseModel):
|
||||
"""Was ein Empfänger ohne Konto sieht. Bewusst ohne Angaben dazu, wer
|
||||
was eingetragen hat, und ohne Mitgliederliste."""
|
||||
|
||||
list_name: str
|
||||
rev: int
|
||||
allow_check: bool
|
||||
expires_at: datetime
|
||||
markets: list["ViewMarket"]
|
||||
grand_total_cents: int
|
||||
|
||||
|
||||
class PublicToggleIn(BaseModel):
|
||||
status: Literal["open", "bought"]
|
||||
|
||||
|
||||
class ListSnapshot(BaseModel):
|
||||
"""Alles, was die Detailansicht braucht, in einer Antwort.
|
||||
|
||||
Vorher waren das vier Aufrufe. Bei jeder Synchronisation - also nach
|
||||
jedem Abhaken - summieren sich vier Rundreisen zu einer spürbaren
|
||||
Verzögerung, besonders im Mobilfunknetz.
|
||||
"""
|
||||
|
||||
list: "ListOut"
|
||||
view: "ListView"
|
||||
markets: list["MarketOut"]
|
||||
categories: list["CategoryOut"]
|
||||
|
||||
|
||||
class ProductLookupOut(BaseModel):
|
||||
"""Ergebnis der Strichcode-Abfrage.
|
||||
|
||||
`source` unterscheidet, woher die Angaben stammen - der eigene
|
||||
Artikelstamm ist verlässlicher als eine Fremdquelle, und der Nutzer
|
||||
soll das sehen können.
|
||||
"""
|
||||
|
||||
barcode: str
|
||||
found: bool
|
||||
source: Literal["catalog", "openfoodfacts", "none"]
|
||||
# Bei einem Treffer im eigenen Bestand: die Artikel-ID
|
||||
article_id: str | None = None
|
||||
name: str | None = None
|
||||
brand: str | None = None
|
||||
package: str | None = None
|
||||
# "6 x 33 cl" -> count 6, Gebinde 33 cl
|
||||
count: int | None = None
|
||||
pack_size: Decimal | None = None
|
||||
pack_unit: str | None = None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Preise
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class MarketPrice(BaseModel):
|
||||
market_id: str
|
||||
market_name: str
|
||||
latest_cents: int
|
||||
latest_at: datetime
|
||||
latest_pack_size: Decimal | None = None
|
||||
latest_pack_unit: str | None = None
|
||||
# Preis je Mengeneinheit in Zehntelcent - bei kleinen Mengen gingen
|
||||
# sonst zu viele Stellen verloren.
|
||||
unit_price_deci: int | None = None
|
||||
min_cents: int
|
||||
max_cents: int
|
||||
observations: int
|
||||
|
||||
|
||||
class PriceEntry(BaseModel):
|
||||
market_id: str
|
||||
market_name: str
|
||||
price_cents: int
|
||||
pack_size: Decimal | None
|
||||
pack_unit: str | None
|
||||
recorded_at: datetime
|
||||
|
||||
|
||||
class ArticlePrices(BaseModel):
|
||||
article_id: str
|
||||
article_name: str
|
||||
markets: list[MarketPrice]
|
||||
history: list[PriceEntry]
|
||||
|
||||
|
||||
class PriceOverviewRow(BaseModel):
|
||||
article_id: str
|
||||
article_name: str
|
||||
# market_id -> juengster Preis in Cent
|
||||
prices: dict[str, int]
|
||||
best_market_ids: list[str]
|
||||
best_cents: int
|
||||
# Abstand zwischen teuerstem und guenstigstem Markt
|
||||
spread_cents: int
|
||||
|
||||
|
||||
class PriceOverview(BaseModel):
|
||||
markets: list[MarketPrice]
|
||||
market_names: dict[str, str]
|
||||
rows: list[PriceOverviewRow]
|
||||
|
||||
|
||||
class PriceHint(BaseModel):
|
||||
article_id: str
|
||||
best_market_ids: list[str]
|
||||
best_cents: int
|
||||
spread_cents: int
|
||||
prices: dict[str, int]
|
||||
29
backend/app/schemas_sync.py
Normal file
29
backend/app/schemas_sync.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
OpKind = Literal["item.create", "item.update", "item.delete", "items.clear_bought"]
|
||||
|
||||
|
||||
class OpIn(BaseModel):
|
||||
# Vom Client vergeben, bevor gesendet wird. Grundlage der Idempotenz:
|
||||
# dieselbe op_id wird nie zweimal ausgefuehrt.
|
||||
op_id: str = Field(min_length=8, max_length=64)
|
||||
kind: OpKind
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class OpBatchIn(BaseModel):
|
||||
ops: list[OpIn] = Field(default_factory=list, max_length=200)
|
||||
|
||||
|
||||
class OpResult(BaseModel):
|
||||
op_id: str
|
||||
status: Literal["applied", "duplicate", "rejected"]
|
||||
result_id: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class OpBatchOut(BaseModel):
|
||||
rev: int
|
||||
results: list[OpResult]
|
||||
91
backend/app/security.py
Normal file
91
backend/app/security.py
Normal file
@@ -0,0 +1,91 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import InvalidHashError, VerifyMismatchError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import RateLimit
|
||||
|
||||
_hasher = PasswordHasher()
|
||||
|
||||
# Dummy-Hash gegen Timing-Angriffe: bei unbekanntem Konto wird trotzdem
|
||||
# eine Verifikation durchgefuehrt, damit die Antwortzeit gleich bleibt.
|
||||
_DUMMY_HASH = _hasher.hash("dummy-password-for-constant-time-comparison")
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return _hasher.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str | None) -> bool:
|
||||
try:
|
||||
_hasher.verify(password_hash or _DUMMY_HASH, password)
|
||||
except (VerifyMismatchError, InvalidHashError):
|
||||
return False
|
||||
return password_hash is not None
|
||||
|
||||
|
||||
def needs_rehash(password_hash: str) -> bool:
|
||||
try:
|
||||
return _hasher.check_needs_rehash(password_hash)
|
||||
except InvalidHashError:
|
||||
return True
|
||||
|
||||
|
||||
def new_token() -> str:
|
||||
"""256 Bit Zufall, URL-tauglich."""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
"""Token landen nur als Hash in der Datenbank. Kein Salt noetig -
|
||||
der Eingabewert ist bereits hochentropisch."""
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def tokens_equal(a: str, b: str) -> bool:
|
||||
return hmac.compare_digest(a, b)
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(UTC).replace(tzinfo=None)
|
||||
|
||||
|
||||
def normalize_email(email: str) -> str:
|
||||
return email.strip().lower()
|
||||
|
||||
|
||||
def check_rate_limit(
|
||||
db: Session, bucket: str, *, limit: int, window_minutes: int
|
||||
) -> bool:
|
||||
"""True = Anfrage erlaubt. Zaehlt hoch und gibt False zurueck,
|
||||
sobald das Limit im aktuellen Fenster erreicht ist."""
|
||||
now = utcnow()
|
||||
window = now.replace(second=0, microsecond=0)
|
||||
window = window - timedelta(minutes=window.minute % window_minutes)
|
||||
|
||||
row = db.get(RateLimit, (bucket[:160], window))
|
||||
if row is None:
|
||||
row = RateLimit(bucket=bucket[:160], window_start=window, count=1)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
return True
|
||||
|
||||
if row.count >= limit:
|
||||
return False
|
||||
|
||||
row.count += 1
|
||||
db.flush()
|
||||
return True
|
||||
|
||||
|
||||
def purge_rate_limits(db: Session, older_than_hours: int = 24) -> None:
|
||||
cutoff = utcnow() - timedelta(hours=older_than_hours)
|
||||
for row in db.scalars(
|
||||
select(RateLimit).where(RateLimit.window_start < cutoff)
|
||||
).all():
|
||||
db.delete(row)
|
||||
285
backend/app/users.py
Normal file
285
backend/app/users.py
Normal file
@@ -0,0 +1,285 @@
|
||||
"""Benutzerverwaltung: Anlegen, Adressänderung, Deaktivieren, Löschen.
|
||||
|
||||
Liegt außerhalb der Router, weil dieselben Vorgänge an zwei Stellen
|
||||
gebraucht werden: über die Administrationsoberfläche und über die
|
||||
automatische Bereinigung.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.deps import get_setting
|
||||
from app.models import (
|
||||
EmailChange,
|
||||
EmailToken,
|
||||
ListMember,
|
||||
ShoppingList,
|
||||
User,
|
||||
UserSession,
|
||||
)
|
||||
from app.security import hash_token, new_token, normalize_email, utcnow
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
WELCOME_DAYS = 14
|
||||
EMAIL_CHANGE_HOURS = 48
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Einstellungen zur automatischen Bereinigung
|
||||
# ==========================================================================
|
||||
|
||||
def months_setting(db: Session, key: str, fallback: int) -> int:
|
||||
"""Liest einen Monatswert aus der Laufzeitkonfiguration.
|
||||
|
||||
0 bedeutet ausdrücklich "abgeschaltet" - nicht "sofort". Ein
|
||||
Tippfehler soll nicht dazu führen, dass beim nächsten Durchlauf alle
|
||||
Konten verschwinden.
|
||||
"""
|
||||
raw = get_setting(db, key, "")
|
||||
if not raw:
|
||||
return fallback
|
||||
try:
|
||||
value = int(raw)
|
||||
except ValueError:
|
||||
log.warning("Einstellung %s ist keine Zahl (%r) - verwende %d", key, raw, fallback)
|
||||
return fallback
|
||||
return max(0, min(value, 600))
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Anlegen und Willkommensnachricht
|
||||
# ==========================================================================
|
||||
|
||||
def issue_token(db: Session, user: User, purpose: str, hours: int) -> str:
|
||||
"""Erzeugt ein Einmal-Token und entwertet ältere desselben Zwecks."""
|
||||
for old in db.scalars(
|
||||
select(EmailToken).where(
|
||||
EmailToken.user_id == user.id,
|
||||
EmailToken.purpose == purpose,
|
||||
EmailToken.used_at.is_(None),
|
||||
)
|
||||
).all():
|
||||
old.used_at = utcnow()
|
||||
|
||||
raw = new_token()
|
||||
db.add(
|
||||
EmailToken(
|
||||
token_hash=hash_token(raw),
|
||||
user_id=user.id,
|
||||
purpose=purpose,
|
||||
expires_at=utcnow() + timedelta(hours=hours),
|
||||
)
|
||||
)
|
||||
return raw
|
||||
|
||||
|
||||
def create_user(
|
||||
db: Session, *, email: str, display_name: str | None, is_admin: bool = False
|
||||
) -> tuple[User, str]:
|
||||
"""Legt ein Konto ohne Passwort an.
|
||||
|
||||
Das Passwort setzt die eingeladene Person selbst über den Link aus
|
||||
der Willkommensnachricht. Ein vom Administrator vergebenes Passwort
|
||||
wäre ihm bekannt - und würde per Mail verschickt.
|
||||
|
||||
@returns (Konto, Klartext-Token für die Willkommensnachricht)
|
||||
"""
|
||||
user = User(
|
||||
email=normalize_email(email),
|
||||
display_name=(display_name or None),
|
||||
# Platzhalter: Mit diesem Wert lässt sich nicht anmelden, weil er
|
||||
# kein gültiger Argon2-Hash ist und die Prüfung fehlschlägt.
|
||||
password_hash="!",
|
||||
is_admin=is_admin,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
token = issue_token(db, user, "welcome", hours=WELCOME_DAYS * 24)
|
||||
return user, token
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Adressänderung
|
||||
# ==========================================================================
|
||||
|
||||
def start_email_change(
|
||||
db: Session, user: User, new_email: str, requested_by: str | None
|
||||
) -> tuple[EmailChange, str, str | None]:
|
||||
"""Beginnt eine Adressänderung.
|
||||
|
||||
@returns (Vorgang, Token für die neue Adresse, Token für die alte
|
||||
Adresse oder None)
|
||||
|
||||
Bei Administratorkonten wird auch von der alten Adresse eine
|
||||
Bestätigung verlangt. Sonst könnte, wer Zugriff auf ein
|
||||
Administratorkonto erlangt, die Adresse auf eine eigene umstellen und
|
||||
sich dauerhaft einnisten - der rechtmäßige Inhaber verlöre den Weg
|
||||
zurück über "Passwort vergessen".
|
||||
"""
|
||||
target = normalize_email(new_email)
|
||||
|
||||
# Offene Vorgänge desselben Kontos zurückziehen.
|
||||
for pending in db.scalars(
|
||||
select(EmailChange).where(
|
||||
EmailChange.user_id == user.id,
|
||||
EmailChange.applied_at.is_(None),
|
||||
EmailChange.cancelled_at.is_(None),
|
||||
)
|
||||
).all():
|
||||
pending.cancelled_at = utcnow()
|
||||
|
||||
raw_new = new_token()
|
||||
raw_old = new_token() if user.is_admin else None
|
||||
|
||||
change = EmailChange(
|
||||
user_id=user.id,
|
||||
old_email=user.email,
|
||||
new_email=target,
|
||||
token_new_hash=hash_token(raw_new),
|
||||
token_old_hash=hash_token(raw_old) if raw_old else None,
|
||||
requires_old=user.is_admin,
|
||||
requested_by=requested_by,
|
||||
expires_at=utcnow() + timedelta(hours=EMAIL_CHANGE_HOURS),
|
||||
)
|
||||
db.add(change)
|
||||
db.flush()
|
||||
return change, raw_new, raw_old
|
||||
|
||||
|
||||
def apply_if_complete(db: Session, change: EmailChange) -> bool:
|
||||
"""Übernimmt die neue Adresse, sobald alle Bestätigungen vorliegen."""
|
||||
if change.applied_at or change.cancelled_at:
|
||||
return False
|
||||
if change.confirmed_new_at is None:
|
||||
return False
|
||||
if change.requires_old and change.confirmed_old_at is None:
|
||||
return False
|
||||
|
||||
user = db.get(User, change.user_id)
|
||||
if user is None:
|
||||
change.cancelled_at = utcnow()
|
||||
return False
|
||||
|
||||
# Zwischenzeitlich vergeben? Dann Vorgang verwerfen statt eine
|
||||
# Eindeutigkeitsverletzung zu produzieren.
|
||||
taken = db.scalar(
|
||||
select(User).where(User.email == change.new_email, User.id != user.id)
|
||||
)
|
||||
if taken is not None:
|
||||
change.cancelled_at = utcnow()
|
||||
return False
|
||||
|
||||
user.email = change.new_email
|
||||
# Die neue Adresse hat sich gerade selbst bestätigt.
|
||||
user.verified_at = utcnow()
|
||||
change.applied_at = utcnow()
|
||||
|
||||
# Alle Sitzungen beenden: Ein Adresswechsel ist ein guter Anlass,
|
||||
# sich überall neu anzumelden - besonders, wenn er nicht vom
|
||||
# Kontoinhaber ausging.
|
||||
for session in db.scalars(
|
||||
select(UserSession).where(UserSession.user_id == user.id)
|
||||
).all():
|
||||
db.delete(session)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Deaktivieren, Reaktivieren, Löschen
|
||||
# ==========================================================================
|
||||
|
||||
def deactivate(db: Session, user: User) -> None:
|
||||
"""Sperrt ein Konto und beendet alle Sitzungen."""
|
||||
if not user.is_active:
|
||||
return
|
||||
user.is_active = False
|
||||
user.deactivated_at = utcnow()
|
||||
for session in db.scalars(
|
||||
select(UserSession).where(UserSession.user_id == user.id)
|
||||
).all():
|
||||
db.delete(session)
|
||||
|
||||
|
||||
def reactivate(db: Session, user: User) -> None:
|
||||
user.is_active = True
|
||||
user.deactivated_at = None
|
||||
# Zähler zurücksetzen, sonst wäre das Konto beim nächsten Durchlauf
|
||||
# sofort wieder fällig.
|
||||
user.last_seen_at = utcnow()
|
||||
|
||||
|
||||
def dispose_lists(db: Session, user: User) -> tuple[int, int]:
|
||||
"""Regelt die Listen eines zu löschenden Kontos.
|
||||
|
||||
`shopping_list.owner_id` steht auf RESTRICT - ohne diese Vorarbeit
|
||||
schlüge das Löschen fehl.
|
||||
|
||||
Geteilte Listen gehen an das dienstälteste andere Mitglied über;
|
||||
Listen ohne weitere Mitglieder werden gelöscht. Eine geteilte Liste
|
||||
mitzulöschen würde anderen Leuten Daten wegnehmen, mit denen das
|
||||
ausscheidende Konto nichts mehr zu tun hat.
|
||||
|
||||
@returns (übertragen, gelöscht)
|
||||
"""
|
||||
transferred = 0
|
||||
removed = 0
|
||||
|
||||
lists = db.scalars(
|
||||
select(ShoppingList).where(ShoppingList.owner_id == user.id)
|
||||
).all()
|
||||
|
||||
for lst in lists:
|
||||
successor = db.scalars(
|
||||
select(ListMember)
|
||||
.where(ListMember.list_id == lst.id, ListMember.user_id != user.id)
|
||||
.order_by(ListMember.joined_at)
|
||||
).first()
|
||||
|
||||
if successor is None:
|
||||
db.delete(lst)
|
||||
removed += 1
|
||||
continue
|
||||
|
||||
successor.role = "owner"
|
||||
successor.may_share_public = True
|
||||
lst.owner_id = successor.user_id
|
||||
lst.rev += 1
|
||||
transferred += 1
|
||||
|
||||
# Eigene Mitgliedschaften enden ohnehin per CASCADE.
|
||||
db.flush()
|
||||
return transferred, removed
|
||||
|
||||
|
||||
def delete_user(db: Session, user: User) -> dict[str, int]:
|
||||
"""Löscht ein Konto samt Vorarbeit an seinen Listen."""
|
||||
transferred, removed = dispose_lists(db, user)
|
||||
db.delete(user)
|
||||
db.flush()
|
||||
return {"übertragene Listen": transferred, "gelöschte Listen": removed}
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Kennzahlen
|
||||
# ==========================================================================
|
||||
|
||||
def owned_list_counts(db: Session) -> dict[str, int]:
|
||||
rows = db.execute(
|
||||
select(ShoppingList.owner_id, func.count())
|
||||
.where(ShoppingList.deleted_at.is_(None))
|
||||
.group_by(ShoppingList.owner_id)
|
||||
).all()
|
||||
return {owner_id: count for owner_id, count in rows}
|
||||
|
||||
|
||||
def membership_counts(db: Session) -> dict[str, int]:
|
||||
rows = db.execute(
|
||||
select(ListMember.user_id, func.count()).group_by(ListMember.user_id)
|
||||
).all()
|
||||
return {user_id: count for user_id, count in rows}
|
||||
31
backend/app/wait_for_db.py
Normal file
31
backend/app/wait_for_db.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Wartet, bis MariaDB Verbindungen annimmt. Wird vom entrypoint.sh
|
||||
aufgerufen, bevor Alembic laeuft."""
|
||||
import sys
|
||||
import time
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db import engine
|
||||
|
||||
DEADLINE_SECONDS = 90
|
||||
|
||||
|
||||
def main() -> int:
|
||||
start = time.monotonic()
|
||||
while True:
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
print("[wait_for_db] Datenbank erreichbar.")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
if time.monotonic() - start > DEADLINE_SECONDS:
|
||||
print(f"[wait_for_db] Aufgegeben nach {DEADLINE_SECONDS}s: {exc}",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
print(f"[wait_for_db] noch nicht bereit: {type(exc).__name__}")
|
||||
time.sleep(2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
18
backend/entrypoint.sh
Normal file
18
backend/entrypoint.sh
Normal file
@@ -0,0 +1,18 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "[entrypoint] Warte auf Datenbank ${DB_HOST}:${DB_PORT} ..."
|
||||
python -m app.wait_for_db
|
||||
|
||||
echo "[entrypoint] Migrationen einspielen ..."
|
||||
alembic upgrade head
|
||||
|
||||
echo "[entrypoint] Starte API."
|
||||
# --proxy-headers: X-Forwarded-For auswerten, damit das Rate Limiting
|
||||
# die echte Client-IP sieht und nicht die des nginx-Containers.
|
||||
# --forwarded-allow-ips: nur diesen Absendern glauben. Der Wert ist
|
||||
# unkritisch, solange der api-Container KEINEN oeffentlichen Port hat.
|
||||
exec uvicorn app.main:app \
|
||||
--host 0.0.0.0 --port 8000 \
|
||||
--proxy-headers --forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-*}" \
|
||||
--reload
|
||||
17
backend/requirements.txt
Normal file
17
backend/requirements.txt
Normal file
@@ -0,0 +1,17 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
sqlalchemy==2.0.36
|
||||
pymysql==1.1.1
|
||||
alembic==1.14.0
|
||||
pydantic==2.10.4
|
||||
pydantic-settings==2.7.0
|
||||
email-validator==2.2.0
|
||||
argon2-cffi==23.1.0
|
||||
# Transitiv über Starlette, hier explizit wegen to_thread.run_sync
|
||||
anyio==4.7.0
|
||||
# Ausgehende Abfragen bei Open Food Facts
|
||||
httpx==0.28.1
|
||||
# Web Push (VAPID, aes128gcm). Bringt cryptography und http-ece mit.
|
||||
pywebpush==2.0.3
|
||||
# Druckansicht - mit autoescape, weil Artikelnamen freie Eingaben sind
|
||||
jinja2==3.1.5
|
||||
Reference in New Issue
Block a user