fix: render pipeline + multi-tenancy bugs (B-Fix-1 through B-Fix-9)
- Remove worker-thumbnail (no Blender, was competing on thumbnail_rendering) - Move render_order_line_task to thumbnail_rendering queue (render-worker) - Restore template_service.py real implementation (fix circular import shim) - Thread tenant_id through STEP upload, Excel import, product create - Make system tables (output_types, materials, etc.) tenant_id nullable - Fix tenants frontend 307-redirect: use trailing slash /tenants/ - Remove Flamenco + Three.js from Admin UI (unsupported) - Set all output_types render_backend to celery (was flamenco) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -345,6 +345,7 @@ async def create_order(
|
||||
created_by=user.id,
|
||||
source_excel=body.source_excel,
|
||||
notes=body.notes,
|
||||
tenant_id=getattr(user, 'tenant_id', None),
|
||||
)
|
||||
db.add(order)
|
||||
await db.flush()
|
||||
@@ -364,6 +365,7 @@ async def create_order(
|
||||
lagertyp=item_data.lagertyp,
|
||||
medias_rendering=item_data.medias_rendering,
|
||||
components=[c.model_dump() for c in item_data.components],
|
||||
tenant_id=getattr(user, 'tenant_id', None),
|
||||
)
|
||||
db.add(item)
|
||||
|
||||
@@ -381,6 +383,7 @@ async def create_order(
|
||||
render_position_id=line_data.render_position_id,
|
||||
gewuenschte_bildnummer=line_data.gewuenschte_bildnummer,
|
||||
notes=line_data.notes,
|
||||
tenant_id=getattr(user, 'tenant_id', None),
|
||||
)
|
||||
db.add(line)
|
||||
|
||||
@@ -565,6 +568,7 @@ async def split_missing_step(
|
||||
created_by=order.created_by,
|
||||
source_excel=order.source_excel,
|
||||
notes=f"Split from {order.order_number} — awaiting STEP files",
|
||||
tenant_id=order.tenant_id,
|
||||
)
|
||||
db.add(new_order)
|
||||
await db.flush()
|
||||
@@ -665,6 +669,7 @@ async def generate_lines_from_items(
|
||||
product_id=product.id,
|
||||
output_type_id=type_id,
|
||||
gewuenschte_bildnummer=item.gewuenschte_bildnummer,
|
||||
tenant_id=getattr(user, 'tenant_id', None),
|
||||
)
|
||||
db.add(line)
|
||||
existing_pairs.add(pair)
|
||||
@@ -807,6 +812,7 @@ async def add_order_line(
|
||||
render_position_id=body.render_position_id,
|
||||
gewuenschte_bildnummer=body.gewuenschte_bildnummer,
|
||||
notes=body.notes,
|
||||
tenant_id=getattr(user, 'tenant_id', None),
|
||||
)
|
||||
db.add(line)
|
||||
try:
|
||||
|
||||
@@ -206,7 +206,7 @@ async def create_product(
|
||||
raise HTTPException(409, detail=f"Product with pim_id '{body.pim_id}' already exists")
|
||||
|
||||
from app.services.product_service import create_default_positions
|
||||
product = Product(**body.model_dump())
|
||||
product = Product(**body.model_dump(), tenant_id=getattr(user, 'tenant_id', None))
|
||||
db.add(product)
|
||||
await db.flush()
|
||||
await create_default_positions(db, product.id)
|
||||
|
||||
@@ -241,6 +241,7 @@ async def finalize_excel(
|
||||
included_rows,
|
||||
source_excel=str(excel_path),
|
||||
category_key=parsed_dict.get("category_key"),
|
||||
tenant_id=getattr(user, 'tenant_id', None),
|
||||
)
|
||||
|
||||
# 5. Seed material aliases
|
||||
@@ -260,6 +261,7 @@ async def finalize_excel(
|
||||
created_by=user.id,
|
||||
source_excel=str(excel_path),
|
||||
notes=body.notes,
|
||||
tenant_id=getattr(user, 'tenant_id', None),
|
||||
)
|
||||
db.add(order)
|
||||
await db.flush()
|
||||
@@ -292,6 +294,7 @@ async def finalize_excel(
|
||||
for c in row.get("components", [])
|
||||
],
|
||||
cad_file_id=inherited_cad,
|
||||
tenant_id=getattr(user, 'tenant_id', None),
|
||||
)
|
||||
db.add(item)
|
||||
|
||||
@@ -316,6 +319,7 @@ async def finalize_excel(
|
||||
product_id=uuid.UUID(product_id),
|
||||
output_type_id=None,
|
||||
gewuenschte_bildnummer=row.get("gewuenschte_bildnummer"),
|
||||
tenant_id=getattr(user, 'tenant_id', None),
|
||||
)
|
||||
db.add(line)
|
||||
else:
|
||||
@@ -325,6 +329,7 @@ async def finalize_excel(
|
||||
product_id=uuid.UUID(product_id),
|
||||
output_type_id=type_id,
|
||||
gewuenschte_bildnummer=row.get("gewuenschte_bildnummer"),
|
||||
tenant_id=getattr(user, 'tenant_id', None),
|
||||
)
|
||||
db.add(line)
|
||||
|
||||
@@ -410,6 +415,7 @@ async def upload_step(
|
||||
file_hash=file_hash,
|
||||
file_size=len(content),
|
||||
processing_status=ProcessingStatus.pending,
|
||||
tenant_id=getattr(user, 'tenant_id', None),
|
||||
)
|
||||
db.add(cad_file)
|
||||
await db.commit()
|
||||
|
||||
@@ -69,7 +69,7 @@ async def lookup_product(
|
||||
|
||||
|
||||
async def lookup_or_create_product(
|
||||
db: AsyncSession, pim_id: str | None, fields: dict
|
||||
db: AsyncSession, pim_id: str | None, fields: dict, tenant_id=None
|
||||
) -> tuple[Product, bool]:
|
||||
"""Look up by produkt_baureihe (primary), then pim_id (fallback). Create if not found.
|
||||
|
||||
@@ -120,6 +120,7 @@ async def lookup_or_create_product(
|
||||
components=fields.get("components", []),
|
||||
cad_part_materials=fields.get("cad_part_materials", []),
|
||||
source_excel=fields.get("source_excel"),
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
db.add(product)
|
||||
await db.flush()
|
||||
|
||||
@@ -110,9 +110,7 @@ def dispatch_render_with_workflow(order_line_id: str) -> dict:
|
||||
|
||||
|
||||
def _legacy_dispatch(order_line_id: str) -> dict:
|
||||
"""Delegate to the original render_dispatcher logic (kept for backward compat)."""
|
||||
# Import the original full implementation (not the shim) to avoid circular imports.
|
||||
# The original logic lives inline in the orders router / step_tasks path;
|
||||
# here we re-use the existing flamenco/celery routing code.
|
||||
from app.services.render_dispatcher import dispatch_render # noqa: F401 — shim re-export
|
||||
return dispatch_render(order_line_id)
|
||||
"""Queue render_order_line_task (the working Celery render implementation)."""
|
||||
from app.tasks.step_tasks import render_order_line_task
|
||||
render_order_line_task.delay(order_line_id)
|
||||
return {"backend": "celery", "queued": True}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Rendering services — template resolution, dispatch, and Blender utilities."""
|
||||
|
||||
# Re-export from original service files for backward compatibility.
|
||||
# Re-export from canonical service files for backward compatibility.
|
||||
# template_service contains the actual sync implementations (Celery-safe).
|
||||
from app.services.template_service import resolve_template, get_material_library_path
|
||||
from app.services.render_dispatcher import dispatch_render
|
||||
from app.services.render_blender import find_blender, is_blender_available
|
||||
|
||||
@@ -36,6 +36,7 @@ async def import_excel_to_products(
|
||||
parsed_rows: list[dict],
|
||||
source_excel: str,
|
||||
category_key: str | None = None,
|
||||
tenant_id=None,
|
||||
) -> ImportResult:
|
||||
"""For each row, look up or create a Product.
|
||||
|
||||
@@ -78,7 +79,7 @@ async def import_excel_to_products(
|
||||
"source_excel": source_excel,
|
||||
}
|
||||
|
||||
product, was_created = await lookup_or_create_product(db, pim_id, fields)
|
||||
product, was_created = await lookup_or_create_product(db, pim_id, fields, tenant_id=tenant_id)
|
||||
row["product_id"] = str(product.id)
|
||||
row["product_created"] = was_created
|
||||
# Carry forward any STEP file already linked to this product
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
# Compat shim — use app.domains.rendering.service instead
|
||||
from app.domains.rendering.service import dispatch_render
|
||||
# Compat shim — routes to render_order_line_task (the working implementation)
|
||||
def dispatch_render(order_line_id: str) -> dict:
|
||||
"""Queue render_order_line_task for the given order line."""
|
||||
from app.tasks.step_tasks import render_order_line_task
|
||||
render_order_line_task.delay(order_line_id)
|
||||
return {"backend": "celery", "queued": True}
|
||||
|
||||
|
||||
__all__ = ["dispatch_render"]
|
||||
|
||||
@@ -1,3 +1,102 @@
|
||||
# Compat shim — use app.domains.rendering.service instead
|
||||
from app.domains.rendering.service import resolve_template, get_material_library_path
|
||||
__all__ = ["resolve_template", "get_material_library_path"]
|
||||
"""Render template resolution service.
|
||||
|
||||
Used from Celery tasks (sync context) to find the best matching .blend template
|
||||
for a given category + output type combination.
|
||||
|
||||
Cascade priority (first active match wins):
|
||||
1. Exact: category_key + output_type_id
|
||||
2. Category only: category_key + output_type_id IS NULL
|
||||
3. OT only: category_key IS NULL + output_type_id
|
||||
4. Global: both NULL
|
||||
5. No template → caller falls back to factory-settings behavior
|
||||
"""
|
||||
import logging
|
||||
|
||||
from sqlalchemy import create_engine, select, and_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.render_template import RenderTemplate
|
||||
from app.models.system_setting import SystemSetting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_engine = None
|
||||
|
||||
|
||||
def _get_engine():
|
||||
global _engine
|
||||
if _engine is None:
|
||||
from app.config import settings as app_settings
|
||||
_engine = create_engine(app_settings.database_url_sync)
|
||||
return _engine
|
||||
|
||||
|
||||
def resolve_template(
|
||||
category_key: str | None = None,
|
||||
output_type_id: str | None = None,
|
||||
) -> RenderTemplate | None:
|
||||
"""Find the best matching active render template.
|
||||
|
||||
Uses sync SQLAlchemy — safe for Celery tasks.
|
||||
"""
|
||||
engine = _get_engine()
|
||||
with Session(engine) as session:
|
||||
active = RenderTemplate.is_active == True # noqa: E712
|
||||
|
||||
# 1. Exact match
|
||||
if category_key and output_type_id:
|
||||
row = session.execute(
|
||||
select(RenderTemplate).where(and_(
|
||||
active,
|
||||
RenderTemplate.category_key == category_key,
|
||||
RenderTemplate.output_type_id == output_type_id,
|
||||
))
|
||||
).scalar_one_or_none()
|
||||
if row:
|
||||
return row
|
||||
|
||||
# 2. Category only
|
||||
if category_key:
|
||||
row = session.execute(
|
||||
select(RenderTemplate).where(and_(
|
||||
active,
|
||||
RenderTemplate.category_key == category_key,
|
||||
RenderTemplate.output_type_id.is_(None),
|
||||
))
|
||||
).scalar_one_or_none()
|
||||
if row:
|
||||
return row
|
||||
|
||||
# 3. OT only
|
||||
if output_type_id:
|
||||
row = session.execute(
|
||||
select(RenderTemplate).where(and_(
|
||||
active,
|
||||
RenderTemplate.category_key.is_(None),
|
||||
RenderTemplate.output_type_id == output_type_id,
|
||||
))
|
||||
).scalar_one_or_none()
|
||||
if row:
|
||||
return row
|
||||
|
||||
# 4. Global fallback (both NULL)
|
||||
row = session.execute(
|
||||
select(RenderTemplate).where(and_(
|
||||
active,
|
||||
RenderTemplate.category_key.is_(None),
|
||||
RenderTemplate.output_type_id.is_(None),
|
||||
))
|
||||
).scalar_one_or_none()
|
||||
return row
|
||||
|
||||
|
||||
def get_material_library_path() -> str | None:
|
||||
"""Read material_library_path from system_settings. Returns None if empty."""
|
||||
engine = _get_engine()
|
||||
with Session(engine) as session:
|
||||
row = session.execute(
|
||||
select(SystemSetting).where(SystemSetting.key == "material_library_path")
|
||||
).scalar_one_or_none()
|
||||
if row and row.value and row.value.strip():
|
||||
return row.value.strip()
|
||||
return None
|
||||
|
||||
@@ -239,42 +239,12 @@ def regenerate_thumbnail(self, cad_file_id: str, part_colors: dict):
|
||||
|
||||
@celery_app.task(name="app.tasks.step_tasks.dispatch_order_line_render", queue="step_processing")
|
||||
def dispatch_order_line_render(order_line_id: str):
|
||||
"""Thin wrapper that calls render_dispatcher.dispatch_render()."""
|
||||
"""Route an order-line render to render_order_line_task."""
|
||||
logger.info(f"Dispatching render for order line: {order_line_id}")
|
||||
try:
|
||||
from app.services.render_dispatcher import dispatch_render
|
||||
result = dispatch_render(order_line_id)
|
||||
logger.info(f"Dispatch result for {order_line_id}: {result}")
|
||||
return result
|
||||
except Exception as exc:
|
||||
logger.error(f"dispatch_order_line_render failed for {order_line_id}: {exc}")
|
||||
# Mark line as failed so it doesn't stay stuck in "processing"
|
||||
try:
|
||||
from sqlalchemy import create_engine, update as sql_update
|
||||
from sqlalchemy.orm import Session
|
||||
from app.config import settings as app_settings
|
||||
from app.models.order_line import OrderLine
|
||||
from datetime import datetime
|
||||
sync_url = app_settings.database_url.replace("+asyncpg", "")
|
||||
eng = create_engine(sync_url)
|
||||
with Session(eng) as s:
|
||||
s.execute(
|
||||
sql_update(OrderLine)
|
||||
.where(OrderLine.id == order_line_id)
|
||||
.values(
|
||||
render_status="failed",
|
||||
render_completed_at=datetime.utcnow(),
|
||||
render_log={"error": f"Dispatch failed: {str(exc)[:500]}"},
|
||||
)
|
||||
)
|
||||
s.commit()
|
||||
eng.dispose()
|
||||
except Exception:
|
||||
logger.exception(f"Failed to mark {order_line_id} as failed after dispatch error")
|
||||
raise
|
||||
render_order_line_task.delay(order_line_id)
|
||||
|
||||
|
||||
@celery_app.task(bind=True, name="app.tasks.step_tasks.render_order_line_task", queue="step_processing", max_retries=3)
|
||||
@celery_app.task(bind=True, name="app.tasks.step_tasks.render_order_line_task", queue="thumbnail_rendering", max_retries=3)
|
||||
def render_order_line_task(self, order_line_id: str):
|
||||
"""Render a specific output type for an order line.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user