Erste Produktivversion

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

View File

@@ -0,0 +1,102 @@
"""Phase 1: Benutzer, Sessions, Mail-Token, Einstellungen, Rate Limiting
Revision ID: 0001
Revises:
Create Date: 2026-08-07
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0001"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"user",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("email", sa.String(255), nullable=False),
sa.Column("display_name", sa.String(80), nullable=True),
sa.Column("password_hash", sa.String(255), nullable=False),
sa.Column("verified_at", sa.DateTime(), nullable=True),
sa.Column("is_admin", sa.Boolean(), nullable=False, server_default=sa.text("0")),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("1")),
sa.Column("created_at", sa.DateTime(), nullable=False,
server_default=sa.func.now()),
sa.UniqueConstraint("email", name="uq_user_email"),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_unicode_ci",
)
op.create_table(
"user_session",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("token_hash", sa.String(64), nullable=False),
sa.Column("user_id", sa.String(36), nullable=False),
sa.Column("csrf_token", sa.String(64), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False,
server_default=sa.func.now()),
sa.Column("last_seen_at", sa.DateTime(), nullable=False,
server_default=sa.func.now()),
sa.Column("expires_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
sa.UniqueConstraint("token_hash", name="uq_user_session_token"),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_unicode_ci",
)
op.create_index("ix_user_session_expires", "user_session", ["expires_at"])
op.create_table(
"email_token",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("token_hash", sa.String(64), nullable=False),
sa.Column("user_id", sa.String(36), nullable=False),
sa.Column("purpose", sa.String(32), nullable=False),
sa.Column("expires_at", sa.DateTime(), nullable=False),
sa.Column("used_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False,
server_default=sa.func.now()),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
sa.UniqueConstraint("token_hash", name="uq_email_token_token"),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_unicode_ci",
)
op.create_index("ix_email_token_user_purpose", "email_token", ["user_id", "purpose"])
op.create_table(
"setting",
sa.Column("key", sa.String(64), primary_key=True),
sa.Column("value", sa.String(255), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False,
server_default=sa.func.now()),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_unicode_ci",
)
op.create_table(
"rate_limit",
sa.Column("bucket", sa.String(160), primary_key=True),
sa.Column("window_start", sa.DateTime(), primary_key=True),
sa.Column("count", sa.Integer(), nullable=False, server_default=sa.text("0")),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_unicode_ci",
)
def downgrade() -> None:
op.drop_table("rate_limit")
op.drop_table("setting")
op.drop_index("ix_email_token_user_purpose", table_name="email_token")
op.drop_table("email_token")
op.drop_index("ix_user_session_expires", table_name="user_session")
op.drop_table("user_session")
op.drop_table("user")

View File

@@ -0,0 +1,153 @@
"""Phase 2: Listen, Mitgliedschaften, Märkte, Warengruppen, Artikel, Einträge
Revision ID: 0002
Revises: 0001
Create Date: 2026-08-07
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0002"
down_revision: str | None = "0001"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
MYSQL = {
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
}
def upgrade() -> None:
op.create_table(
"shopping_list",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("name", sa.String(120), nullable=False),
sa.Column("owner_id", sa.String(36), nullable=False),
sa.Column("rev", sa.BigInteger(), nullable=False, server_default=sa.text("0")),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["owner_id"], ["user.id"], ondelete="RESTRICT"),
**MYSQL,
)
op.create_table(
"list_member",
sa.Column("list_id", sa.String(36), primary_key=True),
sa.Column("user_id", sa.String(36), primary_key=True),
sa.Column("role", sa.String(16), nullable=False, server_default="editor"),
sa.Column("joined_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
**MYSQL,
)
op.create_index("ix_list_member_user", "list_member", ["user_id"])
op.create_table(
"market",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("list_id", sa.String(36), nullable=False),
sa.Column("name", sa.String(120), nullable=False),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("row_rev", sa.BigInteger(), nullable=False, server_default=sa.text("0")),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
sa.UniqueConstraint("list_id", "name", name="uq_market_list_name"),
**MYSQL,
)
op.create_index("ix_market_list_rev", "market", ["list_id", "row_rev"])
op.create_table(
"category",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("list_id", sa.String(36), nullable=False),
sa.Column("name", sa.String(120), nullable=False),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("row_rev", sa.BigInteger(), nullable=False, server_default=sa.text("0")),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
sa.UniqueConstraint("list_id", "name", name="uq_category_list_name"),
**MYSQL,
)
op.create_index("ix_category_list_rev", "category", ["list_id", "row_rev"])
op.create_table(
"article",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("list_id", sa.String(36), nullable=False),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("barcode", sa.String(64), nullable=True),
sa.Column("note", sa.String(500), nullable=True),
sa.Column("default_market_id", sa.String(36), nullable=True),
sa.Column("default_category_id", sa.String(36), nullable=True),
sa.Column("row_rev", sa.BigInteger(), nullable=False, server_default=sa.text("0")),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["default_market_id"], ["market.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["default_category_id"], ["category.id"], ondelete="SET NULL"),
sa.UniqueConstraint("list_id", "name", name="uq_article_list_name"),
**MYSQL,
)
op.create_index("ix_article_list_rev", "article", ["list_id", "row_rev"])
op.create_index("ix_article_barcode", "article", ["list_id", "barcode"])
op.create_table(
"article_attribute",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("article_id", sa.String(36), nullable=False),
sa.Column("attr_name", sa.String(80), nullable=False),
sa.Column("attr_value", sa.String(300), nullable=False),
sa.ForeignKeyConstraint(["article_id"], ["article.id"], ondelete="CASCADE"),
sa.UniqueConstraint("article_id", "attr_name", name="uq_article_attr"),
**MYSQL,
)
op.create_table(
"article_market",
sa.Column("article_id", sa.String(36), primary_key=True),
sa.Column("market_id", sa.String(36), primary_key=True),
sa.ForeignKeyConstraint(["article_id"], ["article.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["market_id"], ["market.id"], ondelete="CASCADE"),
**MYSQL,
)
op.create_table(
"list_item",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("list_id", sa.String(36), nullable=False),
sa.Column("article_id", sa.String(36), nullable=False),
sa.Column("market_id", sa.String(36), nullable=True),
sa.Column("category_id", sa.String(36), nullable=True),
sa.Column("quantity", sa.Numeric(10, 3), nullable=True),
sa.Column("unit", sa.String(32), nullable=True),
sa.Column("note", sa.String(500), nullable=True),
sa.Column("status", sa.String(16), nullable=False, server_default="open"),
sa.Column("price_cents", sa.Integer(), nullable=True),
sa.Column("row_rev", sa.BigInteger(), nullable=False, server_default=sa.text("0")),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["article_id"], ["article.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["market_id"], ["market.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["category_id"], ["category.id"], ondelete="SET NULL"),
**MYSQL,
)
op.create_index("ix_list_item_list_rev", "list_item", ["list_id", "row_rev"])
op.create_index("ix_list_item_list_status", "list_item", ["list_id", "status"])
def downgrade() -> None:
op.drop_table("list_item")
op.drop_table("article_market")
op.drop_table("article_attribute")
op.drop_table("article")
op.drop_table("category")
op.drop_table("market")
op.drop_index("ix_list_member_user", table_name="list_member")
op.drop_table("list_member")
op.drop_table("shopping_list")

View File

@@ -0,0 +1,31 @@
"""Phase 2b: Zwang zum Passwortwechsel beim initial angelegten Admin
Revision ID: 0003
Revises: 0002
Create Date: 2026-08-07
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0003"
down_revision: str | None = "0002"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"user",
sa.Column(
"must_change_password",
sa.Boolean(),
nullable=False,
server_default=sa.text("0"),
),
)
def downgrade() -> None:
op.drop_column("user", "must_change_password")

View File

@@ -0,0 +1,60 @@
"""Phase 5: Einladungen, Eigentümerwechsel, Ersteller je Eintrag
Revision ID: 0004
Revises: 0003
Create Date: 2026-08-08
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0004"
down_revision: str | None = "0003"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"list_item",
sa.Column("created_by", sa.String(36), nullable=True),
)
op.create_foreign_key(
"fk_list_item_created_by", "list_item", "user",
["created_by"], ["id"], ondelete="SET NULL",
)
op.create_table(
"list_invite",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("list_id", sa.String(36), nullable=False),
sa.Column("email", sa.String(255), nullable=False),
sa.Column("role", sa.String(16), nullable=False, server_default="editor"),
sa.Column("token_hash", sa.String(64), nullable=False),
sa.Column("invited_by", sa.String(36), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("last_sent_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("send_count", sa.Integer(), nullable=False, server_default=sa.text("1")),
sa.Column("expires_at", sa.DateTime(), nullable=False),
sa.Column("accepted_at", sa.DateTime(), nullable=True),
sa.Column("accepted_by", sa.String(36), nullable=True),
sa.Column("revoked_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["invited_by"], ["user.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["accepted_by"], ["user.id"], ondelete="SET NULL"),
sa.UniqueConstraint("token_hash", name="uq_list_invite_token"),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_unicode_ci",
)
op.create_index("ix_list_invite_list", "list_invite", ["list_id"])
op.create_index("ix_list_invite_email", "list_invite", ["email"])
def downgrade() -> None:
op.drop_index("ix_list_invite_email", table_name="list_invite")
op.drop_index("ix_list_invite_list", table_name="list_invite")
op.drop_table("list_invite")
op.drop_constraint("fk_list_item_created_by", "list_item", type_="foreignkey")
op.drop_column("list_item", "created_by")

View File

@@ -0,0 +1,56 @@
"""Phase 5b: Öffentliche Ansichtslinks und Zusatzrecht zum Teilen
Revision ID: 0005
Revises: 0004
Create Date: 2026-08-08
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0005"
down_revision: str | None = "0004"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"list_member",
sa.Column(
"may_share_public", sa.Boolean(), nullable=False,
server_default=sa.text("0"),
),
)
# Eigentuemer haben das Recht immer - der Vollstaendigkeit halber
# auch in der Spalte, damit Abfragen einheitlich bleiben.
op.execute("UPDATE list_member SET may_share_public = 1 WHERE role = 'owner'")
op.create_table(
"public_share",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("list_id", sa.String(36), nullable=False),
sa.Column("token_hash", sa.String(64), nullable=False),
sa.Column("label", sa.String(120), nullable=True),
sa.Column("allow_check", sa.Boolean(), nullable=False, server_default=sa.text("1")),
sa.Column("created_by", sa.String(36), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("expires_at", sa.DateTime(), nullable=False),
sa.Column("revoked_at", sa.DateTime(), nullable=True),
sa.Column("last_access_at", sa.DateTime(), nullable=True),
sa.Column("access_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["created_by"], ["user.id"], ondelete="SET NULL"),
sa.UniqueConstraint("token_hash", name="uq_public_share_token"),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_unicode_ci",
)
op.create_index("ix_public_share_list", "public_share", ["list_id"])
def downgrade() -> None:
op.drop_index("ix_public_share_list", table_name="public_share")
op.drop_table("public_share")
op.drop_column("list_member", "may_share_public")

View File

@@ -0,0 +1,39 @@
"""Phase 4: Idempotenz-Quittungen für die Outbox
Revision ID: 0006
Revises: 0005
Create Date: 2026-08-08
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0006"
down_revision: str | None = "0005"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"applied_op",
sa.Column("op_id", sa.String(64), primary_key=True),
sa.Column("list_id", sa.String(36), nullable=False),
sa.Column("user_id", sa.String(36), nullable=True),
sa.Column("kind", sa.String(32), nullable=False),
sa.Column("result_id", sa.String(36), nullable=True),
sa.Column("rev", sa.BigInteger(), nullable=False, server_default=sa.text("0")),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="SET NULL"),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_unicode_ci",
)
op.create_index("ix_applied_op_list", "applied_op", ["list_id", "created_at"])
def downgrade() -> None:
op.drop_index("ix_applied_op_list", table_name="applied_op")
op.drop_table("applied_op")

View File

@@ -0,0 +1,23 @@
"""Phase 6b: Eigenschaft je Listeneintrag
Revision ID: 0007
Revises: 0006
Create Date: 2026-08-08
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0007"
down_revision: str | None = "0006"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("list_item", sa.Column("variant", sa.String(200), nullable=True))
def downgrade() -> None:
op.drop_column("list_item", "variant")

View File

@@ -0,0 +1,37 @@
"""Phase 6c: Zwischenspeicher für Produktabfragen
Revision ID: 0008
Revises: 0007
Create Date: 2026-08-08
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0008"
down_revision: str | None = "0007"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"product_cache",
sa.Column("barcode", sa.String(64), primary_key=True),
sa.Column("found", sa.Boolean(), nullable=False, server_default=sa.text("0")),
sa.Column("name", sa.String(300), nullable=True),
sa.Column("brand", sa.String(200), nullable=True),
sa.Column("package", sa.String(120), nullable=True),
sa.Column("quantity", sa.Numeric(10, 3), nullable=True),
sa.Column("unit", sa.String(32), nullable=True),
sa.Column("source", sa.String(32), nullable=False, server_default="openfoodfacts"),
sa.Column("fetched_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_unicode_ci",
)
def downgrade() -> None:
op.drop_table("product_cache")

View File

@@ -0,0 +1,45 @@
"""Phase 7: Preisdatenbank
Revision ID: 0009
Revises: 0008
Create Date: 2026-08-08
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0009"
down_revision: str | None = "0008"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"price_point",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("list_id", sa.String(36), nullable=False),
sa.Column("article_id", sa.String(36), nullable=False),
sa.Column("market_id", sa.String(36), nullable=False),
sa.Column("price_cents", sa.Integer(), nullable=False),
sa.Column("quantity", sa.Numeric(10, 3), nullable=True),
sa.Column("unit", sa.String(32), nullable=True),
sa.Column("recorded_at", sa.DateTime(), nullable=False,
server_default=sa.func.now()),
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["article_id"], ["article.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["market_id"], ["market.id"], ondelete="CASCADE"),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_unicode_ci",
)
op.create_index("ix_price_article_market", "price_point",
["article_id", "market_id", "recorded_at"])
op.create_index("ix_price_list", "price_point", ["list_id", "recorded_at"])
def downgrade() -> None:
op.drop_index("ix_price_list", table_name="price_point")
op.drop_index("ix_price_article_market", table_name="price_point")
op.drop_table("price_point")

View File

@@ -0,0 +1,62 @@
"""Phase 7b: Stückzahl und Gebinde trennen
Bis hierher steckten beide in `quantity`/`unit`. Bei "500 ml" zu 2,99 EUR
rechnete die Summenbildung 500 × 2,99 = 1495 EUR.
Die vorhandenen Werte werden als Gebinde übernommen (aus "500 ml" wird
pack_size=500, pack_unit=ml) und die Stückzahl auf 1 gesetzt. Das ist die
sichere Richtung: Wo bisher tatsächlich eine Stückzahl gemeint war,
stimmt die Summe danach - sie wird nur nicht mehr vervielfacht.
Revision ID: 0010
Revises: 0009
Create Date: 2026-08-08
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0010"
down_revision: str | None = "0009"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"list_item",
sa.Column("count", sa.Integer(), nullable=False, server_default=sa.text("1")),
)
op.alter_column("list_item", "quantity",
new_column_name="pack_size", existing_type=sa.Numeric(10, 3))
op.alter_column("list_item", "unit",
new_column_name="pack_unit", existing_type=sa.String(32))
op.alter_column("price_point", "quantity",
new_column_name="pack_size", existing_type=sa.Numeric(10, 3))
op.alter_column("price_point", "unit",
new_column_name="pack_unit", existing_type=sa.String(32))
op.add_column("product_cache", sa.Column("count", sa.Integer(), nullable=True))
op.alter_column("product_cache", "quantity",
new_column_name="pack_size", existing_type=sa.Numeric(10, 3))
op.alter_column("product_cache", "unit",
new_column_name="pack_unit", existing_type=sa.String(32))
def downgrade() -> None:
op.alter_column("product_cache", "pack_unit",
new_column_name="unit", existing_type=sa.String(32))
op.alter_column("product_cache", "pack_size",
new_column_name="quantity", existing_type=sa.Numeric(10, 3))
op.drop_column("product_cache", "count")
op.alter_column("price_point", "pack_unit",
new_column_name="unit", existing_type=sa.String(32))
op.alter_column("price_point", "pack_size",
new_column_name="quantity", existing_type=sa.Numeric(10, 3))
op.alter_column("list_item", "pack_unit",
new_column_name="unit", existing_type=sa.String(32))
op.alter_column("list_item", "pack_size",
new_column_name="quantity", existing_type=sa.Numeric(10, 3))
op.drop_column("list_item", "count")

View File

@@ -0,0 +1,55 @@
"""Phase 8: Push-Benachrichtigungen
Revision ID: 0011
Revises: 0010
Create Date: 2026-08-08
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0011"
down_revision: str | None = "0010"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"push_subscription",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("user_id", sa.String(36), nullable=False),
sa.Column("endpoint", sa.String(500), nullable=False),
sa.Column("p256dh", sa.String(200), nullable=False),
sa.Column("auth", sa.String(100), nullable=False),
sa.Column("label", sa.String(80), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("last_success_at", sa.DateTime(), nullable=True),
sa.Column("failure_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
sa.UniqueConstraint("endpoint", name="uq_push_subscription_endpoint"),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_unicode_ci",
)
op.create_index("ix_push_subscription_user", "push_subscription", ["user_id"])
op.create_table(
"notify_state",
sa.Column("list_id", sa.String(36), primary_key=True),
sa.Column("user_id", sa.String(36), primary_key=True),
sa.Column("last_notified_at", sa.DateTime(), nullable=False,
server_default=sa.func.now()),
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_unicode_ci",
)
def downgrade() -> None:
op.drop_table("notify_state")
op.drop_index("ix_push_subscription_user", table_name="push_subscription")
op.drop_table("push_subscription")

View File

@@ -0,0 +1,59 @@
"""Phase 10: Benutzerverwaltung, Adressänderung, automatische Bereinigung
Revision ID: 0012
Revises: 0011
Create Date: 2026-08-08
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0012"
down_revision: str | None = "0011"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("user", sa.Column("last_seen_at", sa.DateTime(), nullable=True))
op.add_column("user", sa.Column("deactivated_at", sa.DateTime(), nullable=True))
# Bestandskonten nicht sofort der automatischen Deaktivierung
# aussetzen: Ohne Startwert waere last_seen_at NULL und damit
# scheinbar "nie benutzt". Das Erstellungsdatum ist die
# konservativere Annahme.
op.execute("UPDATE `user` SET last_seen_at = created_at WHERE last_seen_at IS NULL")
op.create_table(
"email_change",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("user_id", sa.String(36), nullable=False),
sa.Column("old_email", sa.String(255), nullable=False),
sa.Column("new_email", sa.String(255), nullable=False),
sa.Column("token_new_hash", sa.String(64), nullable=False),
sa.Column("token_old_hash", sa.String(64), nullable=True),
sa.Column("requires_old", sa.Boolean(), nullable=False, server_default=sa.text("0")),
sa.Column("confirmed_new_at", sa.DateTime(), nullable=True),
sa.Column("confirmed_old_at", sa.DateTime(), nullable=True),
sa.Column("applied_at", sa.DateTime(), nullable=True),
sa.Column("cancelled_at", sa.DateTime(), nullable=True),
sa.Column("requested_by", sa.String(36), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("expires_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["requested_by"], ["user.id"], ondelete="SET NULL"),
sa.UniqueConstraint("token_new_hash", name="uq_email_change_token_new"),
sa.UniqueConstraint("token_old_hash", name="uq_email_change_token_old"),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_unicode_ci",
)
op.create_index("ix_email_change_user", "email_change", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_email_change_user", table_name="email_change")
op.drop_table("email_change")
op.drop_column("user", "deactivated_at")
op.drop_column("user", "last_seen_at")