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
+27
View File
@@ -0,0 +1,27 @@
FROM python:3.11-slim
WORKDIR /app
# System dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Docker SDK (for dynamic flamenco-worker scaling via /var/run/docker.sock)
RUN pip install --no-cache-dir "docker>=6.1.0"
# Install Python dependencies
COPY pyproject.toml .
RUN pip install --no-cache-dir -e .
# Copy app code
COPY . .
# Create upload dirs
RUN mkdir -p uploads/step_files uploads/excel_files uploads/thumbnails
COPY start.sh /start.sh
RUN chmod +x /start.sh
EXPOSE 8000
+41
View File
@@ -0,0 +1,41 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = postgresql://schaeffler:schaeffler@localhost:5432/schaeffler
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
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')
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
View File
Binary file not shown.
Binary file not shown.
View File

Some files were not shown because too many files have changed in this diff Show More