feat(N): workflow pipeline, 3D viewer, worker management, QC tests

- workflow_builder.py: fix broken stubs, add render_order_line_still_task
  (resolves step_path from DB instead of passing order_line_id as step_path)
- domains/rendering/tasks.py: add render_order_line_still_task,
  export_gltf_for_order_line_task, export_blend_for_order_line_task,
  generate_gltf_geometry_task (trimesh STL→GLB, no Blender needed)
- tasks/step_tasks.py: add generate_gltf_geometry_task for CadFile GLB export
- cad router: POST /{id}/generate-gltf-geometry endpoint (admin/PM)
- worker router: GET /celery-workers + POST /scale (docker compose subprocess)
- Dockerfile: pip install -e "[dev]" to enable pytest
- docker-compose.yml: docker socket + compose file mount on backend
- ThreeDViewer.tsx: mode toggle (geometry/production), wireframe, env presets,
  download buttons (GLB + .blend)
- CadPreview.tsx: load gltf_geometry/gltf_production/blend_production assets
  from MediaAsset table and pass URLs to ThreeDViewer
- ProductDetail.tsx: "View 3D" button → /cad/:id, "Generate GLB" button
- media router/service: cad_file_id filter on GET /api/media
- WorkerManagement.tsx: new page with worker status, queue depth, scale controls
- App.tsx + Layout.tsx: /workers route + sidebar link (admin/PM)
- tests: test_rendering_service.py, test_orders_service.py (backend)
- tests: WorkerActivity.test.tsx, WorkerManagement.test.tsx (frontend)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 22:56:53 +01:00
parent 208eb21988
commit a70cb55d01
24 changed files with 1828 additions and 448 deletions
@@ -0,0 +1,191 @@
"""Tests for orders domain — order creation, status transitions, and pricing."""
import uuid
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _create_test_product(db):
from app.domains.products.models import Product
product = Product(
id=uuid.uuid4(),
name=f"Test Product {uuid.uuid4().hex[:6]}",
category_key="TRB",
components=[],
cad_part_materials=[],
)
db.add(product)
await db.commit()
await db.refresh(product)
return product
async def _create_test_order(db, user):
from app.domains.orders.models import Order, OrderStatus
order = Order(
id=uuid.uuid4(),
order_number=f"TEST-{uuid.uuid4().hex[:6].upper()}",
status=OrderStatus.draft,
created_by=user.id,
tenant_id=None,
)
db.add(order)
await db.commit()
await db.refresh(order)
return order
# ---------------------------------------------------------------------------
# Order creation
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_create_order_draft_status(db, admin_user):
"""New order starts in draft status."""
order = await _create_test_order(db, admin_user)
assert order.id is not None
assert order.status.value == "draft"
assert order.order_number.startswith("TEST-")
@pytest.mark.asyncio
async def test_order_has_no_lines_initially(db, admin_user):
"""New order starts with zero order lines."""
from sqlalchemy import select
from app.domains.orders.models import Order, OrderLine
order = await _create_test_order(db, admin_user)
result = await db.execute(
select(OrderLine).where(OrderLine.order_id == order.id)
)
lines = result.scalars().all()
assert len(lines) == 0
# ---------------------------------------------------------------------------
# Order line creation
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_add_order_line(db, admin_user):
"""Order line can be added to a draft order."""
from app.domains.orders.models import OrderLine
product = await _create_test_product(db)
order = await _create_test_order(db, admin_user)
line = OrderLine(
id=uuid.uuid4(),
order_id=order.id,
product_id=product.id,
render_status="pending",
item_status="pending",
tenant_id=None,
)
db.add(line)
await db.commit()
await db.refresh(line)
assert line.id is not None
assert line.order_id == order.id
assert line.render_status == "pending"
# ---------------------------------------------------------------------------
# Status transitions
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_order_status_transition_to_submitted(db, admin_user):
"""Order status can be changed from draft to submitted."""
from app.domains.orders.models import Order, OrderStatus
order = await _create_test_order(db, admin_user)
order.status = OrderStatus.submitted
await db.commit()
await db.refresh(order)
assert order.status == OrderStatus.submitted
@pytest.mark.asyncio
async def test_order_multiple_lines(db, admin_user):
"""Multiple lines can be added to the same order."""
from app.domains.orders.models import OrderLine
product = await _create_test_product(db)
order = await _create_test_order(db, admin_user)
for _ in range(3):
line = OrderLine(
id=uuid.uuid4(),
order_id=order.id,
product_id=product.id,
render_status="pending",
item_status="pending",
tenant_id=None,
)
db.add(line)
await db.commit()
from sqlalchemy import select
result = await db.execute(
select(OrderLine).where(OrderLine.order_id == order.id)
)
assert len(result.scalars().all()) == 3
# ---------------------------------------------------------------------------
# Render status tracking
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_order_line_render_status_update(db, admin_user):
"""Order line render_status can be updated to processing/completed."""
from app.domains.orders.models import OrderLine
product = await _create_test_product(db)
order = await _create_test_order(db, admin_user)
line = OrderLine(
id=uuid.uuid4(),
order_id=order.id,
product_id=product.id,
render_status="pending",
item_status="pending",
tenant_id=None,
)
db.add(line)
await db.commit()
line.render_status = "processing"
await db.commit()
await db.refresh(line)
assert line.render_status == "processing"
line.render_status = "completed"
await db.commit()
await db.refresh(line)
assert line.render_status == "completed"
# ---------------------------------------------------------------------------
# Unit price
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_order_line_unit_price_nullable(db, admin_user):
"""unit_price defaults to None."""
from app.domains.orders.models import OrderLine
product = await _create_test_product(db)
order = await _create_test_order(db, admin_user)
line = OrderLine(
id=uuid.uuid4(),
order_id=order.id,
product_id=product.id,
render_status="pending",
item_status="pending",
tenant_id=None,
)
db.add(line)
await db.commit()
await db.refresh(line)
assert line.unit_price is None
@@ -0,0 +1,112 @@
"""Tests for rendering domain — workflow builder + task helpers."""
import uuid
from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# workflow_builder unit tests (no DB required)
# ---------------------------------------------------------------------------
def test_dispatch_workflow_unknown_type_raises():
from app.domains.rendering.workflow_builder import dispatch_workflow
with pytest.raises(ValueError, match="Unknown workflow type"):
dispatch_workflow("nonexistent_type", str(uuid.uuid4()))
def test_build_still_returns_chain():
"""_build_still returns a Celery chain wrapping render_order_line_still_task."""
from celery import chain
from app.domains.rendering.workflow_builder import _build_still
canvas = _build_still(str(uuid.uuid4()), {})
# A single-task chain is still a Celery Signature, not a plain chain, but
# it should be callable / have apply_async
assert hasattr(canvas, "apply_async")
def test_build_multi_angle_creates_group():
"""_build_multi_angle returns a Celery group with one sig per angle."""
from celery import group
from app.domains.rendering.workflow_builder import _build_multi_angle
order_line_id = str(uuid.uuid4())
canvas = _build_multi_angle(order_line_id, {"angles": [0, 90, 180]})
# group has tasks attribute
assert hasattr(canvas, "tasks")
assert len(canvas.tasks) == 3
def test_build_still_with_exports_is_chain():
"""_build_still_with_exports returns a chain."""
from app.domains.rendering.workflow_builder import _build_still_with_exports
canvas = _build_still_with_exports(str(uuid.uuid4()), {})
assert hasattr(canvas, "apply_async")
def test_build_turntable_raises_without_step_path():
"""_build_turntable raises ValueError if step_path missing in params."""
from app.domains.rendering.workflow_builder import _build_turntable
with pytest.raises(ValueError, match="step_path"):
_build_turntable(str(uuid.uuid4()), {})
def test_build_turntable_raises_without_output_dir():
from app.domains.rendering.workflow_builder import _build_turntable
with pytest.raises(ValueError, match="output_dir"):
_build_turntable(str(uuid.uuid4()), {"step_path": "/tmp/test.stp"})
# ---------------------------------------------------------------------------
# _resolve_step_path_for_order_line — unit-tests with DB (integration)
# ---------------------------------------------------------------------------
@pytest.mark.integration
@pytest.mark.asyncio
async def test_resolve_step_path_returns_none_for_missing_line(db):
"""Returns (None, None) for a line_id that doesn't exist."""
from app.domains.rendering.tasks import _resolve_step_path_for_order_line
import asyncio
result = _resolve_step_path_for_order_line(str(uuid.uuid4()))
assert result == (None, None)
# ---------------------------------------------------------------------------
# publish_asset (unit test with mocked DB)
# ---------------------------------------------------------------------------
def test_publish_asset_signature():
"""publish_asset is importable and is a bound Celery task."""
from app.domains.rendering.tasks import publish_asset
assert callable(publish_asset)
assert hasattr(publish_asset, "delay")
# ---------------------------------------------------------------------------
# generate_gltf_geometry_task — smoke test (unit)
# ---------------------------------------------------------------------------
def test_generate_gltf_geometry_task_importable():
from app.tasks.step_tasks import generate_gltf_geometry_task
assert callable(generate_gltf_geometry_task)
assert hasattr(generate_gltf_geometry_task, "delay")
# ---------------------------------------------------------------------------
# New order-line tasks are importable and correctly registered
# ---------------------------------------------------------------------------
def test_render_order_line_still_task_importable():
from app.domains.rendering.tasks import render_order_line_still_task
assert render_order_line_still_task.name == "app.domains.rendering.tasks.render_order_line_still_task"
assert render_order_line_still_task.queue == "thumbnail_rendering"
def test_export_gltf_for_order_line_task_importable():
from app.domains.rendering.tasks import export_gltf_for_order_line_task
assert export_gltf_for_order_line_task.queue == "thumbnail_rendering"
def test_export_blend_for_order_line_task_importable():
from app.domains.rendering.tasks import export_blend_for_order_line_task
assert export_blend_for_order_line_task.queue == "thumbnail_rendering"