feat: per-position camera settings, material alias dialog, product delete, media browser links

- Per-render-position focal_length_mm/sensor_width_mm (DB → pipeline → Blender)
- FOV-based camera distance with min clamp fix for wide-angle lenses
- Unmapped materials blocking dialog on "Dispatch Renders" with batch alias creation
- Material check endpoint (GET /orders/{id}/check-materials)
- Batch alias endpoint (POST /materials/batch-aliases)
- Quick-map "No alias" badges on Materials page
- Full product hard-delete with storage cleanup (MinIO + disk files + orphaned CadFile)
- Delete button on ProductDetail page with confirmation
- Clickable product names in Media Browser (links to product page)
- Single-line render dispatch/retry (POST /orders/{id}/lines/{id}/dispatch-render)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-14 12:16:37 +01:00
parent 0020376702
commit b583b0d7a2
48 changed files with 1827 additions and 376 deletions
+57
View File
@@ -825,3 +825,60 @@ async def import_existing_media_assets(
await db.commit()
return {"created": created, "skipped": skipped}
@router.delete("/settings/purge-render-media", status_code=status.HTTP_200_OK)
async def purge_render_media(
admin: User = Depends(require_global_admin),
db: AsyncSession = Depends(get_db),
):
"""Delete all still and turntable MediaAsset records and their backing files.
This removes rendered images and animations but leaves thumbnails, GLBs,
STLs, and USD masters intact.
"""
import logging
from pathlib import Path
from app.config import settings
from app.core.storage import get_storage
from app.domains.media.models import MediaAsset, MediaAssetType
logger = logging.getLogger(__name__)
storage = get_storage()
result = await db.execute(
select(MediaAsset).where(
MediaAsset.asset_type.in_([MediaAssetType.still, MediaAssetType.turntable])
)
)
assets = result.scalars().all()
deleted_db = 0
deleted_files = 0
freed_bytes = 0
for asset in assets:
# Delete backing file
key = asset.storage_key
try:
candidate = Path(key) if Path(key).is_absolute() else Path(settings.upload_dir) / key
if candidate.exists():
freed_bytes += candidate.stat().st_size
candidate.unlink()
deleted_files += 1
elif hasattr(storage, 'delete'):
storage.delete(key)
deleted_files += 1
except Exception as exc:
logger.warning("Could not delete file for asset %s (%s): %s", asset.id, key, exc)
await db.delete(asset)
deleted_db += 1
await db.commit()
return {
"deleted_records": deleted_db,
"deleted_files": deleted_files,
"freed_mb": round(freed_bytes / 1024 / 1024, 1),
"message": f"Purged {deleted_db} still/turntable asset(s), freed {round(freed_bytes / 1024 / 1024, 1)} MB",
}
+1 -1
View File
@@ -313,7 +313,7 @@ async def regenerate_thumbnail(
db: AsyncSession = Depends(get_db),
):
"""Queue a Celery task to reprocess the STEP file and regenerate its thumbnail."""
if user.role.value != "admin":
if user.role.value not in ("admin", "global_admin", "tenant_admin"):
raise HTTPException(
status_code=403,
detail="Only admins can trigger thumbnail regeneration",
+56
View File
@@ -174,6 +174,62 @@ async def seed_aliases(
return {"inserted": inserted, "total": total}
class BatchAliasMapping(BaseModel):
alias: str
material_id: uuid.UUID
class BatchAliasCreate(BaseModel):
mappings: list[BatchAliasMapping]
@router.post("/batch-aliases")
async def batch_create_aliases(
body: BatchAliasCreate,
user: User = Depends(require_admin_or_pm),
db: AsyncSession = Depends(get_db),
):
"""Create multiple material aliases in one request.
Skips aliases that already exist (case-insensitive). Validates that
each material_id exists.
"""
created = 0
skipped = 0
for mapping in body.mappings:
alias_str = mapping.alias.strip()
if not alias_str:
skipped += 1
continue
# Verify material exists
mat_result = await db.execute(
select(Material).where(Material.id == mapping.material_id)
)
if not mat_result.scalar_one_or_none():
raise HTTPException(
status.HTTP_404_NOT_FOUND,
detail=f"Material {mapping.material_id} not found",
)
# Check if alias already exists (case-insensitive)
existing = await db.execute(
select(MaterialAlias).where(
func.lower(MaterialAlias.alias) == alias_str.lower()
)
)
if existing.scalar_one_or_none():
skipped += 1
continue
db.add(MaterialAlias(material_id=mapping.material_id, alias=alias_str))
created += 1
await db.commit()
return {"created": created, "skipped": skipped}
@router.delete("/aliases/{alias_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_alias(
alias_id: uuid.UUID,
+96
View File
@@ -865,6 +865,50 @@ async def add_order_line(
return _build_line_out(line_loaded)
@router.get("/{order_id}/check-materials")
async def check_materials(
order_id: uuid.UUID,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Check if all materials in this order's products are mapped to library materials."""
from app.domains.materials.service import find_unmapped_materials
result = await db.execute(select(Order).where(Order.id == order_id))
order = result.scalar_one_or_none()
if not order:
raise HTTPException(404, detail="Order not found")
lines_result = await db.execute(
select(OrderLine)
.options(selectinload(OrderLine.product))
.where(OrderLine.order_id == order_id)
)
lines = lines_result.scalars().all()
# Collect all unique material names from all products
all_material_names: list[str] = []
seen: set[str] = set()
for line in lines:
if not line.product or not line.product.cad_part_materials:
continue
for entry in line.product.cad_part_materials:
mat_name = entry.get("material", "")
if mat_name and mat_name.lower() not in seen:
seen.add(mat_name.lower())
all_material_names.append(mat_name)
unmapped = await find_unmapped_materials(all_material_names, db)
total = len(all_material_names)
mapped = total - len(unmapped)
return {
"unmapped": unmapped,
"total_materials": total,
"mapped_count": mapped,
}
@router.post("/{order_id}/dispatch-renders")
async def dispatch_renders(
order_id: uuid.UUID,
@@ -1000,6 +1044,58 @@ async def cancel_line_render(
}
@router.post("/{order_id}/lines/{line_id}/dispatch-render")
async def dispatch_single_line_render(
order_id: uuid.UUID,
line_id: uuid.UUID,
user: User = Depends(require_admin_or_pm),
db: AsyncSession = Depends(get_db),
):
"""Dispatch (or retry) a render for a single order line (admin/PM only)."""
result = await db.execute(select(Order).where(Order.id == order_id))
order = result.scalar_one_or_none()
if not order:
raise HTTPException(404, detail="Order not found")
line_result = await db.execute(
select(OrderLine).where(OrderLine.id == line_id, OrderLine.order_id == order.id)
)
line = line_result.scalar_one_or_none()
if not line:
raise HTTPException(404, detail="Order line not found")
if line.render_status not in ("pending", "failed", "cancelled"):
raise HTTPException(400, detail=f"Cannot dispatch line in {line.render_status} status")
# Reset to pending
from sqlalchemy import update as sql_update
await db.execute(
sql_update(OrderLine)
.where(OrderLine.id == line.id)
.values(render_status="pending", render_completed_at=None, render_log=None)
)
# Auto-advance order to processing if needed
if order.status in (OrderStatus.submitted, OrderStatus.completed):
now = datetime.utcnow()
order.status = OrderStatus.processing
order.processing_started_at = now
order.completed_at = None
order.updated_at = now
await db.commit()
from app.domains.rendering.dispatch_service import dispatch_render_with_workflow
try:
dispatch_render_with_workflow(str(line.id))
except Exception as exc:
logger.warning("dispatch_render_with_workflow failed for %s: %s", line.id, exc)
from app.tasks.step_tasks import dispatch_order_line_render
dispatch_order_line_render.delay(str(line.id))
return {"dispatched": True, "line_id": str(line.id)}
class RejectLineBody(BaseModel):
reason: str = ""
+63 -2
View File
@@ -270,12 +270,73 @@ async def delete_product(
raise HTTPException(404, detail="Product not found")
if hard:
from sqlalchemy import delete as sql_delete
# Delete order_lines referencing this product
from app.domains.media.models import MediaAsset
from app.core.storage import get_storage
# 1. Collect storage keys from MediaAssets before cascade deletes them
media_result = await db.execute(
select(MediaAsset.storage_key).where(MediaAsset.product_id == product_id)
)
storage_keys = [row[0] for row in media_result.all() if row[0]]
# 2. Collect render result paths from order lines
ol_result = await db.execute(
select(OrderLine.result_path).where(
OrderLine.product_id == product_id,
OrderLine.result_path.isnot(None),
)
)
result_paths = [row[0] for row in ol_result.all() if row[0]]
# 3. Check if CadFile is used by other products
cad_file_id = product.cad_file_id
orphan_cad = False
if cad_file_id:
other_count = await db.execute(
select(func.count(Product.id)).where(
Product.cad_file_id == cad_file_id,
Product.id != product_id,
)
)
orphan_cad = (other_count.scalar() or 0) == 0
# 4. Delete order_lines referencing this product
await db.execute(sql_delete(OrderLine).where(OrderLine.product_id == product_id))
# 5. Delete orphaned CadFile if no other products reference it
if orphan_cad and cad_file_id:
from app.models.cad_file import CadFile
# Collect CadFile media assets too
cad_media_result = await db.execute(
select(MediaAsset.storage_key).where(MediaAsset.cad_file_id == cad_file_id)
)
storage_keys.extend(row[0] for row in cad_media_result.all() if row[0])
product.cad_file_id = None
await db.flush()
await db.execute(sql_delete(CadFile).where(CadFile.id == cad_file_id))
# 6. Delete product (cascades MediaAsset + ProductRenderPosition)
await db.delete(product)
await db.commit()
# 7. Clean up storage files (best-effort, after commit)
storage = get_storage()
for key in storage_keys:
try:
storage.delete(key)
except Exception:
pass
# Clean up render result files on disk
import os
for path in result_paths:
try:
if os.path.isfile(path):
os.unlink(path)
except Exception:
pass
else:
product.is_active = False
await db.commit()
await db.commit()
@router.post("/{product_id}/cad", status_code=status.HTTP_201_CREATED)
+56 -16
View File
@@ -116,9 +116,11 @@ async def list_render_templates(
@router.post("/render-templates", response_model=RenderTemplateOut, status_code=status.HTTP_201_CREATED)
async def create_render_template(
name: str = Form(...),
file: UploadFile = File(...),
file: UploadFile | None = File(None),
clone_blend_from: str | None = Form(None),
category_key: str | None = Form(None),
output_type_id: str | None = Form(None),
output_type_ids: str | None = Form(None),
target_collection: str = Form("Product"),
material_replace_enabled: bool = Form(False),
lighting_only: bool = Form(False),
@@ -127,30 +129,54 @@ async def create_render_template(
user: User = Depends(require_admin_or_pm),
db: AsyncSession = Depends(get_db),
):
if not file.filename or not file.filename.endswith(".blend"):
raise HTTPException(400, detail="File must be a .blend file")
# Normalise empty strings from form data to None
if category_key == "" or category_key == "null":
category_key = None
if output_type_id == "" or output_type_id == "null":
output_type_id = None
if clone_blend_from == "" or clone_blend_from == "null":
clone_blend_from = None
template_id = uuid.uuid4()
blend_path = _blend_dir() / f"{template_id}.blend"
with open(blend_path, "wb") as f:
shutil.copyfileobj(file.file, f)
if file and file.filename:
if not file.filename.endswith(".blend"):
raise HTTPException(400, detail="File must be a .blend file")
with open(blend_path, "wb") as f:
shutil.copyfileobj(file.file, f)
original_filename = file.filename
final_blend_path = str(blend_path)
elif clone_blend_from:
# Share the same .blend file (no copy — just reference the same path)
source = await db.execute(
select(RenderTemplate).where(RenderTemplate.id == uuid.UUID(clone_blend_from))
)
source_tmpl = source.unique().scalar_one_or_none()
if not source_tmpl:
raise HTTPException(404, detail="Source template not found")
source_path = Path(source_tmpl.blend_file_path)
if not source_path.exists():
raise HTTPException(404, detail="Source .blend file not found on disk")
final_blend_path = source_tmpl.blend_file_path
original_filename = source_tmpl.original_filename
else:
raise HTTPException(400, detail="Provide either a .blend file or clone_blend_from template ID")
ot_uuid = uuid.UUID(output_type_id) if output_type_id else None
# Parse M2M output_type_ids (comma-separated string from FormData)
m2m_ot_ids: list[str] = []
if output_type_ids and output_type_ids.strip():
m2m_ot_ids = [s.strip() for s in output_type_ids.split(",") if s.strip()]
tmpl = RenderTemplate(
id=template_id,
name=name,
category_key=category_key,
output_type_id=ot_uuid,
blend_file_path=str(blend_path),
original_filename=file.filename,
blend_file_path=final_blend_path,
original_filename=original_filename,
target_collection=target_collection,
material_replace_enabled=material_replace_enabled,
lighting_only=lighting_only,
@@ -160,12 +186,13 @@ async def create_render_template(
db.add(tmpl)
await db.flush()
# Sync M2M from initial output_type_id
if ot_uuid:
from app.domains.rendering.models import render_template_output_types
# Sync M2M output types
from app.domains.rendering.models import render_template_output_types
ot_ids_to_link = m2m_ot_ids if m2m_ot_ids else ([str(ot_uuid)] if ot_uuid else [])
for ot_id_str in ot_ids_to_link:
await db.execute(
render_template_output_types.insert().values(
template_id=template_id, output_type_id=ot_uuid,
template_id=template_id, output_type_id=uuid.UUID(ot_id_str),
)
)
@@ -250,9 +277,15 @@ async def delete_render_template(
if not tmpl:
raise HTTPException(404, detail="Render template not found")
# Delete .blend file
# Only delete .blend file if no other template shares it
blend_path = Path(tmpl.blend_file_path)
if blend_path.exists():
other_refs = await db.execute(
select(RenderTemplate.id).where(
RenderTemplate.blend_file_path == tmpl.blend_file_path,
RenderTemplate.id != template_id,
)
)
if not other_refs.first() and blend_path.exists():
blend_path.unlink(missing_ok=True)
await db.execute(sql_delete(RenderTemplate).where(RenderTemplate.id == template_id))
@@ -277,10 +310,17 @@ async def upload_blend_file(
blend_path = _blend_dir() / f"{template_id}.blend"
# Remove old file if path changed
# Only remove old file if no other template shares it
old_path = Path(tmpl.blend_file_path)
if old_path.exists() and old_path != blend_path:
old_path.unlink(missing_ok=True)
other_refs = await db.execute(
select(RenderTemplate.id).where(
RenderTemplate.blend_file_path == tmpl.blend_file_path,
RenderTemplate.id != template_id,
)
)
if not other_refs.first():
old_path.unlink(missing_ok=True)
with open(blend_path, "wb") as f:
shutil.copyfileobj(file.file, f)
+6
View File
@@ -429,14 +429,20 @@ async def scale_workers(
compose_dir = os.environ.get("COMPOSE_PROJECT_DIR", "/compose")
compose_file = os.path.join(compose_dir, "docker-compose.yml")
# Derive project name from compose dir on host (directory name = project name).
# Inside the container the compose file is at /compose, but the host project
# dir name determines the container naming prefix (e.g. "schaefflerautomat").
compose_project = os.environ.get("COMPOSE_PROJECT_NAME", "schaefflerautomat")
def _scale() -> subprocess.CompletedProcess:
return subprocess.run(
[
"docker", "compose",
"-f", compose_file,
"-p", compose_project,
"up",
"--scale", f"{body.service}={body.count}",
"--no-build",
"--no-recreate",
"-d",
],