1142 lines
41 KiB
Python
1142 lines
41 KiB
Python
"""Product library API router."""
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import os
|
|
import re
|
|
import uuid
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
|
from fastapi.responses import StreamingResponse
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import select, or_, text, func
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload, joinedload
|
|
|
|
from app.config import settings
|
|
from app.core.render_paths import (
|
|
resolve_result_path,
|
|
resolve_public_asset_url,
|
|
result_path_to_public_url,
|
|
)
|
|
from app.database import get_db
|
|
from app.models.cad_file import CadFile, ProcessingStatus
|
|
from app.models.material import Material
|
|
from app.models.order import Order
|
|
from app.models.order_line import OrderLine
|
|
from app.models.output_type import OutputType
|
|
from app.models.product import Product
|
|
from app.models.render_position import ProductRenderPosition
|
|
from app.models.system_setting import SystemSetting
|
|
from app.schemas.order import OrderOut
|
|
from app.schemas.product import ProductCreate, ProductOut, ProductPatch
|
|
from app.schemas.render_position import RenderPositionCreate, RenderPositionPatch, RenderPositionOut
|
|
from app.utils.auth import get_current_user, require_admin_or_pm
|
|
from app.models.user import User
|
|
|
|
router = APIRouter(prefix="/products", tags=["products"])
|
|
|
|
|
|
class BatchDeleteRequest(BaseModel):
|
|
product_ids: list[uuid.UUID]
|
|
hard: bool = False
|
|
|
|
|
|
@router.post("/batch-delete", status_code=status.HTTP_200_OK)
|
|
async def batch_delete_products(
|
|
body: BatchDeleteRequest,
|
|
user: User = Depends(require_admin_or_pm),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Delete or deactivate multiple products at once.
|
|
|
|
When hard=False (default), products are soft-deleted (is_active=False).
|
|
When hard=True, products and their related data are permanently removed
|
|
using the same cleanup logic as the single-product delete endpoint.
|
|
"""
|
|
from sqlalchemy import delete as sql_delete
|
|
|
|
if not body.product_ids:
|
|
return {"deleted": 0, "not_found": 0}
|
|
|
|
# Deduplicate
|
|
product_ids = list(set(body.product_ids))
|
|
|
|
# Load all products
|
|
result = await db.execute(
|
|
select(Product).where(Product.id.in_(product_ids))
|
|
)
|
|
products_found = {p.id: p for p in result.scalars().all()}
|
|
not_found = len(product_ids) - len(products_found)
|
|
|
|
if not products_found:
|
|
return {"deleted": 0, "not_found": not_found}
|
|
|
|
if not body.hard:
|
|
# Soft delete: deactivate all found products
|
|
for product in products_found.values():
|
|
product.is_active = False
|
|
await db.commit()
|
|
return {"deleted": len(products_found), "not_found": not_found}
|
|
|
|
# Hard delete: reuse single-delete cleanup logic per product
|
|
from app.domains.media.models import MediaAsset
|
|
from app.core.storage import get_storage
|
|
|
|
all_storage_keys: list[str] = []
|
|
all_result_paths: list[str] = []
|
|
|
|
for pid, product in products_found.items():
|
|
# 1. Collect storage keys from MediaAssets
|
|
media_result = await db.execute(
|
|
select(MediaAsset.storage_key).where(MediaAsset.product_id == pid)
|
|
)
|
|
all_storage_keys.extend(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 == pid,
|
|
OrderLine.result_path.isnot(None),
|
|
)
|
|
)
|
|
all_result_paths.extend(row[0] for row in ol_result.all() if row[0])
|
|
|
|
# 3. Check if CadFile is orphaned
|
|
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.notin_([p for p in products_found]),
|
|
)
|
|
)
|
|
orphan_cad = (other_count.scalar() or 0) == 0
|
|
|
|
# 4. Delete order_lines
|
|
await db.execute(sql_delete(OrderLine).where(OrderLine.product_id == pid))
|
|
|
|
# 5. Delete orphaned CadFile
|
|
if orphan_cad and cad_file_id:
|
|
cad_media_result = await db.execute(
|
|
select(MediaAsset.storage_key).where(MediaAsset.cad_file_id == cad_file_id)
|
|
)
|
|
all_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 all_storage_keys:
|
|
try:
|
|
storage.delete(key)
|
|
except Exception:
|
|
pass
|
|
for path in all_result_paths:
|
|
try:
|
|
if os.path.isfile(path):
|
|
os.unlink(path)
|
|
except Exception:
|
|
pass
|
|
|
|
return {"deleted": len(products_found), "not_found": not_found}
|
|
|
|
|
|
def _best_render_url(product: Product, priority: list[str]) -> str | None:
|
|
"""Walk the priority list and return the first available render URL.
|
|
|
|
Each entry in priority is tried in order:
|
|
"cad_thumbnail" — stop and return None (caller shows STEP thumbnail)
|
|
"latest_render" — pick newest completed render regardless of output type
|
|
<UUID string> — pick newest render of that specific output type
|
|
|
|
Returns None if nothing is found (or "cad_thumbnail" is reached first).
|
|
"""
|
|
for source in priority:
|
|
if source == "cad_thumbnail":
|
|
return None # Signal to caller to show STEP thumbnail
|
|
|
|
filter_ot_id: str | None = None if source == "latest_render" else source
|
|
|
|
best = None
|
|
best_time = None
|
|
for line in product.order_lines:
|
|
if line.render_status != "completed" or not line.result_path:
|
|
continue
|
|
if filter_ot_id is not None and str(line.output_type_id) != filter_ot_id:
|
|
continue
|
|
url = _result_path_to_url(line.result_path)
|
|
if url and (best_time is None or (line.render_completed_at and line.render_completed_at > best_time)):
|
|
disk = _resolve_disk_path(url)
|
|
if disk and disk.exists():
|
|
best = url
|
|
best_time = line.render_completed_at
|
|
|
|
if best:
|
|
return best # Found a match for this priority entry
|
|
|
|
return None # Nothing found in the entire priority list
|
|
|
|
|
|
def _product_out(product: Product, priority: list[str] | None = None) -> ProductOut:
|
|
out = ProductOut.model_validate(product)
|
|
out.thumbnail_url = product.thumbnail_url
|
|
out.processing_status = product.processing_status
|
|
out.cad_parsed_objects = product.cad_parsed_objects
|
|
out.cad_mesh_attributes = product.cad_file.mesh_attributes if product.cad_file else None
|
|
out.cad_render_log = product.cad_file.render_log if product.cad_file else None
|
|
out.render_image_url = _best_render_url(product, priority or ["latest_render", "cad_thumbnail"])
|
|
return out
|
|
|
|
|
|
async def _load_thumbnail_priority(db: AsyncSession) -> list[str]:
|
|
"""Read product_thumbnail_priority from system_settings.
|
|
|
|
Falls back to ["latest_render", "cad_thumbnail"] (legacy behaviour).
|
|
Also reads the old product_thumbnail_source key for backward compatibility.
|
|
"""
|
|
row = await db.execute(
|
|
select(SystemSetting).where(SystemSetting.key == "product_thumbnail_priority")
|
|
)
|
|
setting = row.scalar_one_or_none()
|
|
if setting:
|
|
try:
|
|
parsed = json.loads(setting.value)
|
|
if isinstance(parsed, list) and parsed:
|
|
return parsed
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass
|
|
|
|
# Legacy fallback: read old product_thumbnail_source key
|
|
legacy_row = await db.execute(
|
|
select(SystemSetting).where(SystemSetting.key == "product_thumbnail_source")
|
|
)
|
|
legacy = legacy_row.scalar_one_or_none()
|
|
if legacy:
|
|
src = legacy.value
|
|
if src == "cad_thumbnail":
|
|
return ["cad_thumbnail"]
|
|
elif src == "latest_render":
|
|
return ["latest_render", "cad_thumbnail"]
|
|
else:
|
|
return [src, "latest_render", "cad_thumbnail"]
|
|
|
|
return ["latest_render", "cad_thumbnail"]
|
|
|
|
|
|
@router.get("", response_model=list[ProductOut])
|
|
async def list_products(
|
|
q: str = Query(""),
|
|
category_key: str = Query(""),
|
|
has_cad: bool | None = Query(None),
|
|
ready_only: bool = Query(False),
|
|
materials_filter: str = Query(""), # "complete" | "incomplete" | ""
|
|
skip: int = Query(0, ge=0),
|
|
limit: int = Query(50, ge=1, le=200),
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
stmt = (
|
|
select(Product)
|
|
.options(
|
|
selectinload(Product.cad_file),
|
|
selectinload(Product.order_lines),
|
|
selectinload(Product.render_positions),
|
|
)
|
|
.where(Product.is_active.is_(True))
|
|
)
|
|
if q:
|
|
pattern = f"%{q}%"
|
|
stmt = stmt.where(
|
|
or_(Product.pim_id.ilike(pattern), Product.name.ilike(pattern))
|
|
)
|
|
if category_key:
|
|
stmt = stmt.where(Product.category_key == category_key)
|
|
if ready_only:
|
|
stmt = stmt.where(Product.cad_file_id.is_not(None))
|
|
elif has_cad is True:
|
|
stmt = stmt.where(Product.cad_file_id.is_not(None))
|
|
elif has_cad is False:
|
|
stmt = stmt.where(Product.cad_file_id.is_(None))
|
|
if materials_filter == "incomplete":
|
|
# STEP processed, but cad_part_materials is empty or has at least one blank entry.
|
|
stmt = stmt.join(CadFile, CadFile.id == Product.cad_file_id).where(
|
|
CadFile.processing_status == ProcessingStatus.completed,
|
|
text(
|
|
"("
|
|
" jsonb_array_length(products.cad_part_materials) = 0"
|
|
" OR EXISTS ("
|
|
" SELECT 1 FROM jsonb_array_elements(products.cad_part_materials) AS m"
|
|
" WHERE coalesce(m->>'material', '') = ''"
|
|
" )"
|
|
")"
|
|
),
|
|
)
|
|
elif materials_filter == "complete":
|
|
# STEP processed, cad_part_materials non-empty, and every entry has a material.
|
|
stmt = stmt.join(CadFile, CadFile.id == Product.cad_file_id).where(
|
|
CadFile.processing_status == ProcessingStatus.completed,
|
|
text(
|
|
"("
|
|
" jsonb_array_length(products.cad_part_materials) > 0"
|
|
" AND NOT EXISTS ("
|
|
" SELECT 1 FROM jsonb_array_elements(products.cad_part_materials) AS m"
|
|
" WHERE coalesce(m->>'material', '') = ''"
|
|
" )"
|
|
")"
|
|
),
|
|
)
|
|
|
|
stmt = stmt.order_by(Product.updated_at.desc()).offset(skip).limit(limit)
|
|
result = await db.execute(stmt)
|
|
products = result.scalars().all()
|
|
priority = await _load_thumbnail_priority(db)
|
|
return [_product_out(p, priority) for p in products]
|
|
|
|
|
|
@router.post("", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
|
|
async def create_product(
|
|
body: ProductCreate,
|
|
user: User = Depends(require_admin_or_pm),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
existing = await db.execute(select(Product).where(Product.pim_id == body.pim_id))
|
|
if existing.scalar_one_or_none():
|
|
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(), tenant_id=getattr(user, 'tenant_id', None))
|
|
db.add(product)
|
|
await db.flush()
|
|
await create_default_positions(db, product.id)
|
|
await db.commit()
|
|
result = await db.execute(
|
|
select(Product)
|
|
.options(
|
|
selectinload(Product.cad_file),
|
|
selectinload(Product.order_lines),
|
|
selectinload(Product.render_positions),
|
|
)
|
|
.where(Product.id == product.id)
|
|
)
|
|
return _product_out(result.scalar_one())
|
|
|
|
|
|
@router.get("/{product_id}", response_model=ProductOut)
|
|
async def get_product(
|
|
product_id: uuid.UUID,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
result = await db.execute(
|
|
select(Product)
|
|
.options(
|
|
selectinload(Product.cad_file),
|
|
selectinload(Product.order_lines),
|
|
selectinload(Product.render_positions),
|
|
)
|
|
.where(Product.id == product_id)
|
|
)
|
|
product = result.scalar_one_or_none()
|
|
if not product:
|
|
raise HTTPException(404, detail="Product not found")
|
|
priority = await _load_thumbnail_priority(db)
|
|
return _product_out(product, priority)
|
|
|
|
|
|
@router.patch("/{product_id}", response_model=ProductOut)
|
|
async def update_product(
|
|
product_id: uuid.UUID,
|
|
body: ProductPatch,
|
|
user: User = Depends(require_admin_or_pm),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
result = await db.execute(
|
|
select(Product).options(selectinload(Product.cad_file)).where(Product.id == product_id)
|
|
)
|
|
product = result.scalar_one_or_none()
|
|
if not product:
|
|
raise HTTPException(404, detail="Product not found")
|
|
|
|
for field_name, value in body.model_dump(exclude_unset=True).items():
|
|
setattr(product, field_name, value)
|
|
await db.commit()
|
|
await db.refresh(product)
|
|
return _product_out(product)
|
|
|
|
|
|
@router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_product(
|
|
product_id: uuid.UUID,
|
|
hard: bool = Query(False, description="Hard delete (permanent) instead of soft delete"),
|
|
user: User = Depends(require_admin_or_pm),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
result = await db.execute(select(Product).where(Product.id == product_id))
|
|
product = result.scalar_one_or_none()
|
|
if not product:
|
|
raise HTTPException(404, detail="Product not found")
|
|
if hard:
|
|
from sqlalchemy import delete as sql_delete
|
|
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()
|
|
|
|
|
|
@router.post("/{product_id}/cad", status_code=status.HTTP_201_CREATED)
|
|
async def upload_product_cad(
|
|
product_id: uuid.UUID,
|
|
file: UploadFile = File(...),
|
|
user: User = Depends(require_admin_or_pm),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Upload or replace the STEP file for a product."""
|
|
suffix = Path(file.filename or "").suffix.lower()
|
|
if suffix not in {".stp", ".step"}:
|
|
raise HTTPException(400, detail="Only .stp / .step files are accepted")
|
|
|
|
result = await db.execute(
|
|
select(Product).options(selectinload(Product.cad_file)).where(Product.id == product_id)
|
|
)
|
|
product = result.scalar_one_or_none()
|
|
if not product:
|
|
raise HTTPException(404, detail="Product not found")
|
|
|
|
# Check for STEP replacement warnings before proceeding
|
|
warnings: list[str] = []
|
|
existing_render_count = 0
|
|
old_cad_file_id = product.cad_file_id
|
|
|
|
content = await file.read()
|
|
file_hash = hashlib.sha256(content).hexdigest()
|
|
|
|
# Dedup by hash
|
|
existing_cad = await db.execute(select(CadFile).where(CadFile.file_hash == file_hash))
|
|
cad_file = existing_cad.scalar_one_or_none()
|
|
|
|
# Detect replacement: product already has a different CAD file
|
|
if old_cad_file_id and (cad_file is None or cad_file.id != old_cad_file_id):
|
|
old_name = product.cad_file.original_name if product.cad_file else "unknown"
|
|
warnings.append(
|
|
f"Replacing existing STEP file '{old_name}' with '{file.filename}'."
|
|
)
|
|
# Count existing renders (MediaAssets) for this product
|
|
from app.domains.media.models import MediaAsset
|
|
render_count_result = await db.execute(
|
|
select(func.count(MediaAsset.id)).where(MediaAsset.product_id == product_id)
|
|
)
|
|
existing_render_count = render_count_result.scalar() or 0
|
|
if existing_render_count > 0:
|
|
warnings.append(
|
|
f"This product has {existing_render_count} existing render(s) that were "
|
|
"generated from the previous STEP file. They may no longer match."
|
|
)
|
|
|
|
if cad_file is None:
|
|
step_dir = Path(settings.upload_dir) / "step_files"
|
|
step_dir.mkdir(parents=True, exist_ok=True)
|
|
stored_name = f"{uuid.uuid4()}{suffix}"
|
|
stored_path = step_dir / stored_name
|
|
stored_path.write_bytes(content)
|
|
|
|
cad_file = CadFile(
|
|
original_name=file.filename,
|
|
stored_path=str(stored_path),
|
|
file_hash=file_hash,
|
|
file_size=len(content),
|
|
processing_status=ProcessingStatus.pending,
|
|
)
|
|
db.add(cad_file)
|
|
await db.commit()
|
|
await db.refresh(cad_file)
|
|
|
|
try:
|
|
from app.tasks.step_tasks import process_step_file
|
|
process_step_file.delay(str(cad_file.id))
|
|
except Exception:
|
|
pass
|
|
|
|
# Link to product
|
|
from app.services.product_service import link_cad_to_product
|
|
product = await link_cad_to_product(db, product_id, cad_file.id)
|
|
|
|
return {
|
|
"cad_file_id": str(cad_file.id),
|
|
"original_name": cad_file.original_name,
|
|
"file_hash": file_hash,
|
|
"status": "uploaded" if cad_file.processing_status == ProcessingStatus.pending else "already_exists",
|
|
"product_id": str(product_id),
|
|
"warnings": warnings,
|
|
"existing_render_count": existing_render_count,
|
|
}
|
|
|
|
|
|
@router.post("/{product_id}/cad-materials", response_model=ProductOut)
|
|
async def save_product_cad_materials(
|
|
product_id: uuid.UUID,
|
|
body: dict,
|
|
user: User = Depends(require_admin_or_pm),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Save cad_part_materials and enqueue thumbnail regeneration."""
|
|
result = await db.execute(
|
|
select(Product)
|
|
.options(
|
|
selectinload(Product.cad_file),
|
|
selectinload(Product.order_lines),
|
|
selectinload(Product.render_positions),
|
|
)
|
|
.where(Product.id == product_id)
|
|
)
|
|
product = result.scalar_one_or_none()
|
|
if not product:
|
|
raise HTTPException(404, detail="Product not found")
|
|
|
|
parts = body.get("parts", [])
|
|
product.cad_part_materials = parts
|
|
|
|
# Auto-add new material names to the materials library
|
|
material_names = {p["material"].strip() for p in parts if p.get("material", "").strip()}
|
|
if material_names:
|
|
existing = await db.execute(
|
|
select(Material).where(
|
|
or_(*[Material.name.ilike(name) for name in material_names])
|
|
)
|
|
)
|
|
existing_names = {m.name.lower() for m in existing.scalars().all()}
|
|
for name in material_names:
|
|
if name.lower() not in existing_names:
|
|
db.add(Material(name=name, source="product_assign", created_by=user.id))
|
|
|
|
await db.commit()
|
|
|
|
if product.cad_file_id:
|
|
try:
|
|
from app.services.step_processor import build_part_colors
|
|
from app.tasks.step_tasks import regenerate_thumbnail
|
|
parsed_objects = product.cad_parsed_objects or []
|
|
part_colors = build_part_colors(parsed_objects, parts)
|
|
regenerate_thumbnail.delay(str(product.cad_file_id), part_colors)
|
|
except Exception:
|
|
pass
|
|
|
|
# Re-fetch with all relationships for _product_out
|
|
result2 = await db.execute(
|
|
select(Product)
|
|
.options(
|
|
selectinload(Product.cad_file),
|
|
selectinload(Product.order_lines),
|
|
selectinload(Product.render_positions),
|
|
)
|
|
.where(Product.id == product_id)
|
|
)
|
|
return _product_out(result2.scalar_one())
|
|
|
|
|
|
@router.post("/{product_id}/regenerate", status_code=status.HTTP_202_ACCEPTED)
|
|
async def regenerate_product_thumbnail(
|
|
product_id: uuid.UUID,
|
|
user: User = Depends(require_admin_or_pm),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Re-queue thumbnail generation with current part_colors."""
|
|
result = await db.execute(
|
|
select(Product).options(selectinload(Product.cad_file)).where(Product.id == product_id)
|
|
)
|
|
product = result.scalar_one_or_none()
|
|
if not product:
|
|
raise HTTPException(404, detail="Product not found")
|
|
if not product.cad_file_id:
|
|
raise HTTPException(400, detail="Product has no CAD file")
|
|
|
|
try:
|
|
from app.services.step_processor import build_part_colors
|
|
from app.tasks.step_tasks import regenerate_thumbnail
|
|
parsed_objects = product.cad_parsed_objects or []
|
|
part_colors = build_part_colors(parsed_objects, product.cad_part_materials or [])
|
|
task = regenerate_thumbnail.delay(str(product.cad_file_id), part_colors)
|
|
return {"status": "queued", "task_id": str(task.id)}
|
|
except Exception as exc:
|
|
raise HTTPException(500, detail=f"Failed to enqueue: {exc}")
|
|
|
|
|
|
def _normalize_part_token_name(name: str) -> str:
|
|
"""Lowercase, strip .prt extension and noise suffixes, normalise separators to underscore."""
|
|
import re as _re
|
|
name = name.lower().strip()
|
|
if name.endswith(".prt"):
|
|
name = name[:-4]
|
|
# Strip OCC assembly-instance suffixes (_AF0, _AF1 …) that RWGltf_CafWriter adds
|
|
prev = None
|
|
while prev != name:
|
|
prev = name
|
|
name = _re.sub(r"_af\d+$", "", name)
|
|
# Strip trailing numeric hash suffixes from Excel .prt filenames (e.g. -21227)
|
|
name = _re.sub(r"-\d{4,}$", "", name)
|
|
# Hyphens and dots → underscores for uniform token splitting
|
|
return _re.sub(r"[-.]", "_", name)
|
|
|
|
|
|
def _part_tokens(name: str) -> set[str]:
|
|
"""Return significant tokens: length ≥ 2, not pure-numeric, contains a letter."""
|
|
return {
|
|
t for t in name.split("_")
|
|
if len(t) >= 2 and not t.isdigit() and any(c.isalpha() for c in t)
|
|
}
|
|
|
|
|
|
def _jaccard(a: set, b: set) -> float:
|
|
if not a or not b:
|
|
return 0.0
|
|
return len(a & b) / len(a | b)
|
|
|
|
|
|
def build_materials_from_excel(
|
|
cad_parts: list[str],
|
|
excel_components: list[dict],
|
|
similarity_threshold: float = 0.35,
|
|
) -> list[dict]:
|
|
"""Match CAD part names to Excel components and return cad_part_materials list.
|
|
|
|
Pure function — no DB access, sync-safe, callable from Celery tasks.
|
|
|
|
Matching strategy per CAD part (in order):
|
|
1. Exact case-insensitive name match
|
|
2. Token-based Jaccard similarity on normalised filenames
|
|
3. Prefix-based fallback (one normalised name is a prefix of the other)
|
|
4. Position-based fallback for remaining low-confidence cases
|
|
"""
|
|
excel_entries: list[tuple[set[str], str, str, str]] = []
|
|
for c in excel_components:
|
|
raw = (c.get("part_name") or "").lower().strip()
|
|
norm = _normalize_part_token_name(raw)
|
|
tokens = _part_tokens(norm)
|
|
excel_entries.append((tokens, raw, c.get("material") or "", norm))
|
|
|
|
new_materials: list[dict] = []
|
|
for i, cad_part in enumerate(cad_parts):
|
|
cad_raw_lower = cad_part.lower()
|
|
cad_norm = _normalize_part_token_name(cad_raw_lower)
|
|
cad_tokens = _part_tokens(cad_norm)
|
|
|
|
best_mat = ""
|
|
best_score = 0.0
|
|
|
|
for tokens, raw, material, excel_norm in excel_entries:
|
|
# 1. Exact match
|
|
if raw == cad_raw_lower:
|
|
best_mat = material
|
|
best_score = 1.0
|
|
break
|
|
# 2. Jaccard similarity
|
|
score = _jaccard(tokens, cad_tokens)
|
|
if score > best_score:
|
|
best_score = score
|
|
best_mat = material
|
|
|
|
# 3. Prefix fallback when Jaccard is below threshold but not zero:
|
|
# one normalised name starts with the other (handles sub-assembly variants)
|
|
if best_score < similarity_threshold:
|
|
for tokens, raw, material, excel_norm in excel_entries:
|
|
if len(excel_norm) >= 5 and len(cad_norm) >= 5 and (
|
|
cad_norm.startswith(excel_norm) or excel_norm.startswith(cad_norm)
|
|
):
|
|
best_mat = material
|
|
best_score = 0.7
|
|
break
|
|
|
|
# 4. Position-based fallback
|
|
if best_score < similarity_threshold:
|
|
if i < len(excel_components):
|
|
best_mat = excel_components[i].get("material") or ""
|
|
|
|
new_materials.append({"part_name": cad_part, "material": best_mat})
|
|
|
|
return new_materials
|
|
|
|
|
|
@router.post("/{product_id}/reassign-materials-from-excel", response_model=ProductOut)
|
|
async def reassign_materials_from_excel(
|
|
product_id: uuid.UUID,
|
|
user: User = Depends(require_admin_or_pm),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Populate cad_part_materials from Excel component data stored on this product.
|
|
|
|
Matching strategy (applied per CAD part in order):
|
|
1. Exact case-insensitive name match (for generic semantic names like "Außenring")
|
|
2. Token-based Jaccard similarity on normalised part filenames:
|
|
Excel stores the .prt filename; OCC extracts assembly instance names derived
|
|
from the same file. Stripping extensions, separators and numeric-only tokens
|
|
lets them be compared reliably (e.g. "z-563681_krk_tr_jpb_dummy-90771.prt"
|
|
↔ "Z-563681_KRK_JPB_DUMMY_1_AF0_1" → Jaccard ≈ 0.6).
|
|
3. Position-based fallback for low-confidence matches.
|
|
|
|
After this the Part Materials UI shows pre-filled materials that can be
|
|
reviewed/adjusted before saving. Thumbnail regeneration is queued automatically.
|
|
"""
|
|
result = await db.execute(
|
|
select(Product)
|
|
.options(selectinload(Product.cad_file), selectinload(Product.order_lines))
|
|
.where(Product.id == product_id)
|
|
)
|
|
product = result.scalar_one_or_none()
|
|
if not product:
|
|
raise HTTPException(404, detail="Product not found")
|
|
|
|
cad_parts: list[str] = product.cad_parsed_objects or []
|
|
if not cad_parts:
|
|
raise HTTPException(
|
|
400,
|
|
detail="No parsed CAD parts found. Use 'Re-process STEP' first to extract part names.",
|
|
)
|
|
|
|
excel_components: list[dict] = product.components or []
|
|
if not excel_components:
|
|
raise HTTPException(
|
|
400,
|
|
detail="No Excel component data found on this product. Was it imported from an Excel file?",
|
|
)
|
|
|
|
new_materials = build_materials_from_excel(cad_parts, excel_components)
|
|
product.cad_part_materials = new_materials
|
|
await db.commit()
|
|
|
|
if product.cad_file_id:
|
|
try:
|
|
from app.services.step_processor import build_part_colors
|
|
from app.tasks.step_tasks import regenerate_thumbnail
|
|
part_colors = build_part_colors(cad_parts, new_materials)
|
|
regenerate_thumbnail.delay(str(product.cad_file_id), part_colors)
|
|
except Exception:
|
|
pass
|
|
|
|
result2 = await db.execute(
|
|
select(Product)
|
|
.options(
|
|
selectinload(Product.cad_file),
|
|
selectinload(Product.order_lines),
|
|
selectinload(Product.render_positions),
|
|
)
|
|
.where(Product.id == product_id)
|
|
)
|
|
return _product_out(result2.scalar_one())
|
|
|
|
|
|
@router.post("/{product_id}/reprocess", status_code=status.HTTP_202_ACCEPTED)
|
|
async def reprocess_product_cad(
|
|
product_id: uuid.UUID,
|
|
user: User = Depends(require_admin_or_pm),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Re-queue full STEP processing (parse objects + generate thumbnail) for a product."""
|
|
result = await db.execute(
|
|
select(Product).options(selectinload(Product.cad_file)).where(Product.id == product_id)
|
|
)
|
|
product = result.scalar_one_or_none()
|
|
if not product:
|
|
raise HTTPException(404, detail="Product not found")
|
|
if not product.cad_file_id:
|
|
raise HTTPException(400, detail="Product has no CAD file")
|
|
|
|
try:
|
|
from app.models.cad_file import ProcessingStatus as PS
|
|
from sqlalchemy import update as sql_update
|
|
await db.execute(
|
|
sql_update(CadFile)
|
|
.where(CadFile.id == product.cad_file_id)
|
|
.values(processing_status=PS.pending, parsed_objects=None)
|
|
)
|
|
await db.commit()
|
|
|
|
from app.tasks.step_tasks import process_step_file
|
|
task = process_step_file.delay(str(product.cad_file_id))
|
|
return {"status": "queued", "task_id": str(task.id)}
|
|
except Exception as exc:
|
|
raise HTTPException(500, detail=f"Failed to enqueue: {exc}")
|
|
|
|
|
|
VIDEO_EXTENSIONS = {".mp4", ".webm", ".avi", ".mov"}
|
|
|
|
|
|
def _result_path_to_url(result_path: str) -> str | None:
|
|
"""Convert an internal result_path to a servable static URL."""
|
|
return result_path_to_public_url(result_path, require_exists=False)
|
|
|
|
|
|
def _resolve_disk_path(url: str) -> Path | None:
|
|
"""Given a servable URL like /renders/X/file.jpg, resolve to disk path."""
|
|
return resolve_public_asset_url(url)
|
|
|
|
|
|
@router.get("/{product_id}/renders")
|
|
async def get_product_renders(
|
|
product_id: uuid.UUID,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""List completed render outputs for a product."""
|
|
result = await db.execute(
|
|
select(OrderLine)
|
|
.options(
|
|
joinedload(OrderLine.output_type),
|
|
joinedload(OrderLine.order),
|
|
joinedload(OrderLine.render_position),
|
|
joinedload(OrderLine.global_render_position),
|
|
)
|
|
.where(
|
|
OrderLine.product_id == product_id,
|
|
OrderLine.render_status == "completed",
|
|
OrderLine.result_path.is_not(None),
|
|
)
|
|
.order_by(OrderLine.render_completed_at.desc())
|
|
)
|
|
lines = result.unique().scalars().all()
|
|
|
|
renders = []
|
|
for line in lines:
|
|
url = _result_path_to_url(line.result_path)
|
|
if url is None:
|
|
continue
|
|
disk = _resolve_disk_path(url)
|
|
if disk is None or not disk.exists():
|
|
continue
|
|
ext = Path(url).suffix.lower()
|
|
position_name = (
|
|
line.render_position.name if line.render_position
|
|
else line.global_render_position.name if line.global_render_position
|
|
else None
|
|
)
|
|
renders.append({
|
|
"order_line_id": str(line.id),
|
|
"order_number": line.order.order_number if line.order else None,
|
|
"output_type_name": line.output_type.name if line.output_type else None,
|
|
"render_url": url,
|
|
"is_video": ext in VIDEO_EXTENSIONS,
|
|
"render_backend": line.render_backend_used,
|
|
"completed_at": line.render_completed_at.isoformat() if line.render_completed_at else None,
|
|
"render_position_name": position_name,
|
|
})
|
|
return renders
|
|
|
|
|
|
@router.delete("/{product_id}/renders/{order_line_id}", status_code=204)
|
|
async def delete_product_render(
|
|
product_id: uuid.UUID,
|
|
order_line_id: uuid.UUID,
|
|
user: User = Depends(require_admin_or_pm),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Delete a render output for a product.
|
|
|
|
Removes the file from disk, clears result_path, and resets render_status to
|
|
'pending' so the line can be re-dispatched if needed.
|
|
"""
|
|
from sqlalchemy import update as sql_update
|
|
|
|
result = await db.execute(
|
|
select(OrderLine).where(
|
|
OrderLine.id == order_line_id,
|
|
OrderLine.product_id == product_id,
|
|
)
|
|
)
|
|
line = result.scalar_one_or_none()
|
|
if line is None:
|
|
raise HTTPException(404, detail="Render not found for this product")
|
|
|
|
# Delete file from disk
|
|
if line.result_path:
|
|
url = _result_path_to_url(line.result_path)
|
|
if url:
|
|
disk = _resolve_disk_path(url)
|
|
if disk and disk.exists():
|
|
try:
|
|
disk.unlink()
|
|
except OSError as exc:
|
|
# Log but don't fail — DB cleanup still proceeds
|
|
import logging
|
|
logging.getLogger(__name__).warning(
|
|
f"Could not delete render file {disk}: {exc}"
|
|
)
|
|
|
|
await db.execute(
|
|
sql_update(OrderLine)
|
|
.where(OrderLine.id == order_line_id)
|
|
.values(result_path=None, render_status="pending", render_completed_at=None)
|
|
)
|
|
await db.commit()
|
|
|
|
|
|
class DownloadRendersRequest(BaseModel):
|
|
order_line_ids: list[uuid.UUID]
|
|
|
|
|
|
@router.post("/{product_id}/download-renders")
|
|
async def download_product_renders(
|
|
product_id: uuid.UUID,
|
|
body: DownloadRendersRequest,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Stream a ZIP of selected completed render files for a product."""
|
|
prod_result = await db.execute(select(Product).where(Product.id == product_id))
|
|
product = prod_result.scalar_one_or_none()
|
|
if not product:
|
|
raise HTTPException(404, detail="Product not found")
|
|
|
|
lines_result = await db.execute(
|
|
select(OrderLine)
|
|
.options(
|
|
joinedload(OrderLine.output_type),
|
|
joinedload(OrderLine.order),
|
|
)
|
|
.where(
|
|
OrderLine.id.in_(body.order_line_ids),
|
|
OrderLine.product_id == product_id,
|
|
OrderLine.render_status == "completed",
|
|
OrderLine.result_path.is_not(None),
|
|
)
|
|
)
|
|
lines = lines_result.unique().scalars().all()
|
|
|
|
if not lines:
|
|
raise HTTPException(404, detail="No completed renders found for the selected lines")
|
|
|
|
def _resolve_path(p: str) -> str:
|
|
resolved = resolve_result_path(p)
|
|
return str(resolved) if resolved is not None else p
|
|
|
|
def _safe(s: str) -> str:
|
|
return re.sub(r"[^\w\-.]", "_", s).strip("_")
|
|
|
|
buf = io.BytesIO()
|
|
name_counts: dict[str, int] = {}
|
|
|
|
with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
|
|
for line in lines:
|
|
if not line.result_path:
|
|
continue
|
|
fs_path = _resolve_path(line.result_path)
|
|
if not os.path.isfile(fs_path):
|
|
continue
|
|
ot_name = (line.output_type.name if line.output_type else None) or "render"
|
|
order_num = (line.order.order_number if line.order else None) or "unknown"
|
|
ext = os.path.splitext(line.result_path)[1] or ".png"
|
|
base_name = f"{_safe(ot_name)}_{_safe(order_num)}{ext}"
|
|
if base_name in name_counts:
|
|
name_counts[base_name] += 1
|
|
stem, suffix = os.path.splitext(base_name)
|
|
archive_name = f"{stem}_{name_counts[base_name]}{suffix}"
|
|
else:
|
|
name_counts[base_name] = 0
|
|
archive_name = base_name
|
|
zf.write(fs_path, archive_name)
|
|
|
|
if not zf.infolist():
|
|
raise HTTPException(404, detail="No render files found on disk")
|
|
|
|
buf.seek(0)
|
|
product_name = product.name or product.pim_id or "product"
|
|
safe_name = re.sub(r"[^\w\-]", "_", product_name)
|
|
filename = f"{safe_name}_renders.zip"
|
|
|
|
return StreamingResponse(
|
|
buf,
|
|
media_type="application/zip",
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
)
|
|
|
|
|
|
@router.get("/{product_id}/orders", response_model=list[OrderOut])
|
|
async def get_product_orders(
|
|
product_id: uuid.UUID,
|
|
user: User = Depends(require_admin_or_pm),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""List orders that reference this product via order_lines."""
|
|
from app.models.order import Order
|
|
from sqlalchemy import func
|
|
|
|
result = await db.execute(
|
|
select(Order)
|
|
.join(OrderLine, OrderLine.order_id == Order.id)
|
|
.where(OrderLine.product_id == product_id)
|
|
.distinct()
|
|
.order_by(Order.created_at.desc())
|
|
)
|
|
orders = result.scalars().all()
|
|
|
|
out = []
|
|
for order in orders:
|
|
d = OrderOut.model_validate(order)
|
|
cnt = await db.execute(
|
|
select(func.count(OrderLine.id)).where(OrderLine.order_id == order.id)
|
|
)
|
|
d.line_count = cnt.scalar() or 0
|
|
out.append(d)
|
|
return out
|
|
|
|
|
|
# ── Render Positions CRUD ────────────────────────────────────────────────────
|
|
|
|
@router.get("/{product_id}/render-positions", response_model=list[RenderPositionOut])
|
|
async def list_render_positions(
|
|
product_id: uuid.UUID,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
result = await db.execute(
|
|
select(ProductRenderPosition)
|
|
.where(ProductRenderPosition.product_id == product_id)
|
|
.order_by(ProductRenderPosition.sort_order, ProductRenderPosition.name)
|
|
)
|
|
return result.scalars().all()
|
|
|
|
|
|
@router.post(
|
|
"/{product_id}/render-positions",
|
|
response_model=RenderPositionOut,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
async def create_render_position(
|
|
product_id: uuid.UUID,
|
|
body: RenderPositionCreate,
|
|
user: User = Depends(require_admin_or_pm),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
prod = await db.execute(select(Product).where(Product.id == product_id))
|
|
if not prod.scalar_one_or_none():
|
|
raise HTTPException(404, detail="Product not found")
|
|
|
|
pos = ProductRenderPosition(product_id=product_id, **body.model_dump())
|
|
db.add(pos)
|
|
try:
|
|
await db.commit()
|
|
except Exception:
|
|
await db.rollback()
|
|
raise HTTPException(409, detail=f"Position named '{body.name}' already exists for this product")
|
|
await db.refresh(pos)
|
|
return pos
|
|
|
|
|
|
@router.patch("/{product_id}/render-positions/{pos_id}", response_model=RenderPositionOut)
|
|
async def update_render_position(
|
|
product_id: uuid.UUID,
|
|
pos_id: uuid.UUID,
|
|
body: RenderPositionPatch,
|
|
user: User = Depends(require_admin_or_pm),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
result = await db.execute(
|
|
select(ProductRenderPosition).where(
|
|
ProductRenderPosition.id == pos_id,
|
|
ProductRenderPosition.product_id == product_id,
|
|
)
|
|
)
|
|
pos = result.scalar_one_or_none()
|
|
if not pos:
|
|
raise HTTPException(404, detail="Render position not found")
|
|
|
|
for field, value in body.model_dump(exclude_unset=True).items():
|
|
setattr(pos, field, value)
|
|
try:
|
|
await db.commit()
|
|
except Exception:
|
|
await db.rollback()
|
|
raise HTTPException(409, detail="Name already exists for this product")
|
|
await db.refresh(pos)
|
|
return pos
|
|
|
|
|
|
@router.delete("/{product_id}/render-positions/{pos_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_render_position(
|
|
product_id: uuid.UUID,
|
|
pos_id: uuid.UUID,
|
|
user: User = Depends(require_admin_or_pm),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
result = await db.execute(
|
|
select(ProductRenderPosition).where(
|
|
ProductRenderPosition.id == pos_id,
|
|
ProductRenderPosition.product_id == product_id,
|
|
)
|
|
)
|
|
pos = result.scalar_one_or_none()
|
|
if not pos:
|
|
raise HTTPException(404, detail="Render position not found")
|
|
await db.delete(pos)
|
|
await db.commit()
|