feat: initial commit

This commit is contained in:
2026-03-05 22:12:38 +01:00
commit bce762a783
380 changed files with 51955 additions and 0 deletions
Binary file not shown.
+62
View File
@@ -0,0 +1,62 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from app.database import Base
from app.config import settings
# Import all models to register them with Base
import app.models # noqa: F401
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+26
View File
@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,153 @@
"""initial schema
Revision ID: 001
Revises:
Create Date: 2026-03-01
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "001"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# users
op.create_table(
"users",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("email", sa.String(255), nullable=False, unique=True),
sa.Column("password_hash", sa.String(255), nullable=False),
sa.Column("full_name", sa.String(255), nullable=False),
sa.Column("role", sa.Enum("admin", "client", name="userrole"), nullable=False, server_default="client"),
sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"),
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()),
)
op.create_index("ix_users_email", "users", ["email"])
# templates
op.create_table(
"templates",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("category_key", sa.String(100), nullable=False, unique=True),
sa.Column("standard_fields", postgresql.JSONB, nullable=False, server_default="{}"),
sa.Column("component_schema", postgresql.JSONB, nullable=False, server_default="{}"),
sa.Column("description", sa.Text, nullable=True),
sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"),
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()),
)
op.create_index("ix_templates_category_key", "templates", ["category_key"])
# cad_files
op.create_table(
"cad_files",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("original_name", sa.String(500), nullable=False),
sa.Column("stored_path", sa.String(1000), nullable=False),
sa.Column("file_hash", sa.String(64), nullable=False, unique=True),
sa.Column("file_size", sa.BigInteger, nullable=True),
sa.Column("parsed_objects", postgresql.JSONB, nullable=True),
sa.Column("thumbnail_path", sa.String(1000), nullable=True),
sa.Column("gltf_path", sa.String(1000), nullable=True),
sa.Column(
"processing_status",
sa.Enum("pending", "processing", "completed", "failed", name="processingstatus"),
nullable=False,
server_default="pending",
),
sa.Column("error_message", sa.String(2000), nullable=True),
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()),
)
op.create_index("ix_cad_files_file_hash", "cad_files", ["file_hash"])
# orders
op.create_table(
"orders",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("order_number", sa.String(50), nullable=False, unique=True),
sa.Column("template_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("templates.id"), nullable=True),
sa.Column(
"status",
sa.Enum("draft", "submitted", "processing", "completed", "rejected", name="orderstatus"),
nullable=False,
server_default="draft",
),
sa.Column("created_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False),
sa.Column("source_excel", sa.String(1000), nullable=True),
sa.Column("notes", sa.Text, nullable=True),
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()),
)
op.create_index("ix_orders_order_number", "orders", ["order_number"])
# order_items
op.create_table(
"order_items",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("order_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("orders.id"), nullable=False),
sa.Column("row_index", sa.Integer, nullable=False),
sa.Column("ebene1", sa.String(500), nullable=True),
sa.Column("ebene2", sa.String(500), nullable=True),
sa.Column("baureihe", sa.String(500), nullable=True),
sa.Column("pim_id", sa.String(500), nullable=True),
sa.Column("produkt_baureihe", sa.String(500), nullable=True),
sa.Column("gewaehltes_produkt", sa.String(500), nullable=True),
sa.Column("name_cad_modell", sa.String(500), nullable=True),
sa.Column("gewuenschte_bildnummer", sa.String(500), nullable=True),
sa.Column("lagertyp", sa.String(500), nullable=True),
sa.Column("medias_rendering", sa.Boolean, nullable=True),
sa.Column("components", postgresql.JSONB, nullable=False, server_default="[]"),
sa.Column("cad_file_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("cad_files.id"), nullable=True),
sa.Column("thumbnail_path", sa.String(1000), nullable=True),
sa.Column(
"ai_validation_status",
sa.Enum("not_started", "pending", "completed", "failed", name="aivalidationstatus"),
nullable=False,
server_default="not_started",
),
sa.Column("ai_validation_result", postgresql.JSONB, nullable=True),
sa.Column(
"item_status",
sa.Enum("pending", "approved", "rejected", name="itemstatus"),
nullable=False,
server_default="pending",
),
sa.Column("notes", sa.Text, nullable=True),
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()),
)
# audit_log
op.create_table(
"audit_log",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
sa.Column("action", sa.String(100), nullable=False),
sa.Column("entity_type", sa.String(100), nullable=True),
sa.Column("entity_id", sa.String(255), nullable=True),
sa.Column("details", postgresql.JSONB, nullable=True),
sa.Column("timestamp", sa.DateTime, nullable=False, server_default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table("audit_log")
op.drop_table("order_items")
op.drop_table("orders")
op.drop_table("cad_files")
op.drop_table("templates")
op.drop_table("users")
op.execute("DROP TYPE IF EXISTS userrole")
op.execute("DROP TYPE IF EXISTS orderstatus")
op.execute("DROP TYPE IF EXISTS processingstatus")
op.execute("DROP TYPE IF EXISTS aivalidationstatus")
op.execute("DROP TYPE IF EXISTS itemstatus")
@@ -0,0 +1,33 @@
"""system settings table
Revision ID: 002
Revises: 001
Create Date: 2026-03-01
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "002"
down_revision: Union[str, None] = "001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"system_settings",
sa.Column("key", sa.String(100), primary_key=True),
sa.Column("value", sa.Text(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True, server_default=sa.func.now()),
)
# Insert defaults
op.execute(
"INSERT INTO system_settings (key, value, updated_at) VALUES "
"('thumbnail_renderer', 'pillow', NOW())"
)
def downgrade() -> None:
op.drop_table("system_settings")
@@ -0,0 +1,31 @@
"""blender render settings
Revision ID: 003
Revises: 002
Create Date: 2026-03-01
"""
from typing import Sequence, Union
from alembic import op
revision: str = "003"
down_revision: Union[str, None] = "002"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"INSERT INTO system_settings (key, value, updated_at) VALUES "
"('blender_engine', 'cycles', NOW()),"
"('blender_cycles_samples', '256', NOW()),"
"('blender_eevee_samples', '64', NOW()) "
"ON CONFLICT (key) DO NOTHING"
)
def downgrade() -> None:
op.execute(
"DELETE FROM system_settings WHERE key IN "
"('blender_engine', 'blender_cycles_samples', 'blender_eevee_samples')"
)
@@ -0,0 +1,28 @@
"""threejs render size setting
Revision ID: 004
Revises: 003
Create Date: 2026-03-01
"""
from typing import Sequence, Union
from alembic import op
revision: str = "004"
down_revision: Union[str, None] = "003"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"INSERT INTO system_settings (key, value, updated_at) VALUES "
"('threejs_render_size', '512', NOW()) "
"ON CONFLICT (key) DO NOTHING"
)
def downgrade() -> None:
op.execute(
"DELETE FROM system_settings WHERE key = 'threejs_render_size'"
)
@@ -0,0 +1,28 @@
"""set threejs_render_size default to 1024
Revision ID: 005
Revises: 004
Create Date: 2026-03-01
"""
from typing import Sequence, Union
from alembic import op
revision: str = "005"
down_revision: Union[str, None] = "004"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"UPDATE system_settings SET value = '1024', updated_at = NOW() "
"WHERE key = 'threejs_render_size' AND value = '512'"
)
def downgrade() -> None:
op.execute(
"UPDATE system_settings SET value = '512', updated_at = NOW() "
"WHERE key = 'threejs_render_size' AND value = '1024'"
)
@@ -0,0 +1,28 @@
"""thumbnail format setting (jpg | png)
Revision ID: 006
Revises: 005
Create Date: 2026-03-01
"""
from typing import Sequence, Union
from alembic import op
revision: str = "006"
down_revision: Union[str, None] = "005"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"INSERT INTO system_settings (key, value, updated_at) VALUES "
"('thumbnail_format', 'jpg', NOW()) "
"ON CONFLICT (key) DO NOTHING"
)
def downgrade() -> None:
op.execute(
"DELETE FROM system_settings WHERE key = 'thumbnail_format'"
)
+33
View File
@@ -0,0 +1,33 @@
"""materials table and cad_part_materials column
Revision ID: 007
Revises: 006
Create Date: 2026-03-01
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID, JSONB
revision: str = "007"
down_revision: Union[str, None] = "006"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"materials",
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("name", sa.String(200), nullable=False, unique=True),
sa.Column("description", sa.Text, nullable=True),
sa.Column("created_at", sa.DateTime, server_default=sa.text("NOW()"), nullable=False),
sa.Column("updated_at", sa.DateTime, server_default=sa.text("NOW()"), nullable=False),
)
op.execute("ALTER TABLE order_items ADD COLUMN IF NOT EXISTS cad_part_materials JSONB NOT NULL DEFAULT '[]'::jsonb")
def downgrade() -> None:
op.execute("ALTER TABLE order_items DROP COLUMN IF EXISTS cad_part_materials")
op.drop_table("materials")
@@ -0,0 +1,32 @@
"""Add created_by and source to materials
Revision ID: 008
Revises: 007
Create Date: 2026-03-01
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
revision: str = "008"
down_revision: Union[str, None] = "007"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"ALTER TABLE materials "
"ADD COLUMN IF NOT EXISTS created_by UUID REFERENCES users(id) ON DELETE SET NULL, "
"ADD COLUMN IF NOT EXISTS source VARCHAR(20) NOT NULL DEFAULT 'manual'"
)
def downgrade() -> None:
op.execute(
"ALTER TABLE materials "
"DROP COLUMN IF EXISTS created_by, "
"DROP COLUMN IF EXISTS source"
)
@@ -0,0 +1,28 @@
"""Add render_log JSONB column to cad_files
Revision ID: 009
Revises: 008
Create Date: 2026-03-01
"""
from typing import Sequence, Union
from alembic import op
revision: str = "009"
down_revision: Union[str, None] = "008"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"ALTER TABLE cad_files "
"ADD COLUMN IF NOT EXISTS render_log JSONB"
)
def downgrade() -> None:
op.execute(
"ALTER TABLE cad_files "
"DROP COLUMN IF EXISTS render_log"
)
@@ -0,0 +1,75 @@
"""KPI analytics and pricing tiers
Revision ID: 010
Revises: 009
Create Date: 2026-03-02
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "010"
down_revision: Union[str, None] = "009"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add project_manager to userrole enum — must be outside a transaction
op.execute("COMMIT")
op.execute("ALTER TYPE userrole ADD VALUE IF NOT EXISTS 'project_manager'")
op.execute("BEGIN")
# Lifecycle timestamps + estimated_price on orders
op.execute(
"ALTER TABLE orders "
"ADD COLUMN IF NOT EXISTS submitted_at TIMESTAMP WITHOUT TIME ZONE"
)
op.execute(
"ALTER TABLE orders "
"ADD COLUMN IF NOT EXISTS processing_started_at TIMESTAMP WITHOUT TIME ZONE"
)
op.execute(
"ALTER TABLE orders "
"ADD COLUMN IF NOT EXISTS completed_at TIMESTAMP WITHOUT TIME ZONE"
)
op.execute(
"ALTER TABLE orders "
"ADD COLUMN IF NOT EXISTS rejected_at TIMESTAMP WITHOUT TIME ZONE"
)
op.execute(
"ALTER TABLE orders "
"ADD COLUMN IF NOT EXISTS estimated_price NUMERIC(12, 2)"
)
# pricing_tiers table
op.execute(
"""
CREATE TABLE IF NOT EXISTS pricing_tiers (
id SERIAL PRIMARY KEY,
category_key VARCHAR(100) NOT NULL,
quality_level VARCHAR(50) NOT NULL DEFAULT 'Normal',
price_per_item NUMERIC(10, 2) NOT NULL,
description TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT uq_pricing_tier UNIQUE (category_key, quality_level)
)
"""
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_pricing_tiers_category_key "
"ON pricing_tiers (category_key)"
)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS pricing_tiers")
op.execute("ALTER TABLE orders DROP COLUMN IF EXISTS estimated_price")
op.execute("ALTER TABLE orders DROP COLUMN IF EXISTS rejected_at")
op.execute("ALTER TABLE orders DROP COLUMN IF EXISTS completed_at")
op.execute("ALTER TABLE orders DROP COLUMN IF EXISTS processing_started_at")
op.execute("ALTER TABLE orders DROP COLUMN IF EXISTS submitted_at")
# Note: removing enum values is not supported in PostgreSQL without full recreation
@@ -0,0 +1,101 @@
"""Product library — products, output_types, order_lines tables
Revision ID: 011
Revises: 010
Create Date: 2026-03-02
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "011"
down_revision: Union[str, None] = "010"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"""
CREATE TABLE IF NOT EXISTS products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
pim_id VARCHAR(500) UNIQUE NOT NULL,
name VARCHAR(500),
category_key VARCHAR(100),
ebene1 VARCHAR(500),
ebene2 VARCHAR(500),
baureihe VARCHAR(500),
produkt_baureihe VARCHAR(500),
lagertyp VARCHAR(500),
name_cad_modell VARCHAR(500),
components JSONB NOT NULL DEFAULT '[]',
cad_part_materials JSONB NOT NULL DEFAULT '[]',
cad_file_id UUID REFERENCES cad_files(id) ON DELETE SET NULL,
notes TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
source_excel VARCHAR(1000),
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW()
)
"""
)
op.execute("CREATE INDEX IF NOT EXISTS ix_products_category_key ON products (category_key)")
op.execute("CREATE INDEX IF NOT EXISTS ix_products_name_cad_modell ON products (name_cad_modell)")
op.execute(
"""
CREATE TABLE IF NOT EXISTS output_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(200) UNIQUE NOT NULL,
description TEXT,
renderer VARCHAR(50) NOT NULL DEFAULT 'threejs',
render_settings JSONB NOT NULL DEFAULT '{}',
output_format VARCHAR(20) NOT NULL DEFAULT 'png',
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW()
)
"""
)
op.execute(
"""
CREATE TABLE IF NOT EXISTS order_lines (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
product_id UUID NOT NULL REFERENCES products(id),
output_type_id UUID REFERENCES output_types(id),
gewuenschte_bildnummer VARCHAR(500),
item_status VARCHAR(20) NOT NULL DEFAULT 'pending',
render_status VARCHAR(20) NOT NULL DEFAULT 'pending',
result_path VARCHAR(1000),
render_log JSONB,
ai_validation_status VARCHAR(20) NOT NULL DEFAULT 'not_started',
ai_validation_result JSONB,
notes TEXT,
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW()
)
"""
)
op.execute("CREATE INDEX IF NOT EXISTS ix_order_lines_order_id ON order_lines (order_id)")
op.execute("CREATE INDEX IF NOT EXISTS ix_order_lines_product_id ON order_lines (product_id)")
# Partial unique indexes to handle NULL output_type_id correctly
op.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_order_lines_tracking "
"ON order_lines (order_id, product_id) "
"WHERE output_type_id IS NULL"
)
op.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_order_lines_render "
"ON order_lines (order_id, product_id, output_type_id) "
"WHERE output_type_id IS NOT NULL"
)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS order_lines")
op.execute("DROP TABLE IF EXISTS output_types")
op.execute("DROP TABLE IF EXISTS products")
@@ -0,0 +1,123 @@
"""Backfill products and order_lines from order_items
Revision ID: 012
Revises: 011
Create Date: 2026-03-02
"""
from typing import Sequence, Union
from alembic import op
revision: str = "012"
down_revision: Union[str, None] = "011"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1. Seed default output type
op.execute(
"""
INSERT INTO output_types (id, name, renderer, output_format, sort_order)
VALUES (gen_random_uuid(), '3D Thumbnail', 'threejs', 'png', 0)
ON CONFLICT (name) DO NOTHING
"""
)
# 2. Create products from distinct pim_id in order_items
# For each distinct pim_id, take fields from the most recently updated row
op.execute(
"""
INSERT INTO products (
pim_id, name, category_key, ebene1, ebene2, baureihe,
produkt_baureihe, lagertyp, name_cad_modell,
components, cad_part_materials, cad_file_id, source_excel
)
SELECT DISTINCT ON (oi.pim_id)
oi.pim_id,
oi.gewaehltes_produkt AS name,
t.category_key,
oi.ebene1,
oi.ebene2,
oi.baureihe,
oi.produkt_baureihe,
oi.lagertyp,
oi.name_cad_modell,
oi.components,
COALESCE(oi.cad_part_materials, '[]'::jsonb) AS cad_part_materials,
oi.cad_file_id,
o.source_excel
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
LEFT JOIN templates t ON t.id = o.template_id
WHERE oi.pim_id IS NOT NULL
AND oi.pim_id <> ''
ORDER BY oi.pim_id, oi.updated_at DESC
ON CONFLICT (pim_id) DO NOTHING
"""
)
# 3. Create order_lines from order_items where pim_id IS NOT NULL
# 3a. Rows with medias_rendering = true → link to '3D Thumbnail' output type
op.execute(
"""
INSERT INTO order_lines (
order_id, product_id, output_type_id,
gewuenschte_bildnummer, item_status, render_status,
ai_validation_status, ai_validation_result, notes
)
SELECT
oi.order_id,
p.id AS product_id,
ot.id AS output_type_id,
oi.gewuenschte_bildnummer,
oi.item_status::TEXT,
CASE
WHEN oi.cad_file_id IS NOT NULL THEN 'pending'
ELSE 'pending'
END AS render_status,
oi.ai_validation_status::TEXT,
oi.ai_validation_result,
oi.notes
FROM order_items oi
JOIN products p ON p.pim_id = oi.pim_id
JOIN output_types ot ON ot.name = '3D Thumbnail'
WHERE oi.pim_id IS NOT NULL
AND oi.pim_id <> ''
AND oi.medias_rendering = TRUE
ON CONFLICT DO NOTHING
"""
)
# 3b. Rows with medias_rendering = false → tracking only (no output_type_id)
op.execute(
"""
INSERT INTO order_lines (
order_id, product_id, output_type_id,
gewuenschte_bildnummer, item_status, render_status,
ai_validation_status, ai_validation_result, notes
)
SELECT
oi.order_id,
p.id AS product_id,
NULL AS output_type_id,
oi.gewuenschte_bildnummer,
oi.item_status::TEXT,
'pending' AS render_status,
oi.ai_validation_status::TEXT,
oi.ai_validation_result,
oi.notes
FROM order_items oi
JOIN products p ON p.pim_id = oi.pim_id
WHERE oi.pim_id IS NOT NULL
AND oi.pim_id <> ''
AND (oi.medias_rendering IS NULL OR oi.medias_rendering = FALSE)
ON CONFLICT DO NOTHING
"""
)
def downgrade() -> None:
op.execute("DELETE FROM order_lines")
op.execute("DELETE FROM products")
op.execute("DELETE FROM output_types WHERE name = '3D Thumbnail'")
@@ -0,0 +1,44 @@
"""Add gewuenschte_bildnummer and medias_rendering to products
Revision ID: 013
Revises: 012
Create Date: 2026-03-02
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "013"
down_revision: Union[str, None] = "012"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("products", sa.Column("gewuenschte_bildnummer", sa.String(500), nullable=True))
op.add_column("products", sa.Column("medias_rendering", sa.Boolean(), nullable=True))
# Backfill from order_items where available
op.execute(
"""
UPDATE products p
SET gewuenschte_bildnummer = sub.gewuenschte_bildnummer,
medias_rendering = sub.medias_rendering
FROM (
SELECT DISTINCT ON (oi.pim_id)
oi.pim_id,
oi.gewuenschte_bildnummer,
oi.medias_rendering
FROM order_items oi
WHERE oi.pim_id IS NOT NULL AND oi.pim_id <> ''
ORDER BY oi.pim_id, oi.updated_at DESC
) sub
WHERE p.pim_id = sub.pim_id
"""
)
def downgrade() -> None:
op.drop_column("products", "medias_rendering")
op.drop_column("products", "gewuenschte_bildnummer")
@@ -0,0 +1,27 @@
"""Add compatible_categories to output_types
Revision ID: 014
Revises: 013
Create Date: 2026-03-02
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
revision: str = "014"
down_revision: Union[str, None] = "013"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"output_types",
sa.Column("compatible_categories", JSONB, server_default="[]", nullable=False),
)
def downgrade() -> None:
op.drop_column("output_types", "compatible_categories")
@@ -0,0 +1,65 @@
"""Add Flamenco render backend support
Revision ID: 015
Revises: 014
Create Date: 2026-03-02
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "015"
down_revision: Union[str, None] = "014"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# output_types: render_backend + is_animation
op.add_column(
"output_types",
sa.Column("render_backend", sa.String(20), server_default="auto", nullable=False),
)
op.add_column(
"output_types",
sa.Column("is_animation", sa.Boolean(), server_default="false", nullable=False),
)
# order_lines: flamenco tracking columns
op.add_column(
"order_lines",
sa.Column("flamenco_job_id", sa.String(100), nullable=True),
)
op.add_column(
"order_lines",
sa.Column("render_backend_used", sa.String(20), nullable=True),
)
op.add_column(
"order_lines",
sa.Column("render_started_at", sa.DateTime(), nullable=True),
)
op.add_column(
"order_lines",
sa.Column("render_completed_at", sa.DateTime(), nullable=True),
)
# Seed system settings for Flamenco
op.execute(
"INSERT INTO system_settings (key, value) VALUES "
"('render_backend', 'celery'), "
"('flamenco_manager_url', 'http://flamenco-manager:8080'), "
"('flamenco_worker_count', '1') "
"ON CONFLICT (key) DO NOTHING"
)
def downgrade() -> None:
op.drop_column("order_lines", "render_completed_at")
op.drop_column("order_lines", "render_started_at")
op.drop_column("order_lines", "render_backend_used")
op.drop_column("order_lines", "flamenco_job_id")
op.drop_column("output_types", "is_animation")
op.drop_column("output_types", "render_backend")
op.execute("DELETE FROM system_settings WHERE key IN ('render_backend', 'flamenco_manager_url', 'flamenco_worker_count')")
@@ -0,0 +1,52 @@
"""Pricing enhancements: OutputType→PricingTier link, per-line unit_price, transparent_bg, default tier
Revision ID: 016
Revises: 015
Create Date: 2026-03-02
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "016"
down_revision: Union[str, None] = "015"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# OutputType → PricingTier link
op.add_column("output_types", sa.Column("pricing_tier_id", sa.Integer(), nullable=True))
op.create_foreign_key(
"fk_output_types_pricing_tier_id",
"output_types",
"pricing_tiers",
["pricing_tier_id"],
["id"],
ondelete="SET NULL",
)
op.create_index("ix_output_types_pricing_tier_id", "output_types", ["pricing_tier_id"])
# Transparent background option for Blender PNG renders
op.add_column("output_types", sa.Column(
"transparent_bg", sa.Boolean(), nullable=False, server_default="false",
))
# Per-line price snapshot
op.add_column("order_lines", sa.Column("unit_price", sa.Numeric(10, 2), nullable=True))
# Seed global default tier (idempotent via ON CONFLICT)
op.execute("""
INSERT INTO pricing_tiers (category_key, quality_level, price_per_item, description, is_active, created_at, updated_at)
VALUES ('default', 'Normal', 25.00, 'Global fallback price', true, NOW(), NOW())
ON CONFLICT ON CONSTRAINT uq_pricing_tier DO NOTHING
""")
def downgrade() -> None:
op.drop_column("order_lines", "unit_price")
op.drop_column("output_types", "transparent_bg")
op.drop_index("ix_output_types_pricing_tier_id", table_name="output_types")
op.drop_constraint("fk_output_types_pricing_tier_id", "output_types", type_="foreignkey")
op.drop_column("output_types", "pricing_tier_id")
@@ -0,0 +1,44 @@
"""Fix stale order_lines.item_status: auto-approve lines for non-draft orders
Revision ID: 017
Revises: 016
Create Date: 2026-03-02
"""
from typing import Sequence, Union
from alembic import op
revision: str = "017"
down_revision: Union[str, None] = "016"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Order lines belonging to submitted/processing/completed orders should
# be "approved", not stuck at "pending". The new Product Library workflow
# has no per-item approval step — submission implies approval.
op.execute("""
UPDATE order_lines
SET item_status = 'approved'
WHERE item_status = 'pending'
AND order_id IN (
SELECT id FROM orders
WHERE status IN ('submitted', 'processing', 'completed')
)
""")
# Lines belonging to rejected orders should be "rejected".
op.execute("""
UPDATE order_lines
SET item_status = 'rejected'
WHERE item_status = 'pending'
AND order_id IN (
SELECT id FROM orders WHERE status = 'rejected'
)
""")
def downgrade() -> None:
# Cannot reliably revert — the original values were all "pending" anyway.
pass
@@ -0,0 +1,56 @@
"""Render templates — .blend file templates per category/output type
Revision ID: 018
Revises: 017
Create Date: 2026-03-02
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
# revision identifiers, used by Alembic.
revision: str = "018"
down_revision: Union[str, None] = "017"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"render_templates",
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("name", sa.String(300), nullable=False),
sa.Column("category_key", sa.String(100), nullable=True),
sa.Column("output_type_id", UUID(as_uuid=True), sa.ForeignKey("output_types.id", ondelete="SET NULL"), nullable=True),
sa.Column("blend_file_path", sa.Text, nullable=False),
sa.Column("original_filename", sa.String(500), nullable=False),
sa.Column("target_collection", sa.String(200), server_default="Product", nullable=False),
sa.Column("material_replace_enabled", sa.Boolean, server_default="false", nullable=False),
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
sa.Column("created_at", sa.DateTime, server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime, server_default=sa.text("now()"), nullable=False),
)
# Unique constraint: one active template per (category_key, output_type_id) combo
op.create_index(
"ix_render_templates_active_unique",
"render_templates",
["category_key", "output_type_id"],
unique=True,
postgresql_where=sa.text("is_active = true"),
)
# Seed material_library_path setting
op.execute(
"INSERT INTO system_settings (key, value, updated_at) "
"VALUES ('material_library_path', '', now()) "
"ON CONFLICT (key) DO NOTHING"
)
def downgrade() -> None:
op.drop_index("ix_render_templates_active_unique", table_name="render_templates")
op.drop_table("render_templates")
op.execute("DELETE FROM system_settings WHERE key = 'material_library_path'")
@@ -0,0 +1,38 @@
"""Schaeffler standard materials — add schaeffler_code column and seed 35 materials
Revision ID: 019
Revises: 018
Create Date: 2026-03-02
"""
from alembic import op
import sqlalchemy as sa
import uuid
from datetime import datetime
revision = "019"
down_revision = "018"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("materials", sa.Column("schaeffler_code", sa.Integer(), nullable=True))
from app.data.schaeffler_materials import SCHAEFFLER_MATERIALS
conn = op.get_bind()
now = datetime.utcnow().isoformat()
for mat in SCHAEFFLER_MATERIALS:
desc = mat["description"].replace("'", "''")
name = mat["name"].replace("'", "''")
conn.execute(sa.text(
f"INSERT INTO materials (id, name, description, source, schaeffler_code, created_at, updated_at) "
f"VALUES ('{uuid.uuid4()}', '{name}', '{desc}', '{mat['source']}', "
f"{mat['schaeffler_code']}, '{now}', '{now}') "
f"ON CONFLICT (name) DO NOTHING"
))
def downgrade() -> None:
op.execute("DELETE FROM materials WHERE source = 'schaeffler_standard'")
op.drop_column("materials", "schaeffler_code")
@@ -0,0 +1,99 @@
"""Material aliases — substitution/alias system for material name resolution
Revision ID: 020
Revises: 019
Create Date: 2026-03-02
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
import uuid
from datetime import datetime
revision = "020"
down_revision = "019"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create material_aliases table
op.create_table(
"material_aliases",
sa.Column("id", UUID(as_uuid=True), primary_key=True, default=uuid.uuid4),
sa.Column(
"material_id",
UUID(as_uuid=True),
sa.ForeignKey("materials.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("alias", sa.String(300), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
)
# Case-insensitive unique index on alias
op.create_index(
"uq_material_aliases_alias_lower",
"material_aliases",
[sa.text("lower(alias)")],
unique=True,
)
# Index on material_id for FK lookups
op.create_index(
"ix_material_aliases_material_id",
"material_aliases",
["material_id"],
)
# Seed aliases from naming_scheme.xlsx Materialmapping data
_seed_aliases()
def _seed_aliases() -> None:
from app.data.material_alias_seeds import MATERIAL_ALIAS_SEEDS
conn = op.get_bind()
for entry in MATERIAL_ALIAS_SEEDS:
material_name = entry["material_name"]
# Look up material by name
result = conn.execute(
sa.text("SELECT id FROM materials WHERE name = :name"),
{"name": material_name},
)
row = result.fetchone()
if not row:
# Material not seeded yet, skip
continue
material_id = row[0]
for alias_str in entry["aliases"]:
# Skip if alias already exists (case-insensitive)
existing = conn.execute(
sa.text("SELECT id FROM material_aliases WHERE lower(alias) = lower(:alias)"),
{"alias": alias_str},
)
if existing.fetchone():
continue
conn.execute(
sa.text(
"INSERT INTO material_aliases (id, material_id, alias, created_at) "
"VALUES (:id, :material_id, :alias, :created_at)"
),
{
"id": str(uuid.uuid4()),
"material_id": str(material_id),
"alias": alias_str,
"created_at": datetime.utcnow(),
},
)
def downgrade() -> None:
op.drop_index("ix_material_aliases_material_id", table_name="material_aliases")
op.drop_index("uq_material_aliases_alias_lower", table_name="material_aliases")
op.drop_table("material_aliases")
@@ -0,0 +1,62 @@
"""Notification center — add target_user_id, read_at, notification to audit_log
Revision ID: 021
Revises: 020
Create Date: 2026-03-03
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
revision = "021"
down_revision = "020"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"audit_log",
sa.Column(
"target_user_id",
UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
)
op.add_column(
"audit_log",
sa.Column("read_at", sa.DateTime(), nullable=True),
)
op.add_column(
"audit_log",
sa.Column(
"notification",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
)
# Composite index for user notification queries
op.create_index(
"ix_audit_log_target_notification",
"audit_log",
["target_user_id", "notification", "read_at"],
)
# Partial index for listing recent notifications
op.create_index(
"ix_audit_log_notification_ts",
"audit_log",
["notification", "timestamp"],
postgresql_where=sa.text("notification = true"),
)
def downgrade() -> None:
op.drop_index("ix_audit_log_notification_ts", table_name="audit_log")
op.drop_index("ix_audit_log_target_notification", table_name="audit_log")
op.drop_column("audit_log", "notification")
op.drop_column("audit_log", "read_at")
op.drop_column("audit_log", "target_user_id")
@@ -0,0 +1,87 @@
"""Product variants — per-product material variant support
Revision ID: 022
Revises: 021
Create Date: 2026-03-03
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID, JSONB
revision = "022"
down_revision = "021"
branch_labels = None
depends_on = None
def upgrade() -> None:
# --- New table: product_variants ---
op.create_table(
"product_variants",
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("product_id", UUID(as_uuid=True), sa.ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("name", sa.String(500), nullable=False),
sa.Column("gewuenschte_bildnummer", sa.String(500), nullable=True),
sa.Column("components", JSONB, nullable=False, server_default="[]"),
sa.Column("is_default", sa.Boolean, nullable=False, server_default="false"),
sa.Column("source_excel", sa.String(1000), nullable=True),
sa.Column("created_at", sa.DateTime, server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime, server_default=sa.func.now(), nullable=False),
)
# Unique constraint: (product_id, lower(name))
op.create_index(
"uq_product_variants_product_name",
"product_variants",
[sa.text("product_id"), sa.text("lower(name)")],
unique=True,
)
# --- Alter products ---
op.add_column("products", sa.Column("arbeitspaket", sa.String(500), nullable=True))
# Drop existing unique constraint on pim_id — PIM-ID is a class-level
# identifier shared by many products so it must NOT be unique.
op.drop_constraint("products_pim_id_key", "products", type_="unique")
op.create_index(
"uq_products_produkt_baureihe",
"products",
[sa.text("lower(produkt_baureihe)")],
unique=True,
postgresql_where=sa.text("produkt_baureihe IS NOT NULL AND is_active = true"),
)
# --- Alter order_lines ---
op.add_column(
"order_lines",
sa.Column(
"variant_id",
UUID(as_uuid=True),
sa.ForeignKey("product_variants.id", ondelete="SET NULL"),
nullable=True,
),
)
# --- Backfill: create default variants for existing products ---
op.execute("""
INSERT INTO product_variants (id, product_id, name, components, is_default, source_excel, created_at, updated_at)
SELECT
gen_random_uuid(),
p.id,
COALESCE(p.name, p.pim_id),
COALESCE(p.components, '[]'::jsonb),
true,
p.source_excel,
NOW(),
NOW()
FROM products p
WHERE p.name IS NOT NULL OR p.pim_id IS NOT NULL
""")
def downgrade() -> None:
op.drop_column("order_lines", "variant_id")
op.drop_index("uq_products_produkt_baureihe", "products")
op.create_unique_constraint("products_pim_id_key", "products", ["pim_id"])
op.drop_column("products", "arbeitspaket")
op.drop_index("uq_product_variants_product_name", "product_variants")
op.drop_table("product_variants")
@@ -0,0 +1,55 @@
"""Fix order_line unique constraints to include variant_id
The old constraints (order_id, product_id) caused 409 errors when
multiple variants of the same product were added to the same order.
New constraints use COALESCE(variant_id, nil_uuid) so different
variants of the same product can coexist.
Revision ID: 023
Revises: 022
Create Date: 2026-03-03
"""
from alembic import op
revision = "023"
down_revision = "022"
branch_labels = None
depends_on = None
NIL_UUID = "00000000-0000-0000-0000-000000000000"
def upgrade() -> None:
# Drop old constraints
op.execute("DROP INDEX IF EXISTS uq_order_lines_tracking")
op.execute("DROP INDEX IF EXISTS uq_order_lines_render")
# Recreate with variant_id included (COALESCE handles NULLs)
op.execute(
"CREATE UNIQUE INDEX uq_order_lines_tracking "
f"ON order_lines (order_id, product_id, COALESCE(variant_id, '{NIL_UUID}'::uuid)) "
"WHERE output_type_id IS NULL"
)
op.execute(
"CREATE UNIQUE INDEX uq_order_lines_render "
"ON order_lines (order_id, product_id, output_type_id, "
f"COALESCE(variant_id, '{NIL_UUID}'::uuid)) "
"WHERE output_type_id IS NOT NULL"
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS uq_order_lines_tracking")
op.execute("DROP INDEX IF EXISTS uq_order_lines_render")
# Restore original constraints without variant_id
op.execute(
"CREATE UNIQUE INDEX uq_order_lines_tracking "
"ON order_lines (order_id, product_id) "
"WHERE output_type_id IS NULL"
)
op.execute(
"CREATE UNIQUE INDEX uq_order_lines_render "
"ON order_lines (order_id, product_id, output_type_id) "
"WHERE output_type_id IS NOT NULL"
)
@@ -0,0 +1,33 @@
"""Add lighting_only column to render_templates
When lighting_only=True the render script uses the template's World/HDRI for
lighting but always computes an auto-camera for product framing. This is
useful for HDR-only templates that don't define a fixed camera angle.
Revision ID: 024
Revises: 023
Create Date: 2026-03-03
"""
from alembic import op
import sqlalchemy as sa
revision = '024'
down_revision = '023'
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
'render_templates',
sa.Column(
'lighting_only',
sa.Boolean(),
nullable=False,
server_default='false',
),
)
def downgrade():
op.drop_column('render_templates', 'lighting_only')
@@ -0,0 +1,24 @@
"""Add cycles_device column to output_types
Revision ID: 025
Revises: 024
Create Date: 2026-03-03
"""
from alembic import op
import sqlalchemy as sa
revision = '025'
down_revision = '024'
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
'output_types',
sa.Column('cycles_device', sa.String(10), nullable=True),
)
def downgrade():
op.drop_column('output_types', 'cycles_device')
@@ -0,0 +1,25 @@
"""Add shadow_catcher_enabled to render_templates.
Revision ID: 026
Revises: 025
Create Date: 2026-03-03
"""
from alembic import op
import sqlalchemy as sa
revision = '026'
down_revision = '025'
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
'render_templates',
sa.Column('shadow_catcher_enabled', sa.Boolean(), nullable=False,
server_default='false'),
)
def downgrade():
op.drop_column('render_templates', 'shadow_catcher_enabled')
@@ -0,0 +1,108 @@
"""Remove product variant system — products are unique, no variant concept.
Revision ID: 027
Revises: 026
Create Date: 2026-03-03
"""
from alembic import op
import sqlalchemy as sa
revision = "027"
down_revision = "026"
branch_labels = None
depends_on = None
NIL_UUID = "00000000-0000-0000-0000-000000000000"
def upgrade() -> None:
# Drop variant-aware unique indexes on order_lines
op.execute("DROP INDEX IF EXISTS uq_order_lines_tracking")
op.execute("DROP INDEX IF EXISTS uq_order_lines_render")
# Drop variant_id column from order_lines
op.execute("ALTER TABLE order_lines DROP COLUMN IF EXISTS variant_id")
# Deduplicate tracking-only lines (output_type_id IS NULL) — keep the newest row per
# (order_id, product_id) pair so the unique index can be created cleanly.
op.execute("""
DELETE FROM order_lines
WHERE output_type_id IS NULL
AND id NOT IN (
SELECT DISTINCT ON (order_id, product_id) id
FROM order_lines
WHERE output_type_id IS NULL
ORDER BY order_id, product_id, created_at DESC
)
""")
# Deduplicate render lines — keep the newest row per (order_id, product_id, output_type_id).
op.execute("""
DELETE FROM order_lines
WHERE output_type_id IS NOT NULL
AND id NOT IN (
SELECT DISTINCT ON (order_id, product_id, output_type_id) id
FROM order_lines
WHERE output_type_id IS NOT NULL
ORDER BY order_id, product_id, output_type_id, created_at DESC
)
""")
# Recreate simpler unique indexes without variant_id
op.execute(
"CREATE UNIQUE INDEX uq_order_lines_tracking "
"ON order_lines (order_id, product_id) "
"WHERE output_type_id IS NULL"
)
op.execute(
"CREATE UNIQUE INDEX uq_order_lines_render "
"ON order_lines (order_id, product_id, output_type_id) "
"WHERE output_type_id IS NOT NULL"
)
# Drop product_variants table (CASCADE removes its indexes automatically)
op.execute("DROP TABLE IF EXISTS product_variants CASCADE")
def downgrade() -> None:
# Recreate product_variants table
op.execute("""
CREATE TABLE product_variants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
name VARCHAR(500) NOT NULL,
gewuenschte_bildnummer VARCHAR(500),
components JSONB NOT NULL DEFAULT '[]',
is_default BOOLEAN NOT NULL DEFAULT false,
source_excel VARCHAR(1000),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
)
""")
op.execute(
"CREATE UNIQUE INDEX uq_product_variants_product_name "
"ON product_variants (product_id, lower(name))"
)
op.execute("CREATE INDEX ON product_variants (product_id)")
# Add back variant_id to order_lines
op.execute(
"ALTER TABLE order_lines ADD COLUMN variant_id UUID "
"REFERENCES product_variants(id) ON DELETE SET NULL"
)
# Drop simple indexes and restore variant-aware indexes
op.execute("DROP INDEX IF EXISTS uq_order_lines_tracking")
op.execute("DROP INDEX IF EXISTS uq_order_lines_render")
op.execute(
"CREATE UNIQUE INDEX uq_order_lines_tracking "
f"ON order_lines (order_id, product_id, COALESCE(variant_id, '{NIL_UUID}'::uuid)) "
"WHERE output_type_id IS NULL"
)
op.execute(
"CREATE UNIQUE INDEX uq_order_lines_render "
"ON order_lines (order_id, product_id, output_type_id, "
f"COALESCE(variant_id, '{NIL_UUID}'::uuid)) "
"WHERE output_type_id IS NOT NULL"
)
@@ -0,0 +1,81 @@
"""Add product_render_positions table and render_position_id on order_lines.
Revision ID: 028
Revises: 027
Create Date: 2026-03-04
"""
from alembic import op
import sqlalchemy as sa
revision = "028"
down_revision = "027"
branch_labels = None
depends_on = None
NIL_UUID = "00000000-0000-0000-0000-000000000000"
def upgrade() -> None:
# ── New table: product_render_positions ──────────────────────────────────
op.execute("""
CREATE TABLE product_render_positions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
rotation_x DOUBLE PRECISION NOT NULL DEFAULT 0,
rotation_y DOUBLE PRECISION NOT NULL DEFAULT 0,
rotation_z DOUBLE PRECISION NOT NULL DEFAULT 0,
is_default BOOLEAN NOT NULL DEFAULT false,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
)
""")
op.execute(
"CREATE UNIQUE INDEX uq_render_positions_product_name "
"ON product_render_positions (product_id, lower(name))"
)
op.execute("CREATE INDEX ix_render_positions_product_id ON product_render_positions (product_id)")
# ── Add render_position_id to order_lines ────────────────────────────────
op.execute(
"ALTER TABLE order_lines ADD COLUMN render_position_id UUID "
"REFERENCES product_render_positions(id) ON DELETE SET NULL"
)
# ── Update unique indexes to include position ─────────────────────────────
op.execute("DROP INDEX IF EXISTS uq_order_lines_tracking")
op.execute("DROP INDEX IF EXISTS uq_order_lines_render")
op.execute(
f"CREATE UNIQUE INDEX uq_order_lines_tracking "
f"ON order_lines (order_id, product_id, COALESCE(render_position_id, '{NIL_UUID}'::uuid)) "
f"WHERE output_type_id IS NULL"
)
op.execute(
f"CREATE UNIQUE INDEX uq_order_lines_render "
f"ON order_lines (order_id, product_id, output_type_id, "
f"COALESCE(render_position_id, '{NIL_UUID}'::uuid)) "
f"WHERE output_type_id IS NOT NULL"
)
def downgrade() -> None:
# Restore original unique indexes (without position)
op.execute("DROP INDEX IF EXISTS uq_order_lines_tracking")
op.execute("DROP INDEX IF EXISTS uq_order_lines_render")
op.execute("ALTER TABLE order_lines DROP COLUMN IF EXISTS render_position_id")
op.execute(
"CREATE UNIQUE INDEX uq_order_lines_tracking "
"ON order_lines (order_id, product_id) "
"WHERE output_type_id IS NULL"
)
op.execute(
"CREATE UNIQUE INDEX uq_order_lines_render "
"ON order_lines (order_id, product_id, output_type_id) "
"WHERE output_type_id IS NOT NULL"
)
op.execute("DROP TABLE IF EXISTS product_render_positions CASCADE")
@@ -0,0 +1,53 @@
"""Seed default render positions (3/4 Front + 3/4 Rear) for all existing products.
Revision ID: 029
Revises: 028
Create Date: 2026-03-04
"""
from alembic import op
revision = "029"
down_revision = "028"
branch_labels = None
depends_on = None
def upgrade():
# Insert two default positions for every product that currently has none.
# The CTE guarantees both rows are inserted for the same set of products.
op.execute("""
WITH products_without_positions AS (
SELECT p.id AS product_id
FROM products p
WHERE NOT EXISTS (
SELECT 1 FROM product_render_positions rp WHERE rp.product_id = p.id
)
)
INSERT INTO product_render_positions
(id, product_id, name, rotation_x, rotation_y, rotation_z,
is_default, sort_order, created_at, updated_at)
SELECT gen_random_uuid(), product_id,
'3/4 Front', -15.0, 45.0, 0.0, true, 0, NOW(), NOW()
FROM products_without_positions
UNION ALL
SELECT gen_random_uuid(), product_id,
'3/4 Rear', -15.0, -135.0, 0.0, false, 1, NOW(), NOW()
FROM products_without_positions
""")
def downgrade():
# Remove positions named exactly '3/4 Front' or '3/4 Rear'
# where they are the only two positions on that product (i.e. seeded ones).
op.execute("""
DELETE FROM product_render_positions
WHERE name IN ('3/4 Front', '3/4 Rear')
AND product_id IN (
SELECT product_id
FROM product_render_positions
GROUP BY product_id
HAVING COUNT(*) = 2
AND bool_or(name = '3/4 Front')
AND bool_or(name = '3/4 Rear')
)
""")
@@ -0,0 +1,39 @@
"""Seed 'Default' (unrotated) render position for all existing products.
Revision ID: 030
Revises: 029
Create Date: 2026-03-04
"""
from alembic import op
revision = "030"
down_revision = "029"
branch_labels = None
depends_on = None
def upgrade():
# Add 'Default' (0°/0°/0°) to every product that doesn't already have it.
op.execute("""
INSERT INTO product_render_positions
(id, product_id, name, rotation_x, rotation_y, rotation_z,
is_default, sort_order, created_at, updated_at)
SELECT gen_random_uuid(), p.id,
'Default', 0.0, 0.0, 0.0, false, 2, NOW(), NOW()
FROM products p
WHERE NOT EXISTS (
SELECT 1 FROM product_render_positions rp
WHERE rp.product_id = p.id
AND lower(rp.name) = 'default'
)
""")
def downgrade():
op.execute("""
DELETE FROM product_render_positions
WHERE lower(name) = 'default'
AND rotation_x = 0.0
AND rotation_y = 0.0
AND rotation_z = 0.0
""")
@@ -0,0 +1,25 @@
"""Add camera_orbit to render_templates
Revision ID: 031_camera_orbit
Revises: 030_seed_default_position
Create Date: 2026-03-04
"""
from alembic import op
import sqlalchemy as sa
revision = '031'
down_revision = '030'
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
'render_templates',
sa.Column('camera_orbit', sa.Boolean(), nullable=False,
server_default='true'),
)
def downgrade():
op.drop_column('render_templates', 'camera_orbit')