Erste Produktivversion
This commit is contained in:
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")
|
||||
Reference in New Issue
Block a user