Files
HartOMat/backend/app/domains/rendering/models.py
T
HartmutandClaude Sonnet 4.6 e8b4580608 fix: workflow editor Phase 5/6 sign-off — save guard, conflict detection, dispatch pre-check
- Save button disabled for non-admins (canSave prop threaded through
  WorkflowEditor → WorkflowCanvas → WorkflowCanvasToolbar); tooltip
  explains why when hovered
- Optimistic concurrency: migration 072 adds updated_at to
  workflow_definitions; PUT /workflows/:id returns 409 when client sends
  a stale updated_at; frontend shows a specific reload-prompt toast
- _legacy_dispatch now routes through dispatch_order_line_render instead
  of calling render_order_line_task directly, so cancelled/rejected
  order lines are skipped before queueing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 11:39:02 +02:00

217 lines
12 KiB
Python

import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import String, DateTime, Boolean, Text, Integer, Float, ForeignKey, Table, Column
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
# M2M: render templates ↔ output types
render_template_output_types = Table(
"render_template_output_types",
Base.metadata,
Column("template_id", UUID(as_uuid=True), ForeignKey("render_templates.id", ondelete="CASCADE"), primary_key=True),
Column("output_type_id", UUID(as_uuid=True), ForeignKey("output_types.id", ondelete="CASCADE"), primary_key=True),
)
VALID_RENDER_BACKENDS = {"celery"}
OUTPUT_TYPE_WORKFLOW_FAMILIES = {"cad_file", "order_line"}
OUTPUT_TYPE_WORKFLOW_ROLLOUT_MODES = {"legacy_only", "shadow", "graph"}
OUTPUT_TYPE_ARTIFACT_KINDS = {
"still_image",
"turntable_video",
"model_export",
"thumbnail_image",
"blend_asset",
"package",
"custom",
}
class OutputType(Base):
__tablename__ = "output_types"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(200), unique=True, nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
renderer: Mapped[str] = mapped_column(String(50), nullable=False, default="blender")
render_settings: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
output_format: Mapped[str] = mapped_column(String(20), nullable=False, default="png")
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
compatible_categories: Mapped[list] = mapped_column(JSONB, default=list, server_default="[]")
render_backend: Mapped[str] = mapped_column(String(20), nullable=False, default="celery", server_default="auto")
is_animation: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
transparent_bg: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
workflow_family: Mapped[str] = mapped_column(
String(20), nullable=False, default="order_line", server_default="order_line"
)
artifact_kind: Mapped[str] = mapped_column(
String(50), nullable=False, default="still_image", server_default="still_image"
)
invocation_overrides: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
cycles_device: Mapped[str | None] = mapped_column(String(10), nullable=True, default=None)
pricing_tier_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("pricing_tiers.id", ondelete="SET NULL"), nullable=True, index=True
)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
material_override: Mapped[str | None] = mapped_column(String(200), nullable=True, default=None)
workflow_definition_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("workflow_definitions.id", ondelete="SET NULL"), nullable=True
)
workflow_rollout_mode: Mapped[str] = mapped_column(
String(20), nullable=False, default="legacy_only", server_default="legacy_only"
)
order_lines: Mapped[list["OrderLine"]] = relationship("OrderLine", back_populates="output_type")
pricing_tier: Mapped["PricingTier | None"] = relationship("PricingTier", back_populates="output_types")
class RenderTemplate(Base):
__tablename__ = "render_templates"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(300), nullable=False)
category_key: Mapped[str | None] = mapped_column(String(100), nullable=True)
output_type_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("output_types.id", ondelete="SET NULL"), nullable=True
)
blend_file_path: Mapped[str] = mapped_column(Text, nullable=False)
original_filename: Mapped[str] = mapped_column(String(500), nullable=False)
target_collection: Mapped[str] = mapped_column(String(200), nullable=False, default="Product", server_default="Product")
material_replace_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
lighting_only: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
shadow_catcher_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
camera_orbit: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
workflow_input_schema: Mapped[list[dict[str, Any]]] = mapped_column(
JSONB,
nullable=False,
default=list,
server_default="[]",
)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default="now()")
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default="now()", onupdate=datetime.utcnow)
# Legacy single FK (kept for backward compat, prefer output_types M2M)
output_type = relationship("OutputType", lazy="joined")
# M2M: multiple output types per template
output_types = relationship("OutputType", secondary=render_template_output_types, lazy="joined")
class ProductRenderPosition(Base):
__tablename__ = "product_render_positions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
product_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(200), nullable=False)
rotation_x: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
rotation_y: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
rotation_z: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
focal_length_mm: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
sensor_width_mm: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
product: Mapped["Product"] = relationship("Product", back_populates="render_positions")
order_lines: Mapped[list["OrderLine"]] = relationship("OrderLine", back_populates="render_position")
class GlobalRenderPosition(Base):
__tablename__ = "global_render_positions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(200), nullable=False)
rotation_x: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
rotation_y: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
rotation_z: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
focal_length_mm: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
sensor_width_mm: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
order_lines: Mapped[list["OrderLine"]] = relationship("OrderLine", back_populates="global_render_position")
class WorkflowDefinition(Base):
__tablename__ = "workflow_definitions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(200), nullable=False)
output_type_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("output_types.id", ondelete="SET NULL"), nullable=True
)
config: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
runs: Mapped[list["WorkflowRun"]] = relationship(
"WorkflowRun", back_populates="workflow_def", lazy="noload", cascade="all, delete-orphan"
)
class WorkflowRun(Base):
__tablename__ = "workflow_runs"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
workflow_def_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("workflow_definitions.id", ondelete="SET NULL"), nullable=True
)
order_line_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("order_lines.id", ondelete="CASCADE"), nullable=True, index=True
)
celery_task_id: Mapped[str | None] = mapped_column(String(500), nullable=True)
execution_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="legacy", server_default="legacy")
status: Mapped[str] = mapped_column(String(50), nullable=False, default="pending")
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
workflow_def: Mapped["WorkflowDefinition | None"] = relationship(
"WorkflowDefinition", back_populates="runs"
)
node_results: Mapped[list["WorkflowNodeResult"]] = relationship(
"WorkflowNodeResult", back_populates="run", cascade="all, delete-orphan"
)
class WorkflowNodeResult(Base):
__tablename__ = "workflow_node_results"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
run_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("workflow_runs.id", ondelete="CASCADE"), nullable=False, index=True
)
node_name: Mapped[str] = mapped_column(String(200), nullable=False)
status: Mapped[str] = mapped_column(String(50), nullable=False, default="pending")
output: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
log: Mapped[str | None] = mapped_column(Text, nullable=True)
duration_s: Mapped[float | None] = mapped_column(Float, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
run: Mapped["WorkflowRun"] = relationship("WorkflowRun", back_populates="node_results")