feat: initial commit
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,486 @@
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update as sql_update
|
||||
from pydantic import BaseModel
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.system_setting import SystemSetting
|
||||
from app.models.cad_file import CadFile, ProcessingStatus
|
||||
from app.models.output_type import OutputType as OutputTypeModel
|
||||
from app.schemas.user import UserOut, UserUpdate, UserCreate
|
||||
from app.utils.auth import require_admin, hash_password
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
VALID_RENDERERS = {"pillow", "blender", "threejs"}
|
||||
VALID_ENGINES = {"cycles", "eevee"}
|
||||
VALID_THREEJS_SIZES = {512, 1024, 2048}
|
||||
VALID_FORMATS = {"jpg", "png"}
|
||||
VALID_STL_QUALITIES = {"low", "high"}
|
||||
VALID_CYCLES_DEVICES = {"auto", "gpu", "cpu"}
|
||||
VALID_RENDER_BACKENDS = {"celery", "flamenco", "auto"}
|
||||
|
||||
SETTINGS_DEFAULTS: dict[str, str] = {
|
||||
"thumbnail_renderer": "pillow",
|
||||
"blender_engine": "cycles",
|
||||
"blender_cycles_samples": "256",
|
||||
"blender_eevee_samples": "64",
|
||||
"threejs_render_size": "1024",
|
||||
"thumbnail_format": "jpg",
|
||||
"stl_quality": "low",
|
||||
"blender_smooth_angle": "30",
|
||||
"cycles_device": "auto",
|
||||
"render_backend": "celery",
|
||||
"flamenco_manager_url": "http://flamenco-manager:8080",
|
||||
"flamenco_worker_count": "1",
|
||||
"blender_max_concurrent_renders": "3",
|
||||
"product_thumbnail_priority": '["latest_render","cad_thumbnail"]',
|
||||
"render_stall_timeout_minutes": "120",
|
||||
}
|
||||
|
||||
|
||||
class SettingsOut(BaseModel):
|
||||
thumbnail_renderer: str = "pillow"
|
||||
blender_engine: str = "cycles"
|
||||
blender_cycles_samples: int = 256
|
||||
blender_eevee_samples: int = 64
|
||||
threejs_render_size: int = 1024
|
||||
thumbnail_format: str = "jpg"
|
||||
stl_quality: str = "low"
|
||||
blender_smooth_angle: int = 30
|
||||
cycles_device: str = "auto"
|
||||
render_backend: str = "celery"
|
||||
flamenco_manager_url: str = "http://flamenco-manager:8080"
|
||||
flamenco_worker_count: int = 1
|
||||
blender_max_concurrent_renders: int = 3
|
||||
product_thumbnail_priority: str = '["latest_render","cad_thumbnail"]'
|
||||
render_stall_timeout_minutes: int = 120
|
||||
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
thumbnail_renderer: str | None = None
|
||||
blender_engine: str | None = None
|
||||
blender_cycles_samples: int | None = None
|
||||
blender_eevee_samples: int | None = None
|
||||
threejs_render_size: int | None = None
|
||||
thumbnail_format: str | None = None
|
||||
stl_quality: str | None = None
|
||||
blender_smooth_angle: int | None = None
|
||||
cycles_device: str | None = None
|
||||
render_backend: str | None = None
|
||||
flamenco_manager_url: str | None = None
|
||||
flamenco_worker_count: int | None = None
|
||||
blender_max_concurrent_renders: int | None = None
|
||||
product_thumbnail_priority: str | None = None
|
||||
render_stall_timeout_minutes: int | None = None
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[UserOut])
|
||||
async def list_users(
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(User).order_by(User.created_at.desc()))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/users", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(
|
||||
body: UserCreate,
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(User).where(User.email == body.email))
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(400, detail="Email already registered")
|
||||
|
||||
user = User(
|
||||
email=body.email,
|
||||
password_hash=hash_password(body.password),
|
||||
full_name=body.full_name,
|
||||
role=body.role,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}", response_model=UserOut)
|
||||
async def update_user(
|
||||
user_id: uuid.UUID,
|
||||
body: UserUpdate,
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(404, detail="User not found")
|
||||
|
||||
for field, val in body.model_dump(exclude_unset=True).items():
|
||||
setattr(user, field, val)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_user(
|
||||
user_id: uuid.UUID,
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(404, detail="User not found")
|
||||
if user.id == admin.id:
|
||||
raise HTTPException(400, detail="Cannot delete yourself")
|
||||
await db.delete(user)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── System Settings ──────────────────────────────────────────────────────────
|
||||
|
||||
async def _load_settings(db: AsyncSession) -> dict[str, str]:
|
||||
"""Load all system settings, filling missing keys with defaults."""
|
||||
result = await db.execute(select(SystemSetting))
|
||||
stored = {row.key: row.value for row in result.scalars().all()}
|
||||
return {k: stored.get(k, v) for k, v in SETTINGS_DEFAULTS.items()}
|
||||
|
||||
|
||||
async def _save_setting(db: AsyncSession, key: str, value: str) -> None:
|
||||
result = await db.execute(
|
||||
sql_update(SystemSetting)
|
||||
.where(SystemSetting.key == key)
|
||||
.values(value=value, updated_at=datetime.utcnow())
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
db.add(SystemSetting(key=key, value=value, updated_at=datetime.utcnow()))
|
||||
|
||||
|
||||
def _settings_to_out(raw: dict[str, str]) -> SettingsOut:
|
||||
return SettingsOut(
|
||||
thumbnail_renderer=raw["thumbnail_renderer"],
|
||||
blender_engine=raw["blender_engine"],
|
||||
blender_cycles_samples=int(raw["blender_cycles_samples"]),
|
||||
blender_eevee_samples=int(raw["blender_eevee_samples"]),
|
||||
threejs_render_size=int(raw["threejs_render_size"]),
|
||||
thumbnail_format=raw["thumbnail_format"],
|
||||
stl_quality=raw["stl_quality"],
|
||||
blender_smooth_angle=int(raw["blender_smooth_angle"]),
|
||||
cycles_device=raw["cycles_device"],
|
||||
render_backend=raw["render_backend"],
|
||||
flamenco_manager_url=raw["flamenco_manager_url"],
|
||||
flamenco_worker_count=int(raw["flamenco_worker_count"]),
|
||||
blender_max_concurrent_renders=int(raw["blender_max_concurrent_renders"]),
|
||||
product_thumbnail_priority=raw.get("product_thumbnail_priority", '["latest_render","cad_thumbnail"]'),
|
||||
render_stall_timeout_minutes=int(raw.get("render_stall_timeout_minutes", "120")),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/settings", response_model=SettingsOut)
|
||||
async def get_settings(
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return _settings_to_out(await _load_settings(db))
|
||||
|
||||
|
||||
@router.put("/settings", response_model=SettingsOut)
|
||||
async def update_settings(
|
||||
body: SettingsUpdate,
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if body.thumbnail_renderer is not None and body.thumbnail_renderer not in VALID_RENDERERS:
|
||||
raise HTTPException(400, detail=f"Invalid renderer. Choose: {', '.join(sorted(VALID_RENDERERS))}")
|
||||
if body.blender_engine is not None and body.blender_engine not in VALID_ENGINES:
|
||||
raise HTTPException(400, detail=f"Invalid engine. Choose: {', '.join(sorted(VALID_ENGINES))}")
|
||||
if body.blender_cycles_samples is not None and not (1 <= body.blender_cycles_samples <= 4096):
|
||||
raise HTTPException(400, detail="blender_cycles_samples must be 1–4096")
|
||||
if body.blender_eevee_samples is not None and not (1 <= body.blender_eevee_samples <= 1024):
|
||||
raise HTTPException(400, detail="blender_eevee_samples must be 1–1024")
|
||||
if body.threejs_render_size is not None and body.threejs_render_size not in VALID_THREEJS_SIZES:
|
||||
raise HTTPException(400, detail=f"Invalid threejs_render_size. Choose: {', '.join(str(s) for s in sorted(VALID_THREEJS_SIZES))}")
|
||||
if body.thumbnail_format is not None and body.thumbnail_format not in VALID_FORMATS:
|
||||
raise HTTPException(400, detail=f"Invalid thumbnail_format. Choose: {', '.join(sorted(VALID_FORMATS))}")
|
||||
if body.stl_quality is not None and body.stl_quality not in VALID_STL_QUALITIES:
|
||||
raise HTTPException(400, detail=f"Invalid stl_quality. Choose: {', '.join(sorted(VALID_STL_QUALITIES))}")
|
||||
if body.blender_smooth_angle is not None and not (0 <= body.blender_smooth_angle <= 180):
|
||||
raise HTTPException(400, detail="blender_smooth_angle must be 0–180 degrees")
|
||||
if body.cycles_device is not None and body.cycles_device not in VALID_CYCLES_DEVICES:
|
||||
raise HTTPException(400, detail=f"Invalid cycles_device. Choose: {', '.join(sorted(VALID_CYCLES_DEVICES))}")
|
||||
if body.render_backend is not None and body.render_backend not in VALID_RENDER_BACKENDS:
|
||||
raise HTTPException(400, detail=f"Invalid render_backend. Choose: {', '.join(sorted(VALID_RENDER_BACKENDS))}")
|
||||
if body.flamenco_worker_count is not None and not (1 <= body.flamenco_worker_count <= 16):
|
||||
raise HTTPException(400, detail="flamenco_worker_count must be 1–16")
|
||||
if body.blender_max_concurrent_renders is not None and not (1 <= body.blender_max_concurrent_renders <= 16):
|
||||
raise HTTPException(400, detail="blender_max_concurrent_renders must be 1–16")
|
||||
if body.render_stall_timeout_minutes is not None and not (10 <= body.render_stall_timeout_minutes <= 10080):
|
||||
raise HTTPException(400, detail="render_stall_timeout_minutes must be 10–10080 (10 min to 1 week)")
|
||||
if body.product_thumbnail_priority is not None:
|
||||
try:
|
||||
entries = json.loads(body.product_thumbnail_priority)
|
||||
if not isinstance(entries, list):
|
||||
raise ValueError
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
raise HTTPException(400, detail="product_thumbnail_priority must be a valid JSON array")
|
||||
valid_literals = {"cad_thumbnail", "latest_render"}
|
||||
for entry in entries:
|
||||
if entry not in valid_literals:
|
||||
try:
|
||||
ot_id = uuid.UUID(entry)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail=f"Invalid priority entry '{entry}': must be 'cad_thumbnail', 'latest_render', or a valid output type UUID")
|
||||
ot_row = await db.execute(select(OutputTypeModel).where(OutputTypeModel.id == ot_id))
|
||||
if not ot_row.scalar_one_or_none():
|
||||
raise HTTPException(400, detail=f"Output type '{entry}' not found")
|
||||
|
||||
updates: dict[str, str] = {}
|
||||
if body.thumbnail_renderer is not None:
|
||||
updates["thumbnail_renderer"] = body.thumbnail_renderer
|
||||
if body.blender_engine is not None:
|
||||
updates["blender_engine"] = body.blender_engine
|
||||
if body.blender_cycles_samples is not None:
|
||||
updates["blender_cycles_samples"] = str(body.blender_cycles_samples)
|
||||
if body.blender_eevee_samples is not None:
|
||||
updates["blender_eevee_samples"] = str(body.blender_eevee_samples)
|
||||
if body.threejs_render_size is not None:
|
||||
updates["threejs_render_size"] = str(body.threejs_render_size)
|
||||
if body.thumbnail_format is not None:
|
||||
updates["thumbnail_format"] = body.thumbnail_format
|
||||
if body.stl_quality is not None:
|
||||
updates["stl_quality"] = body.stl_quality
|
||||
if body.blender_smooth_angle is not None:
|
||||
updates["blender_smooth_angle"] = str(body.blender_smooth_angle)
|
||||
if body.cycles_device is not None:
|
||||
updates["cycles_device"] = body.cycles_device
|
||||
if body.render_backend is not None:
|
||||
updates["render_backend"] = body.render_backend
|
||||
if body.flamenco_manager_url is not None:
|
||||
updates["flamenco_manager_url"] = body.flamenco_manager_url
|
||||
if body.flamenco_worker_count is not None:
|
||||
updates["flamenco_worker_count"] = str(body.flamenco_worker_count)
|
||||
if body.blender_max_concurrent_renders is not None:
|
||||
updates["blender_max_concurrent_renders"] = str(body.blender_max_concurrent_renders)
|
||||
if body.render_stall_timeout_minutes is not None:
|
||||
updates["render_stall_timeout_minutes"] = str(body.render_stall_timeout_minutes)
|
||||
if body.product_thumbnail_priority is not None:
|
||||
updates["product_thumbnail_priority"] = body.product_thumbnail_priority
|
||||
|
||||
for k, v in updates.items():
|
||||
await _save_setting(db, k, v)
|
||||
await db.commit()
|
||||
|
||||
# Propagate concurrency limit to blender-renderer immediately (no restart needed)
|
||||
if body.blender_max_concurrent_renders is not None:
|
||||
try:
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
await client.post(
|
||||
"http://blender-renderer:8100/configure",
|
||||
params={"max_concurrent": body.blender_max_concurrent_renders},
|
||||
)
|
||||
except Exception:
|
||||
pass # best-effort; setting is persisted in DB regardless
|
||||
|
||||
return _settings_to_out(await _load_settings(db))
|
||||
|
||||
|
||||
@router.post("/settings/process-unprocessed", status_code=status.HTTP_202_ACCEPTED)
|
||||
async def process_unprocessed_steps(
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Queue all STEP files that are not yet completed.
|
||||
|
||||
Queues pending and failed files immediately. Files stuck in 'processing'
|
||||
for more than 15 minutes (i.e. their worker task was killed or lost) are
|
||||
also recovered. Actively-processing files (updated within the last 15 min)
|
||||
are left alone to avoid duplicate task execution on the same file.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
stuck_cutoff = datetime.utcnow() - timedelta(minutes=15)
|
||||
result = await db.execute(
|
||||
select(CadFile).where(
|
||||
CadFile.stored_path.isnot(None),
|
||||
# pending/failed always, plus processing-but-stale (stuck)
|
||||
(
|
||||
CadFile.processing_status.in_([
|
||||
ProcessingStatus.pending,
|
||||
ProcessingStatus.failed,
|
||||
]) |
|
||||
(
|
||||
(CadFile.processing_status == ProcessingStatus.processing) &
|
||||
(CadFile.updated_at < stuck_cutoff)
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
cad_files = result.scalars().all()
|
||||
|
||||
from app.tasks.step_tasks import process_step_file
|
||||
queued = 0
|
||||
for cad_file in cad_files:
|
||||
cad_file.processing_status = ProcessingStatus.pending
|
||||
process_step_file.delay(str(cad_file.id))
|
||||
queued += 1
|
||||
await db.commit()
|
||||
|
||||
return {"queued": queued, "message": f"Queued {queued} STEP file(s) for processing"}
|
||||
|
||||
|
||||
@router.post("/settings/regenerate-thumbnails", status_code=status.HTTP_202_ACCEPTED)
|
||||
async def regenerate_thumbnails(
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Re-queue all completed CAD files for thumbnail regeneration."""
|
||||
result = await db.execute(
|
||||
select(CadFile).where(CadFile.processing_status == ProcessingStatus.completed)
|
||||
)
|
||||
cad_files = result.scalars().all()
|
||||
|
||||
from app.tasks.step_tasks import render_step_thumbnail
|
||||
queued = 0
|
||||
for cad_file in cad_files:
|
||||
render_step_thumbnail.delay(str(cad_file.id))
|
||||
queued += 1
|
||||
|
||||
return {"queued": queued, "message": f"Re-queued {queued} CAD file(s) for thumbnail regeneration"}
|
||||
|
||||
|
||||
@router.post("/settings/generate-missing-stls", status_code=status.HTTP_202_ACCEPTED)
|
||||
async def generate_missing_stls(
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Queue STL generation for every quality missing from each completed CAD file."""
|
||||
from pathlib import Path as _Path
|
||||
result = await db.execute(
|
||||
select(CadFile).where(CadFile.processing_status == ProcessingStatus.completed)
|
||||
)
|
||||
cad_files = result.scalars().all()
|
||||
|
||||
from app.tasks.step_tasks import generate_stl_cache
|
||||
queued = 0
|
||||
for cad_file in cad_files:
|
||||
if not cad_file.stored_path:
|
||||
continue
|
||||
step = _Path(cad_file.stored_path)
|
||||
for quality in ("low", "high"):
|
||||
if not (step.parent / f"{step.stem}_{quality}.stl").exists():
|
||||
generate_stl_cache.delay(str(cad_file.id), quality)
|
||||
queued += 1
|
||||
|
||||
return {"queued": queued, "message": f"Queued {queued} missing STL generation task(s)"}
|
||||
|
||||
|
||||
@router.get("/settings/renderer-status")
|
||||
async def renderer_status(
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
"""Check health of external renderer services."""
|
||||
import httpx
|
||||
services = {
|
||||
"pillow": {"url": None, "available": True, "note": "Built-in (always available)"},
|
||||
"blender": {"url": "http://blender-renderer:8100/health", "available": False, "note": ""},
|
||||
"threejs": {"url": "http://threejs-renderer:8101/health", "available": False, "note": ""},
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
for name, info in services.items():
|
||||
if info["url"] is None:
|
||||
continue
|
||||
try:
|
||||
resp = await client.get(info["url"])
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
services[name]["available"] = True
|
||||
services[name]["note"] = data.get("renderer", name)
|
||||
except Exception as e:
|
||||
services[name]["note"] = str(e)[:100]
|
||||
return services
|
||||
|
||||
|
||||
@router.get("/settings/flamenco-status")
|
||||
async def flamenco_status(
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Check Flamenco Manager health and list workers."""
|
||||
raw = await _load_settings(db)
|
||||
manager_url = raw.get("flamenco_manager_url", "http://flamenco-manager:8080")
|
||||
|
||||
from app.services.flamenco_client import get_flamenco_client
|
||||
client = get_flamenco_client(manager_url)
|
||||
|
||||
health = client.health_check()
|
||||
workers: list[dict] = []
|
||||
|
||||
if health["available"]:
|
||||
try:
|
||||
workers = client.list_workers()
|
||||
except Exception as exc:
|
||||
workers = [{"error": str(exc)[:200]}]
|
||||
|
||||
return {
|
||||
"manager": health,
|
||||
"workers": workers,
|
||||
"manager_url": manager_url,
|
||||
}
|
||||
|
||||
|
||||
class WorkerCountBody(BaseModel):
|
||||
count: int
|
||||
|
||||
|
||||
@router.get("/settings/flamenco-worker-actual")
|
||||
async def get_flamenco_worker_actual(admin: User = Depends(require_admin)):
|
||||
"""Return the number of flamenco-worker containers currently running."""
|
||||
from app.services.docker_scaler import get_running_worker_count
|
||||
count = await asyncio.get_event_loop().run_in_executor(None, get_running_worker_count)
|
||||
return {"running": count, "available": count >= 0}
|
||||
|
||||
|
||||
@router.post("/settings/flamenco-worker-count")
|
||||
async def set_flamenco_worker_count(
|
||||
body: WorkerCountBody,
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Scale Flamenco worker containers to the requested count via Docker socket."""
|
||||
if not (1 <= body.count <= 16):
|
||||
raise HTTPException(400, detail="Worker count must be 1–16")
|
||||
|
||||
# Save desired count to settings first
|
||||
await _save_setting(db, "flamenco_worker_count", str(body.count))
|
||||
await db.commit()
|
||||
|
||||
# Perform actual Docker scaling in a thread (blocking SDK call)
|
||||
from app.services.docker_scaler import scale_workers
|
||||
try:
|
||||
result = await asyncio.get_event_loop().run_in_executor(None, scale_workers, body.count)
|
||||
return {
|
||||
"count": body.count,
|
||||
"previous": result["previous"],
|
||||
"current": result["current"],
|
||||
"delta": result["delta"],
|
||||
"message": result["message"],
|
||||
}
|
||||
except Exception as exc:
|
||||
# Scaling failed — return a warning but keep the saved setting
|
||||
return {
|
||||
"count": body.count,
|
||||
"previous": -1,
|
||||
"current": -1,
|
||||
"delta": 0,
|
||||
"message": f"Setting saved, but Docker scaling failed: {exc}. "
|
||||
f"Run `docker compose up -d --scale flamenco-worker={body.count}` manually.",
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Analytics router — KPI dashboard endpoints."""
|
||||
from datetime import date, timedelta
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.database import get_db
|
||||
from app.utils.auth import require_admin_or_pm
|
||||
from app.models.user import User
|
||||
from app.services import kpi_service
|
||||
|
||||
router = APIRouter(prefix="/analytics", tags=["analytics"])
|
||||
|
||||
|
||||
# ── Response models ────────────────────────────────────────────────────────────
|
||||
|
||||
class ThroughputPoint(BaseModel):
|
||||
week: str
|
||||
count: int
|
||||
completed: int
|
||||
|
||||
|
||||
class RevenuePoint(BaseModel):
|
||||
month: str
|
||||
revenue: float
|
||||
order_count: int
|
||||
|
||||
|
||||
class ProcessingTimeStats(BaseModel):
|
||||
avg_submit_to_complete_s: Optional[float]
|
||||
avg_submit_to_processing_s: Optional[float]
|
||||
p50_s: Optional[float]
|
||||
p95_s: Optional[float]
|
||||
|
||||
|
||||
class ItemStatusBreakdown(BaseModel):
|
||||
pending: int
|
||||
approved: int
|
||||
rejected: int
|
||||
|
||||
|
||||
class RenderTimeBreakdown(BaseModel):
|
||||
avg_stl_s: Optional[float]
|
||||
avg_render_s: Optional[float]
|
||||
avg_total_s: Optional[float]
|
||||
sample_count: int
|
||||
|
||||
|
||||
class TopLevelSummary(BaseModel):
|
||||
total_orders: int
|
||||
completed_orders: int
|
||||
total_revenue: float
|
||||
total_rendering_items: int
|
||||
|
||||
|
||||
class CategoryCount(BaseModel):
|
||||
category: str
|
||||
count: int
|
||||
|
||||
|
||||
class ProductCategoryStats(BaseModel):
|
||||
unique_products_rendered: int
|
||||
total_products: int
|
||||
products_with_cad: int
|
||||
products_by_category: list[CategoryCount]
|
||||
|
||||
|
||||
class OutputTypeUsagePoint(BaseModel):
|
||||
output_type: str
|
||||
count: int
|
||||
|
||||
|
||||
class RenderStatusDistribution(BaseModel):
|
||||
pending: int
|
||||
processing: int
|
||||
completed: int
|
||||
failed: int
|
||||
|
||||
|
||||
class RendererUsagePoint(BaseModel):
|
||||
renderer: str
|
||||
count: int
|
||||
|
||||
|
||||
class TopProductEntry(BaseModel):
|
||||
pim_id: str
|
||||
product_name: Optional[str]
|
||||
category: str
|
||||
order_count: int
|
||||
|
||||
|
||||
class CategoryRevenueEntry(BaseModel):
|
||||
category: str
|
||||
order_count: int
|
||||
revenue: float
|
||||
|
||||
|
||||
class RenderBackendStatsEntry(BaseModel):
|
||||
backend: str
|
||||
total: int
|
||||
completed: int
|
||||
failed: int
|
||||
avg_render_s: float | None
|
||||
p50_render_s: float | None
|
||||
|
||||
|
||||
class RenderTimeByOutputType(BaseModel):
|
||||
output_type: str
|
||||
job_count: int
|
||||
avg_render_s: float | None
|
||||
min_render_s: float | None
|
||||
max_render_s: float | None
|
||||
p50_render_s: float | None
|
||||
|
||||
|
||||
class OrdersByUserEntry(BaseModel):
|
||||
full_name: str
|
||||
email: str
|
||||
role: str
|
||||
order_count: int
|
||||
revenue: float
|
||||
|
||||
|
||||
class DashboardKPIs(BaseModel):
|
||||
summary: TopLevelSummary
|
||||
throughput: list[ThroughputPoint]
|
||||
revenue: list[RevenuePoint]
|
||||
processing_times: ProcessingTimeStats
|
||||
item_status: ItemStatusBreakdown
|
||||
render_times: RenderTimeBreakdown
|
||||
product_stats: ProductCategoryStats
|
||||
output_type_usage: list[OutputTypeUsagePoint]
|
||||
render_status: RenderStatusDistribution
|
||||
renderer_usage: list[RendererUsagePoint]
|
||||
top_products: list[TopProductEntry]
|
||||
orders_by_user: list[OrdersByUserEntry]
|
||||
category_revenue: list[CategoryRevenueEntry]
|
||||
render_backend_stats: list[RenderBackendStatsEntry]
|
||||
render_time_by_output_type: list[RenderTimeByOutputType]
|
||||
|
||||
|
||||
# ── Endpoints ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/dashboard", response_model=DashboardKPIs)
|
||||
async def get_dashboard_kpis(
|
||||
date_from: date | None = Query(None, description="Start date (ISO), e.g. 2025-01-01"),
|
||||
date_to: date | None = Query(None, description="End date (ISO), e.g. 2025-06-30"),
|
||||
_user: User = Depends(require_admin_or_pm),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DashboardKPIs:
|
||||
"""Aggregate KPI data for the analytics dashboard."""
|
||||
# Default: last 12 weeks
|
||||
if date_to is None:
|
||||
date_to = date.today()
|
||||
if date_from is None:
|
||||
date_from = date_to - timedelta(weeks=12)
|
||||
|
||||
df = date_from.isoformat()
|
||||
dt = date_to.isoformat()
|
||||
|
||||
summary_data, throughput_data, revenue_data, proc_data, item_data, render_data = (
|
||||
await kpi_service.top_level_summary(db, df, dt),
|
||||
await kpi_service.order_throughput_by_week(db, df, dt),
|
||||
await kpi_service.revenue_overview(db, df, dt),
|
||||
await kpi_service.processing_time_stats(db, df, dt),
|
||||
await kpi_service.item_status_breakdown(db, df, dt),
|
||||
await kpi_service.render_time_breakdown(db, df, dt),
|
||||
)
|
||||
|
||||
product_stats_data = await kpi_service.product_and_category_stats(db, df, dt)
|
||||
ot_usage_data = await kpi_service.output_type_usage(db, df, dt)
|
||||
render_status_data, renderer_usage_data = await kpi_service.render_status_distribution(db, df, dt)
|
||||
top_products_data = await kpi_service.top_products(db, df, dt)
|
||||
cat_revenue_data = await kpi_service.category_revenue(db, df, dt)
|
||||
users_data = await kpi_service.orders_by_user(db, df, dt)
|
||||
backend_stats_data = await kpi_service.render_backend_stats(db, df, dt)
|
||||
render_time_ot_data = await kpi_service.render_time_by_output_type(db, df, dt)
|
||||
|
||||
return DashboardKPIs(
|
||||
summary=TopLevelSummary(**summary_data),
|
||||
throughput=[ThroughputPoint(**p) for p in throughput_data],
|
||||
revenue=[RevenuePoint(**p) for p in revenue_data],
|
||||
processing_times=ProcessingTimeStats(**proc_data),
|
||||
item_status=ItemStatusBreakdown(**item_data),
|
||||
render_times=RenderTimeBreakdown(**render_data),
|
||||
product_stats=ProductCategoryStats(
|
||||
**{**product_stats_data, "products_by_category": [
|
||||
CategoryCount(**c) for c in product_stats_data["products_by_category"]
|
||||
]}
|
||||
),
|
||||
output_type_usage=[OutputTypeUsagePoint(**p) for p in ot_usage_data],
|
||||
render_status=RenderStatusDistribution(**render_status_data),
|
||||
renderer_usage=[RendererUsagePoint(**p) for p in renderer_usage_data],
|
||||
top_products=[TopProductEntry(**p) for p in top_products_data],
|
||||
orders_by_user=[OrdersByUserEntry(**p) for p in users_data],
|
||||
category_revenue=[CategoryRevenueEntry(**p) for p in cat_revenue_data],
|
||||
render_backend_stats=[RenderBackendStatsEntry(**p) for p in backend_stats_data],
|
||||
render_time_by_output_type=[RenderTimeByOutputType(**p) for p in render_time_ot_data],
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserCreate, UserOut, TokenResponse, LoginRequest
|
||||
from app.utils.auth import hash_password, verify_password, create_access_token, get_current_user
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||
async def register(body: UserCreate, db: AsyncSession = Depends(get_db)):
|
||||
"""Register a new user (admin-initiated in production)."""
|
||||
result = await db.execute(select(User).where(User.email == body.email))
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
|
||||
user = User(
|
||||
email=body.email,
|
||||
password_hash=hash_password(body.password),
|
||||
full_name=body.full_name,
|
||||
role=body.role,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User).where(User.email == body.email))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user or not verify_password(body.password, user.password_hash):
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=403, detail="Account disabled")
|
||||
|
||||
token = create_access_token(str(user.id), user.role.value)
|
||||
return TokenResponse(access_token=token, user=UserOut.model_validate(user))
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
async def me(user: User = Depends(get_current_user)):
|
||||
return user
|
||||
@@ -0,0 +1,360 @@
|
||||
"""CAD file router - serve thumbnails, glTF models, parsed objects, and trigger reprocessing."""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.cad_file import CadFile, ProcessingStatus
|
||||
from app.models.order import Order
|
||||
from app.models.order_item import OrderItem
|
||||
from app.models.user import User
|
||||
from app.utils.auth import get_current_user
|
||||
from app.services.product_service import link_cad_to_product, lookup_product
|
||||
|
||||
router = APIRouter(prefix="/cad", tags=["cad"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas for match-to-order
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class MatchToOrderRequest(BaseModel):
|
||||
order_id: uuid.UUID
|
||||
cad_file_ids: list[str]
|
||||
|
||||
|
||||
class MatchedItem(BaseModel):
|
||||
item_id: str
|
||||
cad_file_id: str
|
||||
item_name: str
|
||||
cad_name: str
|
||||
|
||||
|
||||
class MatchToOrderResponse(BaseModel):
|
||||
matched: list[MatchedItem]
|
||||
unmatched_cad: list[str]
|
||||
unmatched_items: list[str]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Matching helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _normalize_stem(name: str) -> str:
|
||||
"""Lowercase stem, strip .stp/.step extension for comparison."""
|
||||
stem = name.strip()
|
||||
for ext in (".step", ".stp"):
|
||||
if stem.lower().endswith(ext):
|
||||
stem = stem[: -len(ext)]
|
||||
break
|
||||
return stem.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/match-to-order", response_model=MatchToOrderResponse)
|
||||
async def match_cad_files_to_order(
|
||||
body: MatchToOrderRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Match uploaded CAD files to order items by filename similarity.
|
||||
|
||||
For each CAD file, compares the stem of original_name (case-insensitive,
|
||||
.stp/.step normalised) to the stem of each item's name_cad_modell field.
|
||||
Updates order_item.cad_file_id for successful matches.
|
||||
"""
|
||||
# Load order with items
|
||||
order_result = await db.execute(
|
||||
select(Order)
|
||||
.where(Order.id == body.order_id)
|
||||
.options(selectinload(Order.items))
|
||||
)
|
||||
order = order_result.scalar_one_or_none()
|
||||
if not order:
|
||||
raise HTTPException(404, detail="Order not found")
|
||||
if user.role.value != "admin" and order.created_by != user.id:
|
||||
raise HTTPException(403, detail="Access denied")
|
||||
|
||||
# Parse and validate CAD file IDs
|
||||
cad_uuids: list[uuid.UUID] = []
|
||||
for raw_id in body.cad_file_ids:
|
||||
try:
|
||||
cad_uuids.append(uuid.UUID(raw_id))
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail=f"Invalid cad_file_id: {raw_id}")
|
||||
|
||||
# Load CAD files from DB
|
||||
cad_result = await db.execute(
|
||||
select(CadFile).where(CadFile.id.in_(cad_uuids))
|
||||
)
|
||||
cad_files: list[CadFile] = list(cad_result.scalars().all())
|
||||
|
||||
found_ids = {str(cf.id) for cf in cad_files}
|
||||
missing = [i for i in body.cad_file_ids if i not in found_ids]
|
||||
if missing:
|
||||
raise HTTPException(404, detail=f"CAD files not found: {missing}")
|
||||
|
||||
# Build lookup: normalized stem -> first OrderItem with that stem
|
||||
items: list[OrderItem] = order.items
|
||||
item_by_stem: dict[str, OrderItem] = {}
|
||||
for item in items:
|
||||
if item.name_cad_modell:
|
||||
stem = _normalize_stem(item.name_cad_modell)
|
||||
if stem not in item_by_stem:
|
||||
item_by_stem[stem] = item
|
||||
|
||||
matched: list[MatchedItem] = []
|
||||
unmatched_cad: list[str] = []
|
||||
matched_item_ids: set[str] = set()
|
||||
|
||||
for cad_file in cad_files:
|
||||
cad_stem = _normalize_stem(cad_file.original_name or "")
|
||||
if cad_stem in item_by_stem:
|
||||
item = item_by_stem[cad_stem]
|
||||
item.cad_file_id = cad_file.id
|
||||
item.updated_at = datetime.utcnow()
|
||||
matched.append(
|
||||
MatchedItem(
|
||||
item_id=str(item.id),
|
||||
cad_file_id=str(cad_file.id),
|
||||
item_name=item.name_cad_modell or "",
|
||||
cad_name=cad_file.original_name or "",
|
||||
)
|
||||
)
|
||||
matched_item_ids.add(str(item.id))
|
||||
|
||||
# Propagate the STEP link to the product so that:
|
||||
# (a) the render pipeline can find it via product.cad_file_id
|
||||
# (b) future orders for the same product inherit the STEP automatically
|
||||
# (c) the split-missing-step correctly identifies which products have STEP
|
||||
try:
|
||||
product = await lookup_product(db, item.pim_id, item.produkt_baureihe)
|
||||
if product and product.cad_file_id is None:
|
||||
await link_cad_to_product(db, product.id, cad_file.id)
|
||||
except Exception:
|
||||
pass # non-critical — item link already set above
|
||||
else:
|
||||
unmatched_cad.append(str(cad_file.id))
|
||||
|
||||
await db.commit()
|
||||
|
||||
unmatched_items = [
|
||||
str(item.id)
|
||||
for item in items
|
||||
if str(item.id) not in matched_item_ids
|
||||
]
|
||||
|
||||
return MatchToOrderResponse(
|
||||
matched=matched,
|
||||
unmatched_cad=unmatched_cad,
|
||||
unmatched_items=unmatched_items,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _get_cad_file(cad_id: uuid.UUID, db: AsyncSession) -> CadFile:
|
||||
result = await db.execute(select(CadFile).where(CadFile.id == cad_id))
|
||||
cad = result.scalar_one_or_none()
|
||||
if not cad:
|
||||
raise HTTPException(status_code=404, detail="CAD file not found")
|
||||
return cad
|
||||
|
||||
|
||||
@router.get("/{id}/thumbnail")
|
||||
async def get_thumbnail(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Serve the thumbnail image for a CAD file (no auth — UUID is opaque enough)."""
|
||||
cad = await _get_cad_file(id, db)
|
||||
|
||||
if not cad.thumbnail_path:
|
||||
raise HTTPException(404, detail="Thumbnail not yet generated for this CAD file")
|
||||
|
||||
thumb_path = Path(cad.thumbnail_path)
|
||||
if not thumb_path.exists():
|
||||
raise HTTPException(404, detail="Thumbnail file missing from storage")
|
||||
|
||||
ext = thumb_path.suffix.lower()
|
||||
media_type = "image/jpeg" if ext in (".jpg", ".jpeg") else "image/png"
|
||||
|
||||
return FileResponse(
|
||||
path=str(thumb_path),
|
||||
media_type=media_type,
|
||||
filename=f"{id}{ext}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{id}/model")
|
||||
async def get_model(
|
||||
id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Serve the glTF file for a CAD file."""
|
||||
cad = await _get_cad_file(id, db)
|
||||
|
||||
if not cad.gltf_path:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="glTF model not yet generated for this CAD file",
|
||||
)
|
||||
|
||||
gltf_path = Path(cad.gltf_path)
|
||||
if not gltf_path.exists():
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="glTF file missing from storage",
|
||||
)
|
||||
|
||||
# glTF files may be either .gltf (JSON) or .glb (binary)
|
||||
suffix = gltf_path.suffix.lower()
|
||||
if suffix == ".glb":
|
||||
media_type = "model/gltf-binary"
|
||||
else:
|
||||
media_type = "model/gltf+json"
|
||||
|
||||
return FileResponse(
|
||||
path=str(gltf_path),
|
||||
media_type=media_type,
|
||||
filename=f"{id}{suffix}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{id}/objects")
|
||||
async def get_objects(
|
||||
id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Return the parsed_objects JSON extracted from the STEP file."""
|
||||
cad = await _get_cad_file(id, db)
|
||||
|
||||
if cad.parsed_objects is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Parsed objects not yet available for this CAD file",
|
||||
)
|
||||
|
||||
return {
|
||||
"cad_file_id": str(cad.id),
|
||||
"original_name": cad.original_name,
|
||||
"processing_status": cad.processing_status.value,
|
||||
"parsed_objects": cad.parsed_objects,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{id}/stl/{quality}")
|
||||
async def download_stl(
|
||||
id: uuid.UUID,
|
||||
quality: str,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Download the cached STL for a CAD file with a human-readable filename.
|
||||
|
||||
The STL is cached next to the STEP file on first render.
|
||||
quality must be 'low' or 'high'.
|
||||
"""
|
||||
if quality not in ("low", "high"):
|
||||
raise HTTPException(400, detail="quality must be 'low' or 'high'")
|
||||
|
||||
cad = await _get_cad_file(id, db)
|
||||
|
||||
if not cad.stored_path:
|
||||
raise HTTPException(404, detail="STEP file not uploaded for this CAD file")
|
||||
|
||||
step_path = Path(cad.stored_path)
|
||||
stl_path = step_path.parent / f"{step_path.stem}_{quality}.stl"
|
||||
|
||||
if not stl_path.exists():
|
||||
raise HTTPException(
|
||||
404,
|
||||
detail=f"STL cache not found for quality '{quality}'. Trigger a render first to generate it.",
|
||||
)
|
||||
|
||||
original_stem = Path(cad.original_name or "model").stem
|
||||
filename = f"{original_stem}_{quality}.stl"
|
||||
|
||||
return FileResponse(
|
||||
path=str(stl_path),
|
||||
media_type="application/octet-stream",
|
||||
filename=filename,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{id}/generate-stl/{quality}", status_code=status.HTTP_202_ACCEPTED)
|
||||
async def generate_stl(
|
||||
id: uuid.UUID,
|
||||
quality: str,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Queue STL generation for the given quality without triggering a full render."""
|
||||
if user.role.value not in ("admin", "project_manager"):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
if quality not in ("low", "high"):
|
||||
raise HTTPException(status_code=400, detail="quality must be 'low' or 'high'")
|
||||
|
||||
cad = await _get_cad_file(id, db)
|
||||
|
||||
if not cad.stored_path:
|
||||
raise HTTPException(status_code=404, detail="STEP file not uploaded for this CAD file")
|
||||
|
||||
from app.tasks.step_tasks import generate_stl_cache
|
||||
task = generate_stl_cache.delay(str(id), quality)
|
||||
return {"status": "queued", "task_id": task.id, "quality": quality}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{id}/regenerate-thumbnail",
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
async def regenerate_thumbnail(
|
||||
id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Queue a Celery task to reprocess the STEP file and regenerate its thumbnail."""
|
||||
if user.role.value != "admin":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Only admins can trigger thumbnail regeneration",
|
||||
)
|
||||
|
||||
cad = await _get_cad_file(id, db)
|
||||
|
||||
# Reset processing status so the worker will reprocess
|
||||
cad.processing_status = ProcessingStatus.pending
|
||||
await db.commit()
|
||||
|
||||
# Enqueue Celery task
|
||||
task_id: str | None = None
|
||||
try:
|
||||
from app.tasks.step_tasks import process_step_file
|
||||
result = process_step_file.delay(str(cad.id))
|
||||
task_id = result.id
|
||||
except Exception:
|
||||
# Worker may not be running; status is already reset so it will pick up later
|
||||
pass
|
||||
|
||||
return {
|
||||
"cad_file_id": str(cad.id),
|
||||
"original_name": cad.original_name,
|
||||
"status": "queued",
|
||||
"task_id": task_id,
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
"""Materials router — CRUD for the shared material library."""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.material import Material
|
||||
from app.models.material_alias import MaterialAlias
|
||||
from app.models.user import User
|
||||
from app.utils.auth import get_current_user, require_admin_or_pm
|
||||
|
||||
router = APIRouter(prefix="/materials", tags=["materials"])
|
||||
|
||||
|
||||
class MaterialOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
description: str | None
|
||||
source: str
|
||||
schaeffler_code: int | None = None
|
||||
created_by_name: str | None = None
|
||||
aliases: list[str] = []
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class MaterialAliasOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
alias: str
|
||||
created_at: datetime
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class MaterialCreate(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
source: str = "manual"
|
||||
schaeffler_code: int | None = None
|
||||
|
||||
|
||||
class MaterialUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class AliasCreate(BaseModel):
|
||||
alias: str
|
||||
|
||||
|
||||
def _to_out(mat: Material) -> MaterialOut:
|
||||
creator_name = None
|
||||
if mat.creator is not None:
|
||||
creator_name = mat.creator.full_name or mat.creator.email
|
||||
alias_names = [a.alias for a in mat.aliases] if mat.aliases else []
|
||||
return MaterialOut(
|
||||
id=mat.id,
|
||||
name=mat.name,
|
||||
description=mat.description,
|
||||
source=mat.source,
|
||||
schaeffler_code=mat.schaeffler_code,
|
||||
created_by_name=creator_name,
|
||||
aliases=alias_names,
|
||||
created_at=mat.created_at,
|
||||
updated_at=mat.updated_at,
|
||||
)
|
||||
|
||||
|
||||
# --- Static-path endpoints (before /{material_id}) ---
|
||||
|
||||
|
||||
@router.get("/next-code")
|
||||
async def get_next_code(
|
||||
type_prefix: str,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Find the next available consecutive number for a given type+subtype prefix.
|
||||
|
||||
type_prefix is the 4-digit prefix e.g. "0101" for Metals/Steel.
|
||||
Returns {"next_code": 10106, "prefix": "0101", "next_consecutive": 6}
|
||||
"""
|
||||
if len(type_prefix) != 4 or not type_prefix.isdigit():
|
||||
raise HTTPException(400, "type_prefix must be exactly 4 digits")
|
||||
|
||||
prefix_int = int(type_prefix) * 100 # e.g. "0101" -> 10100
|
||||
range_start = prefix_int
|
||||
range_end = prefix_int + 99
|
||||
|
||||
result = await db.execute(
|
||||
select(func.max(Material.schaeffler_code)).where(
|
||||
Material.schaeffler_code >= range_start,
|
||||
Material.schaeffler_code <= range_end,
|
||||
)
|
||||
)
|
||||
max_code = result.scalar_one_or_none()
|
||||
|
||||
if max_code is None:
|
||||
next_consecutive = 1
|
||||
else:
|
||||
next_consecutive = (max_code % 100) + 1
|
||||
|
||||
return {
|
||||
"next_code": prefix_int + next_consecutive,
|
||||
"prefix": type_prefix,
|
||||
"next_consecutive": next_consecutive,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/seed-schaeffler")
|
||||
async def seed_schaeffler_materials(
|
||||
user: User = Depends(require_admin_or_pm),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Bulk-create the 35 standard Schaeffler materials. Skips existing by name."""
|
||||
from app.data.schaeffler_materials import SCHAEFFLER_MATERIALS
|
||||
|
||||
inserted = 0
|
||||
for mat_data in SCHAEFFLER_MATERIALS:
|
||||
existing = await db.execute(
|
||||
select(Material).where(Material.name == mat_data["name"])
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
continue
|
||||
mat = Material(
|
||||
name=mat_data["name"],
|
||||
description=mat_data["description"],
|
||||
source=mat_data["source"],
|
||||
schaeffler_code=mat_data["schaeffler_code"],
|
||||
created_by=user.id,
|
||||
)
|
||||
db.add(mat)
|
||||
inserted += 1
|
||||
|
||||
await db.commit()
|
||||
return {"inserted": inserted, "total": len(SCHAEFFLER_MATERIALS)}
|
||||
|
||||
|
||||
@router.post("/seed-aliases")
|
||||
async def seed_aliases(
|
||||
user: User = Depends(require_admin_or_pm),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Bulk-seed aliases from naming_scheme.xlsx Materialmapping data. Skips existing."""
|
||||
from app.data.material_alias_seeds import MATERIAL_ALIAS_SEEDS
|
||||
|
||||
inserted = 0
|
||||
total = 0
|
||||
for entry in MATERIAL_ALIAS_SEEDS:
|
||||
mat_result = await db.execute(
|
||||
select(Material).where(Material.name == entry["material_name"])
|
||||
)
|
||||
mat = mat_result.scalar_one_or_none()
|
||||
if not mat:
|
||||
continue
|
||||
|
||||
for alias_str in entry["aliases"]:
|
||||
total += 1
|
||||
existing = await db.execute(
|
||||
select(MaterialAlias).where(func.lower(MaterialAlias.alias) == alias_str.lower())
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
continue
|
||||
db.add(MaterialAlias(material_id=mat.id, alias=alias_str))
|
||||
inserted += 1
|
||||
|
||||
await db.commit()
|
||||
return {"inserted": inserted, "total": total}
|
||||
|
||||
|
||||
@router.delete("/aliases/{alias_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_alias(
|
||||
alias_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(MaterialAlias).where(MaterialAlias.id == alias_id))
|
||||
alias_obj = result.scalar_one_or_none()
|
||||
if not alias_obj:
|
||||
raise HTTPException(404, detail="Alias not found")
|
||||
await db.delete(alias_obj)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# --- Standard CRUD ---
|
||||
|
||||
|
||||
@router.get("", response_model=list[MaterialOut])
|
||||
async def list_materials(
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Material)
|
||||
.options(selectinload(Material.creator), selectinload(Material.aliases))
|
||||
.order_by(Material.name)
|
||||
)
|
||||
return [_to_out(m) for m in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("", response_model=MaterialOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_material(
|
||||
body: MaterialCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
existing = await db.execute(select(Material).where(Material.name == body.name))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(400, detail=f"Material '{body.name}' already exists")
|
||||
mat = Material(
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
source=body.source,
|
||||
schaeffler_code=body.schaeffler_code,
|
||||
created_by=user.id,
|
||||
)
|
||||
db.add(mat)
|
||||
await db.commit()
|
||||
await db.refresh(mat)
|
||||
result = await db.execute(
|
||||
select(Material)
|
||||
.options(selectinload(Material.creator), selectinload(Material.aliases))
|
||||
.where(Material.id == mat.id)
|
||||
)
|
||||
return _to_out(result.scalar_one())
|
||||
|
||||
|
||||
@router.patch("/{material_id}", response_model=MaterialOut)
|
||||
async def update_material(
|
||||
material_id: uuid.UUID,
|
||||
body: MaterialUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Material)
|
||||
.options(selectinload(Material.creator), selectinload(Material.aliases))
|
||||
.where(Material.id == material_id)
|
||||
)
|
||||
mat = result.scalar_one_or_none()
|
||||
if not mat:
|
||||
raise HTTPException(404, detail="Material not found")
|
||||
if body.name is not None:
|
||||
mat.name = body.name
|
||||
if body.description is not None:
|
||||
mat.description = body.description
|
||||
mat.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(mat)
|
||||
result2 = await db.execute(
|
||||
select(Material)
|
||||
.options(selectinload(Material.creator), selectinload(Material.aliases))
|
||||
.where(Material.id == mat.id)
|
||||
)
|
||||
return _to_out(result2.scalar_one())
|
||||
|
||||
|
||||
@router.delete("/{material_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_material(
|
||||
material_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Material).where(Material.id == material_id))
|
||||
mat = result.scalar_one_or_none()
|
||||
if not mat:
|
||||
raise HTTPException(404, detail="Material not found")
|
||||
await db.delete(mat)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# --- Alias sub-resource endpoints ---
|
||||
|
||||
|
||||
@router.get("/{material_id}/aliases", response_model=list[MaterialAliasOut])
|
||||
async def list_aliases(
|
||||
material_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(MaterialAlias)
|
||||
.where(MaterialAlias.material_id == material_id)
|
||||
.order_by(MaterialAlias.alias)
|
||||
)
|
||||
return [MaterialAliasOut.model_validate(a) for a in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("/{material_id}/aliases", response_model=MaterialAliasOut, status_code=status.HTTP_201_CREATED)
|
||||
async def add_alias(
|
||||
material_id: uuid.UUID,
|
||||
body: AliasCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
# Verify material exists
|
||||
mat_result = await db.execute(select(Material).where(Material.id == material_id))
|
||||
if not mat_result.scalar_one_or_none():
|
||||
raise HTTPException(404, detail="Material not found")
|
||||
|
||||
alias_str = body.alias.strip()
|
||||
if not alias_str:
|
||||
raise HTTPException(400, detail="Alias cannot be empty")
|
||||
|
||||
# Check case-insensitive uniqueness
|
||||
existing = await db.execute(
|
||||
select(MaterialAlias).where(func.lower(MaterialAlias.alias) == alias_str.lower())
|
||||
)
|
||||
dup = existing.scalar_one_or_none()
|
||||
if dup:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
detail=f"Alias '{alias_str}' already exists (assigned to material {dup.material_id})",
|
||||
)
|
||||
|
||||
alias_obj = MaterialAlias(material_id=material_id, alias=alias_str)
|
||||
db.add(alias_obj)
|
||||
await db.commit()
|
||||
await db.refresh(alias_obj)
|
||||
return MaterialAliasOut.model_validate(alias_obj)
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Notification center API — list, count, mark-read."""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, update, or_, and_
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.user import User
|
||||
from app.utils.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||
|
||||
|
||||
class NotificationOut(BaseModel):
|
||||
id: str
|
||||
action: str
|
||||
entity_type: str | None = None
|
||||
entity_id: str | None = None
|
||||
details: dict | None = None
|
||||
timestamp: datetime
|
||||
read_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class NotificationListResponse(BaseModel):
|
||||
items: list[NotificationOut]
|
||||
unread_count: int
|
||||
total: int
|
||||
|
||||
|
||||
class UnreadCountResponse(BaseModel):
|
||||
unread_count: int
|
||||
|
||||
|
||||
class MarkReadRequest(BaseModel):
|
||||
notification_ids: list[str] | None = None
|
||||
|
||||
|
||||
def _visibility_filter(user: User):
|
||||
"""Rows visible to this user: targeted at them, or broadcast (null) if admin/PM."""
|
||||
targeted = AuditLog.target_user_id == user.id
|
||||
if user.role.value in ("admin", "project_manager"):
|
||||
broadcast = AuditLog.target_user_id.is_(None)
|
||||
return and_(AuditLog.notification == True, or_(targeted, broadcast)) # noqa: E712
|
||||
return and_(AuditLog.notification == True, targeted) # noqa: E712
|
||||
|
||||
|
||||
@router.get("", response_model=NotificationListResponse)
|
||||
async def list_notifications(
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
unread_only: bool = Query(False),
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
vis = _visibility_filter(user)
|
||||
|
||||
# Total count
|
||||
total_q = select(func.count(AuditLog.id)).where(vis)
|
||||
if unread_only:
|
||||
total_q = total_q.where(AuditLog.read_at.is_(None))
|
||||
total = (await db.execute(total_q)).scalar() or 0
|
||||
|
||||
# Unread count (always)
|
||||
unread_q = select(func.count(AuditLog.id)).where(vis, AuditLog.read_at.is_(None))
|
||||
unread_count = (await db.execute(unread_q)).scalar() or 0
|
||||
|
||||
# Items
|
||||
items_q = (
|
||||
select(AuditLog)
|
||||
.where(vis)
|
||||
.order_by(AuditLog.timestamp.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
if unread_only:
|
||||
items_q = items_q.where(AuditLog.read_at.is_(None))
|
||||
|
||||
rows = (await db.execute(items_q)).scalars().all()
|
||||
items = [
|
||||
NotificationOut(
|
||||
id=str(r.id),
|
||||
action=r.action,
|
||||
entity_type=r.entity_type,
|
||||
entity_id=r.entity_id,
|
||||
details=r.details,
|
||||
timestamp=r.timestamp,
|
||||
read_at=r.read_at,
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
return NotificationListResponse(items=items, unread_count=unread_count, total=total)
|
||||
|
||||
|
||||
@router.get("/unread-count", response_model=UnreadCountResponse)
|
||||
async def unread_count(
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
vis = _visibility_filter(user)
|
||||
q = select(func.count(AuditLog.id)).where(vis, AuditLog.read_at.is_(None))
|
||||
count = (await db.execute(q)).scalar() or 0
|
||||
return UnreadCountResponse(unread_count=count)
|
||||
|
||||
|
||||
@router.post("/mark-read")
|
||||
async def mark_read(
|
||||
body: MarkReadRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Mark notifications as read. If notification_ids is null, mark all as read."""
|
||||
vis = _visibility_filter(user)
|
||||
now = datetime.utcnow()
|
||||
|
||||
if body.notification_ids is None:
|
||||
# Mark all unread
|
||||
stmt = (
|
||||
update(AuditLog)
|
||||
.where(vis, AuditLog.read_at.is_(None))
|
||||
.values(read_at=now)
|
||||
)
|
||||
else:
|
||||
ids = [uuid.UUID(nid) for nid in body.notification_ids]
|
||||
stmt = (
|
||||
update(AuditLog)
|
||||
.where(vis, AuditLog.id.in_(ids), AuditLog.read_at.is_(None))
|
||||
.values(read_at=now)
|
||||
)
|
||||
|
||||
await db.execute(stmt)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/{notification_id}/mark-read")
|
||||
async def mark_one_read(
|
||||
notification_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
vis = _visibility_filter(user)
|
||||
now = datetime.utcnow()
|
||||
result = await db.execute(
|
||||
update(AuditLog)
|
||||
.where(vis, AuditLog.id == notification_id, AuditLog.read_at.is_(None))
|
||||
.values(read_at=now)
|
||||
)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,365 @@
|
||||
"""Order items router - manage individual line items within an order."""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.cad_file import CadFile
|
||||
from app.models.order import Order, OrderStatus
|
||||
from app.models.order_item import OrderItem, ItemStatus
|
||||
from app.models.user import User
|
||||
from app.schemas.order import OrderItemOut
|
||||
from app.utils.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/orders", tags=["order_items"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _is_privileged(user: User) -> bool:
|
||||
return user.role.value in ("admin", "project_manager")
|
||||
|
||||
|
||||
async def _get_order_and_item(
|
||||
order_id: uuid.UUID,
|
||||
item_id: uuid.UUID,
|
||||
user: User,
|
||||
db: AsyncSession,
|
||||
) -> tuple[Order, OrderItem]:
|
||||
"""Load order + item, enforcing ownership/admin access."""
|
||||
order_result = await db.execute(select(Order).where(Order.id == order_id))
|
||||
order = order_result.scalar_one_or_none()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
if not _is_privileged(user) and order.created_by != user.id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
item_result = await db.execute(
|
||||
select(OrderItem)
|
||||
.options(selectinload(OrderItem.cad_file))
|
||||
.where(
|
||||
OrderItem.id == item_id,
|
||||
OrderItem.order_id == order_id,
|
||||
)
|
||||
)
|
||||
item = item_result.scalar_one_or_none()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Order item not found")
|
||||
|
||||
return order, item
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class OrderItemPatch(BaseModel):
|
||||
ebene1: Optional[str] = None
|
||||
ebene2: Optional[str] = None
|
||||
baureihe: Optional[str] = None
|
||||
pim_id: Optional[str] = None
|
||||
produkt_baureihe: Optional[str] = None
|
||||
gewaehltes_produkt: Optional[str] = None
|
||||
name_cad_modell: Optional[str] = None
|
||||
gewuenschte_bildnummer: Optional[str] = None
|
||||
lagertyp: Optional[str] = None
|
||||
medias_rendering: Optional[bool] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class ApproveRejectBody(BaseModel):
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class CadPartMaterialEntry(BaseModel):
|
||||
part_name: str
|
||||
material: str
|
||||
|
||||
|
||||
class CadPartMaterialsBody(BaseModel):
|
||||
parts: list[CadPartMaterialEntry]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/{order_id}/items", response_model=list[OrderItemOut])
|
||||
async def list_order_items(
|
||||
order_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Return all items belonging to an order."""
|
||||
order_result = await db.execute(select(Order).where(Order.id == order_id))
|
||||
order = order_result.scalar_one_or_none()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
if not _is_privileged(user) and order.created_by != user.id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
items_result = await db.execute(
|
||||
select(OrderItem)
|
||||
.options(selectinload(OrderItem.cad_file))
|
||||
.where(OrderItem.order_id == order_id)
|
||||
.order_by(OrderItem.row_index)
|
||||
)
|
||||
items = items_result.scalars().all()
|
||||
return [OrderItemOut.model_validate(i) for i in items]
|
||||
|
||||
|
||||
@router.get("/{order_id}/items/{item_id}", response_model=OrderItemOut)
|
||||
async def get_order_item(
|
||||
order_id: uuid.UUID,
|
||||
item_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Return a single order item."""
|
||||
_, item = await _get_order_and_item(order_id, item_id, user, db)
|
||||
return OrderItemOut.model_validate(item)
|
||||
|
||||
|
||||
@router.patch("/{order_id}/items/{item_id}", response_model=OrderItemOut)
|
||||
async def update_order_item(
|
||||
order_id: uuid.UUID,
|
||||
item_id: uuid.UUID,
|
||||
body: OrderItemPatch,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Edit the standard (non-component) fields of an order item."""
|
||||
order, item = await _get_order_and_item(order_id, item_id, user, db)
|
||||
|
||||
# Only draft orders can be edited (admins may also edit submitted orders)
|
||||
if not _is_privileged(user) and order.status != OrderStatus.draft:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Order items can only be edited while the order is in draft status",
|
||||
)
|
||||
|
||||
patch_data = body.model_dump(exclude_unset=True)
|
||||
for field, value in patch_data.items():
|
||||
setattr(item, field, value)
|
||||
|
||||
item.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
refreshed = await db.execute(
|
||||
select(OrderItem).options(selectinload(OrderItem.cad_file)).where(OrderItem.id == item_id)
|
||||
)
|
||||
return OrderItemOut.model_validate(refreshed.scalar_one())
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{order_id}/items/{item_id}/approve",
|
||||
response_model=OrderItemOut,
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
async def approve_order_item(
|
||||
order_id: uuid.UUID,
|
||||
item_id: uuid.UUID,
|
||||
body: ApproveRejectBody = ApproveRejectBody(),
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Mark an order item as approved (admin only)."""
|
||||
if not _is_privileged(user):
|
||||
raise HTTPException(status_code=403, detail="Only admins or PMs can approve items")
|
||||
|
||||
_, item = await _get_order_and_item(order_id, item_id, user, db)
|
||||
|
||||
if item.item_status == ItemStatus.approved:
|
||||
raise HTTPException(status_code=400, detail="Item is already approved")
|
||||
|
||||
item.item_status = ItemStatus.approved
|
||||
if body.notes is not None:
|
||||
item.notes = body.notes
|
||||
item.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
refreshed = await db.execute(
|
||||
select(OrderItem).options(selectinload(OrderItem.cad_file)).where(OrderItem.id == item_id)
|
||||
)
|
||||
return OrderItemOut.model_validate(refreshed.scalar_one())
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{order_id}/items/{item_id}/reject",
|
||||
response_model=OrderItemOut,
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
async def reject_order_item(
|
||||
order_id: uuid.UUID,
|
||||
item_id: uuid.UUID,
|
||||
body: ApproveRejectBody = ApproveRejectBody(),
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Mark an order item as rejected (admin only)."""
|
||||
if not _is_privileged(user):
|
||||
raise HTTPException(status_code=403, detail="Only admins or PMs can reject items")
|
||||
|
||||
_, item = await _get_order_and_item(order_id, item_id, user, db)
|
||||
|
||||
if item.item_status == ItemStatus.rejected:
|
||||
raise HTTPException(status_code=400, detail="Item is already rejected")
|
||||
|
||||
item.item_status = ItemStatus.rejected
|
||||
if body.notes is not None:
|
||||
item.notes = body.notes
|
||||
item.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
refreshed = await db.execute(
|
||||
select(OrderItem).options(selectinload(OrderItem.cad_file)).where(OrderItem.id == item_id)
|
||||
)
|
||||
return OrderItemOut.model_validate(refreshed.scalar_one())
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{order_id}/items/{item_id}/cad-materials",
|
||||
response_model=OrderItemOut,
|
||||
)
|
||||
async def update_cad_materials(
|
||||
order_id: uuid.UUID,
|
||||
item_id: uuid.UUID,
|
||||
body: CadPartMaterialsBody,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Save material assignments for each CAD part of an order item."""
|
||||
_, item = await _get_order_and_item(order_id, item_id, user, db)
|
||||
from sqlalchemy import update as sql_update
|
||||
await db.execute(
|
||||
sql_update(OrderItem)
|
||||
.where(OrderItem.id == item_id)
|
||||
.values(
|
||||
cad_part_materials=[e.model_dump() for e in body.parts],
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
# Re-fetch with cad_file eagerly loaded so cad_parsed_objects property works
|
||||
refreshed = await db.execute(
|
||||
select(OrderItem)
|
||||
.options(selectinload(OrderItem.cad_file))
|
||||
.where(OrderItem.id == item_id)
|
||||
)
|
||||
updated_item = refreshed.scalar_one()
|
||||
|
||||
# Queue thumbnail re-render with part colours if the item has a linked CAD file
|
||||
if updated_item.cad_file_id and updated_item.cad_file:
|
||||
parsed_objects = (updated_item.cad_file.parsed_objects or {}).get("objects", [])
|
||||
if parsed_objects:
|
||||
from app.services.step_processor import build_part_colors
|
||||
from app.tasks.step_tasks import regenerate_thumbnail
|
||||
part_colors = build_part_colors(
|
||||
parsed_objects,
|
||||
[e.model_dump() for e in body.parts],
|
||||
)
|
||||
regenerate_thumbnail.delay(str(updated_item.cad_file_id), part_colors)
|
||||
|
||||
return OrderItemOut.model_validate(updated_item)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{order_id}/items/{item_id}/cad-file",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
async def unlink_cad_file(
|
||||
order_id: uuid.UUID,
|
||||
item_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Unlink the STEP/CAD file from an order item.
|
||||
|
||||
Clears cad_file_id and cad_part_materials on the item.
|
||||
If no other items reference the same CadFile, deletes the record and
|
||||
removes the stored STEP, thumbnail, and glTF files from disk.
|
||||
Only allowed while the order is in draft status.
|
||||
"""
|
||||
import os
|
||||
from sqlalchemy import update as sql_update, func
|
||||
|
||||
order, item = await _get_order_and_item(order_id, item_id, user, db)
|
||||
|
||||
if order.status != OrderStatus.draft:
|
||||
raise HTTPException(400, detail="CAD file can only be removed from draft orders")
|
||||
|
||||
if not item.cad_file_id:
|
||||
raise HTTPException(400, detail="This item has no CAD file linked")
|
||||
|
||||
cad_id = item.cad_file_id
|
||||
|
||||
# Fetch the CadFile before unlinking
|
||||
cad_result = await db.execute(select(CadFile).where(CadFile.id == cad_id))
|
||||
cad_file = cad_result.scalar_one_or_none()
|
||||
|
||||
# Unlink item
|
||||
from sqlalchemy import update as sql_update
|
||||
await db.execute(
|
||||
sql_update(OrderItem)
|
||||
.where(OrderItem.id == item_id)
|
||||
.values(cad_file_id=None, cad_part_materials=[], updated_at=datetime.utcnow())
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
# Delete CadFile record + disk files if no other items still reference it
|
||||
if cad_file:
|
||||
remaining = await db.execute(
|
||||
select(func.count()).where(OrderItem.cad_file_id == cad_id)
|
||||
)
|
||||
if remaining.scalar() == 0:
|
||||
for path_attr in ("stored_path", "thumbnail_path", "gltf_path"):
|
||||
fpath = getattr(cad_file, path_attr, None)
|
||||
if fpath:
|
||||
try:
|
||||
os.remove(fpath)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
await db.delete(cad_file)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{order_id}/items/{item_id}/regenerate-thumbnail",
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
async def regenerate_item_thumbnail(
|
||||
order_id: uuid.UUID,
|
||||
item_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Queue a thumbnail re-render for an order item's linked CAD file.
|
||||
|
||||
The thumbnail is re-generated with per-part colours derived from the
|
||||
currently saved cad_part_materials. Returns immediately; the worker
|
||||
processes the job asynchronously.
|
||||
"""
|
||||
_, item = await _get_order_and_item(order_id, item_id, user, db)
|
||||
|
||||
if not item.cad_file_id:
|
||||
raise HTTPException(400, detail="No CAD file linked to this item")
|
||||
if not item.cad_file:
|
||||
raise HTTPException(400, detail="CAD file record not found")
|
||||
|
||||
parsed_objects = (item.cad_file.parsed_objects or {}).get("objects", [])
|
||||
from app.services.step_processor import build_part_colors
|
||||
from app.tasks.step_tasks import regenerate_thumbnail
|
||||
|
||||
part_colors = build_part_colors(parsed_objects, item.cad_part_materials or [])
|
||||
task = regenerate_thumbnail.delay(str(item.cad_file_id), part_colors)
|
||||
|
||||
return {"status": "queued", "task_id": task.id, "cad_file_id": str(item.cad_file_id)}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,126 @@
|
||||
"""Output Types API router."""
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select, or_, cast, String
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.order_line import OrderLine
|
||||
from app.models.output_type import OutputType, VALID_RENDER_BACKENDS
|
||||
from app.schemas.output_type import OutputTypeCreate, OutputTypeOut, OutputTypePatch
|
||||
from app.utils.auth import get_current_user, require_admin_or_pm
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/output-types", tags=["output-types"])
|
||||
|
||||
|
||||
def _ot_to_out(ot: OutputType) -> OutputTypeOut:
|
||||
"""Convert an OutputType ORM instance to OutputTypeOut with pricing convenience fields."""
|
||||
out = OutputTypeOut.model_validate(ot)
|
||||
if ot.pricing_tier:
|
||||
out.pricing_tier_name = f"{ot.pricing_tier.category_key}/{ot.pricing_tier.quality_level}"
|
||||
out.price_per_item = float(ot.pricing_tier.price_per_item)
|
||||
return out
|
||||
|
||||
|
||||
@router.get("", response_model=list[OutputTypeOut])
|
||||
async def list_output_types(
|
||||
include_inactive: bool = Query(False),
|
||||
category: str = Query(""),
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = (
|
||||
select(OutputType)
|
||||
.options(selectinload(OutputType.pricing_tier))
|
||||
.order_by(OutputType.sort_order, OutputType.name)
|
||||
)
|
||||
if not include_inactive:
|
||||
stmt = stmt.where(OutputType.is_active.is_(True))
|
||||
if category:
|
||||
# Show output types where compatible_categories is empty (universal)
|
||||
# or contains the given category
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
cast(OutputType.compatible_categories, String) == "[]",
|
||||
OutputType.compatible_categories.contains([category]),
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return [_ot_to_out(ot) for ot in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("", response_model=OutputTypeOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_output_type(
|
||||
body: OutputTypeCreate,
|
||||
user: User = Depends(require_admin_or_pm),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if body.render_backend not in VALID_RENDER_BACKENDS:
|
||||
raise HTTPException(400, detail=f"Invalid render_backend. Choose: {', '.join(sorted(VALID_RENDER_BACKENDS))}")
|
||||
|
||||
existing = await db.execute(select(OutputType).where(OutputType.name == body.name))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(409, detail=f"Output type '{body.name}' already exists")
|
||||
|
||||
ot = OutputType(**body.model_dump())
|
||||
db.add(ot)
|
||||
await db.commit()
|
||||
await db.refresh(ot)
|
||||
# Reload with pricing_tier
|
||||
result2 = await db.execute(
|
||||
select(OutputType).options(selectinload(OutputType.pricing_tier)).where(OutputType.id == ot.id)
|
||||
)
|
||||
return _ot_to_out(result2.scalar_one())
|
||||
|
||||
|
||||
@router.patch("/{output_type_id}", response_model=OutputTypeOut)
|
||||
async def update_output_type(
|
||||
output_type_id: uuid.UUID,
|
||||
body: OutputTypePatch,
|
||||
user: User = Depends(require_admin_or_pm),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(OutputType).where(OutputType.id == output_type_id))
|
||||
ot = result.scalar_one_or_none()
|
||||
if not ot:
|
||||
raise HTTPException(404, detail="Output type not found")
|
||||
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
if "render_backend" in data and data["render_backend"] not in VALID_RENDER_BACKENDS:
|
||||
raise HTTPException(400, detail=f"Invalid render_backend. Choose: {', '.join(sorted(VALID_RENDER_BACKENDS))}")
|
||||
|
||||
for field_name, value in data.items():
|
||||
setattr(ot, field_name, value)
|
||||
await db.commit()
|
||||
await db.refresh(ot)
|
||||
# Reload with pricing_tier
|
||||
result2 = await db.execute(
|
||||
select(OutputType).options(selectinload(OutputType.pricing_tier)).where(OutputType.id == ot.id)
|
||||
)
|
||||
return _ot_to_out(result2.scalar_one())
|
||||
|
||||
|
||||
@router.delete("/{output_type_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_output_type(
|
||||
output_type_id: uuid.UUID,
|
||||
user: User = Depends(require_admin_or_pm),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(OutputType).where(OutputType.id == output_type_id))
|
||||
ot = result.scalar_one_or_none()
|
||||
if not ot:
|
||||
raise HTTPException(404, detail="Output type not found")
|
||||
|
||||
# Check if referenced by order_lines
|
||||
usage = await db.execute(
|
||||
select(OrderLine).where(OrderLine.output_type_id == output_type_id).limit(1)
|
||||
)
|
||||
if usage.scalar_one_or_none():
|
||||
raise HTTPException(409, detail="Output type is referenced by existing order lines and cannot be deleted")
|
||||
|
||||
await db.delete(ot)
|
||||
await db.commit()
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Pricing tiers router — CRUD for category × quality-level price configuration."""
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, update as sql_update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.pricing_tier import PricingTier
|
||||
from app.models.user import User
|
||||
from app.utils.auth import require_admin_or_pm, get_current_user
|
||||
|
||||
router = APIRouter(prefix="/pricing", tags=["pricing"])
|
||||
|
||||
|
||||
# ── Schemas ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class PricingTierOut(BaseModel):
|
||||
id: int
|
||||
category_key: str
|
||||
quality_level: str
|
||||
price_per_item: float
|
||||
description: Optional[str]
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PricingTierCreate(BaseModel):
|
||||
category_key: str
|
||||
quality_level: str = "Normal"
|
||||
price_per_item: Decimal
|
||||
description: Optional[str] = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class PricingTierPatch(BaseModel):
|
||||
category_key: Optional[str] = None
|
||||
quality_level: Optional[str] = None
|
||||
price_per_item: Optional[Decimal] = None
|
||||
description: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
# ── Endpoints ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("", response_model=list[PricingTierOut])
|
||||
async def list_pricing_tiers(
|
||||
_user: User = Depends(require_admin_or_pm),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[PricingTierOut]:
|
||||
result = await db.execute(
|
||||
select(PricingTier).order_by(PricingTier.category_key, PricingTier.quality_level)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("", response_model=PricingTierOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_pricing_tier(
|
||||
body: PricingTierCreate,
|
||||
_user: User = Depends(require_admin_or_pm),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> PricingTierOut:
|
||||
tier = PricingTier(
|
||||
category_key=body.category_key,
|
||||
quality_level=body.quality_level,
|
||||
price_per_item=body.price_per_item,
|
||||
description=body.description,
|
||||
is_active=body.is_active,
|
||||
)
|
||||
db.add(tier)
|
||||
try:
|
||||
await db.commit()
|
||||
await db.refresh(tier)
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Pricing tier for '{body.category_key}' / '{body.quality_level}' already exists",
|
||||
)
|
||||
return tier
|
||||
|
||||
|
||||
@router.patch("/{tier_id}", response_model=PricingTierOut)
|
||||
async def update_pricing_tier(
|
||||
tier_id: int,
|
||||
body: PricingTierPatch,
|
||||
_user: User = Depends(require_admin_or_pm),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> PricingTierOut:
|
||||
result = await db.execute(select(PricingTier).where(PricingTier.id == tier_id))
|
||||
tier = result.scalar_one_or_none()
|
||||
if tier is None:
|
||||
raise HTTPException(status_code=404, detail="Pricing tier not found")
|
||||
|
||||
patch = body.model_dump(exclude_unset=True)
|
||||
if patch:
|
||||
patch["updated_at"] = datetime.utcnow()
|
||||
await db.execute(
|
||||
sql_update(PricingTier).where(PricingTier.id == tier_id).values(**patch)
|
||||
)
|
||||
await db.commit()
|
||||
result = await db.execute(select(PricingTier).where(PricingTier.id == tier_id))
|
||||
tier = result.scalar_one()
|
||||
return tier
|
||||
|
||||
|
||||
@router.delete("/{tier_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_pricing_tier(
|
||||
tier_id: int,
|
||||
_user: User = Depends(require_admin_or_pm),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
result = await db.execute(select(PricingTier).where(PricingTier.id == tier_id))
|
||||
tier = result.scalar_one_or_none()
|
||||
if tier is None:
|
||||
raise HTTPException(status_code=404, detail="Pricing tier not found")
|
||||
await db.delete(tier)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Price Estimation ──────────────────────────────────────────────────────────
|
||||
|
||||
class EstimateLineInput(BaseModel):
|
||||
product_id: uuid.UUID
|
||||
output_type_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class EstimateRequest(BaseModel):
|
||||
lines: list[EstimateLineInput]
|
||||
|
||||
|
||||
@router.post("/estimate")
|
||||
async def estimate_price(
|
||||
body: EstimateRequest,
|
||||
_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Estimate the total price for a set of prospective order lines.
|
||||
|
||||
Open to all authenticated users (read-only, needed by wizard).
|
||||
"""
|
||||
from app.services.pricing_service import estimate_order_price
|
||||
lines_dicts = [{"product_id": str(l.product_id), "output_type_id": str(l.output_type_id) if l.output_type_id else None} for l in body.lines]
|
||||
return await estimate_order_price(db, lines_dicts)
|
||||
@@ -0,0 +1,931 @@
|
||||
"""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
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload, joinedload
|
||||
|
||||
from app.config import settings
|
||||
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"])
|
||||
|
||||
|
||||
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.render_image_url = _best_render_url(product, priority or ["latest_render", "cad_thumbnail"])
|
||||
out.stl_cached = _stl_cached_qualities(product)
|
||||
return out
|
||||
|
||||
|
||||
def _stl_cached_qualities(product: Product) -> list[str]:
|
||||
"""Return list of STL qualities that are cached on disk for this product."""
|
||||
from pathlib import Path as _Path
|
||||
cad = product.cad_file
|
||||
if not cad or not cad.stored_path:
|
||||
return []
|
||||
step = _Path(cad.stored_path)
|
||||
return [q for q in ("low", "high") if (step.parent / f"{step.stem}_{q}.stl").exists()]
|
||||
|
||||
|
||||
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())
|
||||
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
|
||||
# Delete order_lines referencing this product
|
||||
await db.execute(sql_delete(OrderLine).where(OrderLine.product_id == product_id))
|
||||
await db.delete(product)
|
||||
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")
|
||||
|
||||
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()
|
||||
|
||||
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),
|
||||
}
|
||||
|
||||
|
||||
@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, normalise separators to underscore."""
|
||||
import re as _re
|
||||
name = name.lower().strip()
|
||||
if name.endswith(".prt"):
|
||||
name = name[:-4]
|
||||
# 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.3,
|
||||
) -> 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:
|
||||
1. Exact case-insensitive name match
|
||||
2. Token-based Jaccard similarity on normalised filenames
|
||||
3. Position-based fallback for low-confidence matches
|
||||
"""
|
||||
excel_entries: list[tuple[set[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 ""))
|
||||
|
||||
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 in excel_entries:
|
||||
if raw == cad_raw_lower:
|
||||
best_mat = material
|
||||
best_score = 1.0
|
||||
break
|
||||
score = _jaccard(tokens, cad_tokens)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_mat = material
|
||||
|
||||
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."""
|
||||
# Flamenco / shared renders: /shared/renders/X/file.jpg → /renders/X/file.jpg
|
||||
if "/renders/" in result_path:
|
||||
idx = result_path.index("/renders/")
|
||||
return result_path[idx:]
|
||||
# Celery renders stored as thumbnails: /app/uploads/thumbnails/X.png → /thumbnails/X.png
|
||||
if "/thumbnails/" in result_path:
|
||||
idx = result_path.index("/thumbnails/")
|
||||
return result_path[idx:]
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_disk_path(url: str) -> Path | None:
|
||||
"""Given a servable URL like /renders/X/file.jpg, resolve to disk path."""
|
||||
if url.startswith("/renders/"):
|
||||
return Path(settings.upload_dir) / "renders" / url[len("/renders/"):]
|
||||
if url.startswith("/thumbnails/"):
|
||||
return Path(settings.upload_dir) / "thumbnails" / url[len("/thumbnails/"):]
|
||||
return None
|
||||
|
||||
|
||||
@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),
|
||||
)
|
||||
.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()
|
||||
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,
|
||||
})
|
||||
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:
|
||||
if p.startswith("/shared/"):
|
||||
return settings.upload_dir + p[len("/shared"):]
|
||||
return 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()
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
"""Render Templates API — CRUD + .blend file upload/download + material library."""
|
||||
import uuid
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update as sql_update, delete as sql_delete
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.database import get_db
|
||||
from app.config import settings as app_settings
|
||||
from app.models.user import User
|
||||
from app.models.render_template import RenderTemplate
|
||||
from app.models.output_type import OutputType
|
||||
from app.models.system_setting import SystemSetting
|
||||
from app.utils.auth import require_admin_or_pm
|
||||
|
||||
router = APIRouter(tags=["render-templates"])
|
||||
|
||||
BLEND_DIR = "blend-templates"
|
||||
|
||||
|
||||
def _blend_dir() -> Path:
|
||||
d = Path(app_settings.upload_dir) / BLEND_DIR
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class RenderTemplateOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
category_key: str | None
|
||||
output_type_id: str | None
|
||||
output_type_name: str | None
|
||||
blend_file_path: str
|
||||
original_filename: str
|
||||
target_collection: str
|
||||
material_replace_enabled: bool
|
||||
lighting_only: bool
|
||||
shadow_catcher_enabled: bool
|
||||
camera_orbit: bool
|
||||
is_active: bool
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class RenderTemplateUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
category_key: str | None = None
|
||||
output_type_id: str | None = None
|
||||
target_collection: str | None = None
|
||||
material_replace_enabled: bool | None = None
|
||||
lighting_only: bool | None = None
|
||||
shadow_catcher_enabled: bool | None = None
|
||||
camera_orbit: bool | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class MaterialLibraryInfo(BaseModel):
|
||||
exists: bool
|
||||
filename: str | None = None
|
||||
size_bytes: int | None = None
|
||||
path: str | None = None
|
||||
|
||||
|
||||
def _to_out(t: RenderTemplate) -> dict:
|
||||
ot_name = None
|
||||
if t.output_type:
|
||||
ot_name = t.output_type.name
|
||||
return {
|
||||
"id": str(t.id),
|
||||
"name": t.name,
|
||||
"category_key": t.category_key,
|
||||
"output_type_id": str(t.output_type_id) if t.output_type_id else None,
|
||||
"output_type_name": ot_name,
|
||||
"blend_file_path": t.blend_file_path,
|
||||
"original_filename": t.original_filename,
|
||||
"target_collection": t.target_collection,
|
||||
"material_replace_enabled": t.material_replace_enabled,
|
||||
"lighting_only": t.lighting_only,
|
||||
"shadow_catcher_enabled": t.shadow_catcher_enabled,
|
||||
"camera_orbit": t.camera_orbit,
|
||||
"is_active": t.is_active,
|
||||
"created_at": t.created_at.isoformat() if t.created_at else "",
|
||||
"updated_at": t.updated_at.isoformat() if t.updated_at else "",
|
||||
}
|
||||
|
||||
|
||||
# ── CRUD Endpoints ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/render-templates", response_model=list[RenderTemplateOut])
|
||||
async def list_render_templates(
|
||||
user: User = Depends(require_admin_or_pm),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(RenderTemplate).order_by(RenderTemplate.created_at.desc())
|
||||
)
|
||||
return [_to_out(t) for t in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("/render-templates", response_model=RenderTemplateOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_render_template(
|
||||
name: str = Form(...),
|
||||
file: UploadFile = File(...),
|
||||
category_key: str | None = Form(None),
|
||||
output_type_id: str | None = Form(None),
|
||||
target_collection: str = Form("Product"),
|
||||
material_replace_enabled: bool = Form(False),
|
||||
lighting_only: bool = Form(False),
|
||||
shadow_catcher_enabled: bool = Form(False),
|
||||
camera_orbit: bool = Form(True),
|
||||
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
|
||||
|
||||
template_id = uuid.uuid4()
|
||||
blend_path = _blend_dir() / f"{template_id}.blend"
|
||||
|
||||
with open(blend_path, "wb") as f:
|
||||
shutil.copyfileobj(file.file, f)
|
||||
|
||||
ot_uuid = uuid.UUID(output_type_id) if output_type_id else None
|
||||
|
||||
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,
|
||||
target_collection=target_collection,
|
||||
material_replace_enabled=material_replace_enabled,
|
||||
lighting_only=lighting_only,
|
||||
shadow_catcher_enabled=shadow_catcher_enabled,
|
||||
camera_orbit=camera_orbit,
|
||||
)
|
||||
db.add(tmpl)
|
||||
await db.commit()
|
||||
await db.refresh(tmpl)
|
||||
|
||||
# Eagerly load output_type for response
|
||||
if ot_uuid:
|
||||
ot = await db.get(OutputType, ot_uuid)
|
||||
tmpl.output_type = ot
|
||||
|
||||
return _to_out(tmpl)
|
||||
|
||||
|
||||
@router.patch("/render-templates/{template_id}", response_model=RenderTemplateOut)
|
||||
async def update_render_template(
|
||||
template_id: uuid.UUID,
|
||||
body: RenderTemplateUpdate,
|
||||
user: User = Depends(require_admin_or_pm),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(RenderTemplate).where(RenderTemplate.id == template_id))
|
||||
tmpl = result.scalar_one_or_none()
|
||||
if not tmpl:
|
||||
raise HTTPException(404, detail="Render template not found")
|
||||
|
||||
updates = body.model_dump(exclude_unset=True)
|
||||
|
||||
# Normalise empty strings to None for nullable fields
|
||||
if "category_key" in updates and updates["category_key"] in ("", "null"):
|
||||
updates["category_key"] = None
|
||||
if "output_type_id" in updates:
|
||||
val = updates["output_type_id"]
|
||||
if val in ("", "null", None):
|
||||
updates["output_type_id"] = None
|
||||
else:
|
||||
updates["output_type_id"] = uuid.UUID(val)
|
||||
|
||||
if updates:
|
||||
updates["updated_at"] = datetime.utcnow()
|
||||
await db.execute(
|
||||
sql_update(RenderTemplate)
|
||||
.where(RenderTemplate.id == template_id)
|
||||
.values(**updates)
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(tmpl)
|
||||
|
||||
return _to_out(tmpl)
|
||||
|
||||
|
||||
@router.delete("/render-templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_render_template(
|
||||
template_id: uuid.UUID,
|
||||
user: User = Depends(require_admin_or_pm),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(RenderTemplate).where(RenderTemplate.id == template_id))
|
||||
tmpl = result.scalar_one_or_none()
|
||||
if not tmpl:
|
||||
raise HTTPException(404, detail="Render template not found")
|
||||
|
||||
# Delete .blend file
|
||||
blend_path = Path(tmpl.blend_file_path)
|
||||
if blend_path.exists():
|
||||
blend_path.unlink(missing_ok=True)
|
||||
|
||||
await db.execute(sql_delete(RenderTemplate).where(RenderTemplate.id == template_id))
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/render-templates/{template_id}/upload", response_model=RenderTemplateOut)
|
||||
async def upload_blend_file(
|
||||
template_id: uuid.UUID,
|
||||
file: UploadFile = File(...),
|
||||
user: User = Depends(require_admin_or_pm),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Re-upload a .blend file for an existing template."""
|
||||
if not file.filename or not file.filename.endswith(".blend"):
|
||||
raise HTTPException(400, detail="File must be a .blend file")
|
||||
|
||||
result = await db.execute(select(RenderTemplate).where(RenderTemplate.id == template_id))
|
||||
tmpl = result.scalar_one_or_none()
|
||||
if not tmpl:
|
||||
raise HTTPException(404, detail="Render template not found")
|
||||
|
||||
blend_path = _blend_dir() / f"{template_id}.blend"
|
||||
|
||||
# Remove old file if path changed
|
||||
old_path = Path(tmpl.blend_file_path)
|
||||
if old_path.exists() and old_path != blend_path:
|
||||
old_path.unlink(missing_ok=True)
|
||||
|
||||
with open(blend_path, "wb") as f:
|
||||
shutil.copyfileobj(file.file, f)
|
||||
|
||||
await db.execute(
|
||||
sql_update(RenderTemplate)
|
||||
.where(RenderTemplate.id == template_id)
|
||||
.values(
|
||||
blend_file_path=str(blend_path),
|
||||
original_filename=file.filename,
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(tmpl)
|
||||
return _to_out(tmpl)
|
||||
|
||||
|
||||
@router.get("/render-templates/{template_id}/download")
|
||||
async def download_blend_file(
|
||||
template_id: uuid.UUID,
|
||||
user: User = Depends(require_admin_or_pm),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(RenderTemplate).where(RenderTemplate.id == template_id))
|
||||
tmpl = result.scalar_one_or_none()
|
||||
if not tmpl:
|
||||
raise HTTPException(404, detail="Render template not found")
|
||||
|
||||
blend_path = Path(tmpl.blend_file_path)
|
||||
if not blend_path.exists():
|
||||
raise HTTPException(404, detail=".blend file not found on disk")
|
||||
|
||||
return FileResponse(
|
||||
path=str(blend_path),
|
||||
filename=tmpl.original_filename,
|
||||
media_type="application/octet-stream",
|
||||
)
|
||||
|
||||
|
||||
# ── Material Library ─────────────────────────────────────────────────────────
|
||||
|
||||
MATERIAL_LIBRARY_FILENAME = "material_library.blend"
|
||||
|
||||
|
||||
async def _save_setting(db: AsyncSession, key: str, value: str) -> None:
|
||||
result = await db.execute(
|
||||
sql_update(SystemSetting)
|
||||
.where(SystemSetting.key == key)
|
||||
.values(value=value, updated_at=datetime.utcnow())
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
db.add(SystemSetting(key=key, value=value, updated_at=datetime.utcnow()))
|
||||
|
||||
|
||||
@router.post("/admin/settings/material-library", response_model=MaterialLibraryInfo)
|
||||
async def upload_material_library(
|
||||
file: UploadFile = File(...),
|
||||
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")
|
||||
|
||||
lib_path = _blend_dir() / MATERIAL_LIBRARY_FILENAME
|
||||
with open(lib_path, "wb") as f:
|
||||
shutil.copyfileobj(file.file, f)
|
||||
|
||||
await _save_setting(db, "material_library_path", str(lib_path))
|
||||
await db.commit()
|
||||
|
||||
return MaterialLibraryInfo(
|
||||
exists=True,
|
||||
filename=file.filename,
|
||||
size_bytes=lib_path.stat().st_size,
|
||||
path=str(lib_path),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/admin/settings/material-library", response_model=MaterialLibraryInfo)
|
||||
async def get_material_library(
|
||||
user: User = Depends(require_admin_or_pm),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(SystemSetting).where(SystemSetting.key == "material_library_path")
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
path_str = row.value if row else ""
|
||||
|
||||
if path_str and Path(path_str).exists():
|
||||
p = Path(path_str)
|
||||
return MaterialLibraryInfo(
|
||||
exists=True,
|
||||
filename=p.name,
|
||||
size_bytes=p.stat().st_size,
|
||||
path=path_str,
|
||||
)
|
||||
return MaterialLibraryInfo(exists=False)
|
||||
|
||||
|
||||
@router.delete("/admin/settings/material-library", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_material_library(
|
||||
user: User = Depends(require_admin_or_pm),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(SystemSetting).where(SystemSetting.key == "material_library_path")
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
if row and row.value:
|
||||
p = Path(row.value)
|
||||
if p.exists():
|
||||
p.unlink(missing_ok=True)
|
||||
|
||||
await _save_setting(db, "material_library_path", "")
|
||||
await db.commit()
|
||||
@@ -0,0 +1,78 @@
|
||||
import uuid
|
||||
from typing import Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
from app.database import get_db
|
||||
from app.models.template import Template
|
||||
from app.utils.auth import get_current_user, require_admin
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/templates", tags=["templates"])
|
||||
|
||||
|
||||
class TemplateOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
category_key: str
|
||||
standard_fields: Any
|
||||
component_schema: Any
|
||||
description: str | None
|
||||
is_active: bool
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TemplateUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
is_active: bool | None = None
|
||||
standard_fields: Any = None
|
||||
component_schema: Any = None
|
||||
|
||||
|
||||
@router.get("", response_model=list[TemplateOut])
|
||||
async def list_templates(
|
||||
include_inactive: bool = False,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
q = select(Template)
|
||||
# Non-admins always see only active templates
|
||||
if not include_inactive or user.role.value != "admin":
|
||||
q = q.where(Template.is_active == True)
|
||||
result = await db.execute(q)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.get("/{template_id}", response_model=TemplateOut)
|
||||
async def get_template(
|
||||
template_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Template).where(Template.id == template_id))
|
||||
t = result.scalar_one_or_none()
|
||||
if not t:
|
||||
raise HTTPException(404, detail="Template not found")
|
||||
return t
|
||||
|
||||
|
||||
@router.patch("/{template_id}", response_model=TemplateOut)
|
||||
async def update_template(
|
||||
template_id: uuid.UUID,
|
||||
body: TemplateUpdate,
|
||||
user: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Template).where(Template.id == template_id))
|
||||
t = result.scalar_one_or_none()
|
||||
if not t:
|
||||
raise HTTPException(404, detail="Template not found")
|
||||
|
||||
for field, val in body.model_dump(exclude_unset=True).items():
|
||||
setattr(t, field, val)
|
||||
await db.commit()
|
||||
await db.refresh(t)
|
||||
return t
|
||||
@@ -0,0 +1,411 @@
|
||||
import hashlib
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models.cad_file import CadFile, ProcessingStatus
|
||||
from app.models.order import Order
|
||||
from app.models.order_item import OrderItem
|
||||
from app.models.order_line import OrderLine
|
||||
from app.models.user import User
|
||||
from app.schemas.upload import ParsedExcelResponse, ParsedRow, ParsedComponent, StepUploadResponse
|
||||
from app.schemas.order import OrderDetailOut
|
||||
from app.services.excel_parser import parse_excel, parsed_excel_to_dict
|
||||
from app.services.excel_import import import_excel_to_products, preview_excel_rows
|
||||
from app.services.order_service import generate_order_number
|
||||
from app.utils.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/uploads", tags=["uploads"])
|
||||
|
||||
|
||||
# ── Preview response models ────────────────────────────────────────────
|
||||
|
||||
class ExcelPreviewRow(BaseModel):
|
||||
row_index: int
|
||||
pim_id: str | None = None
|
||||
produkt_baureihe: str | None = None
|
||||
gewaehltes_produkt: str | None = None
|
||||
product_exists: bool = False
|
||||
product_id: str | None = None
|
||||
medias_rendering: bool | None = None
|
||||
category_key: str | None = None
|
||||
has_step: bool = False
|
||||
is_duplicate: bool = False
|
||||
duplicate_of_row: int | None = None
|
||||
|
||||
|
||||
class ExcelPreviewResponse(BaseModel):
|
||||
excel_path: str
|
||||
filename: str
|
||||
category_key: str | None
|
||||
row_count: int
|
||||
existing_product_count: int
|
||||
new_product_count: int
|
||||
no_pim_id_count: int
|
||||
has_step_count: int = 0
|
||||
no_step_count: int = 0
|
||||
duplicate_count: int = 0
|
||||
warnings: list[str]
|
||||
rows: list[ExcelPreviewRow]
|
||||
column_headers: list[str] = []
|
||||
template_name: str | None = None
|
||||
|
||||
|
||||
# ── Finalize request models ────────────────────────────────────────────
|
||||
|
||||
class OutputTypeSelection(BaseModel):
|
||||
row_index: int
|
||||
output_type_ids: list[uuid.UUID]
|
||||
|
||||
|
||||
class ExcelFinalizeRequest(BaseModel):
|
||||
excel_path: str
|
||||
included_row_indices: list[int]
|
||||
output_type_selections: list[OutputTypeSelection] = []
|
||||
notes: str | None = None
|
||||
template_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
ALLOWED_EXCEL = {".xlsx", ".xls"}
|
||||
ALLOWED_STEP = {".stp", ".step"}
|
||||
|
||||
|
||||
@router.post("/excel", response_model=ExcelPreviewResponse)
|
||||
async def upload_excel(
|
||||
file: UploadFile = File(...),
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Parse Excel and return a read-only preview. No products are created."""
|
||||
suffix = Path(file.filename or "").suffix.lower()
|
||||
if suffix not in ALLOWED_EXCEL:
|
||||
raise HTTPException(400, detail="Only .xlsx / .xls files are accepted")
|
||||
|
||||
# Save the file
|
||||
upload_dir = Path(settings.upload_dir) / "excel_files"
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
tmp_name = f"{uuid.uuid4()}{suffix}"
|
||||
tmp_path = upload_dir / tmp_name
|
||||
|
||||
content = await file.read()
|
||||
tmp_path.write_bytes(content)
|
||||
|
||||
try:
|
||||
parsed = parse_excel(tmp_path)
|
||||
except ValueError as exc:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise HTTPException(422, detail=str(exc))
|
||||
|
||||
parsed_dict = parsed_excel_to_dict(parsed)
|
||||
parsed_dict["filename"] = file.filename
|
||||
parsed_dict["excel_path"] = str(tmp_path)
|
||||
|
||||
rows = parsed_dict.get("rows", [])
|
||||
try:
|
||||
preview = await preview_excel_rows(
|
||||
db, rows, category_key=parsed_dict.get("category_key"),
|
||||
)
|
||||
except Exception as exc:
|
||||
try:
|
||||
from app.services.notification_service import emit_notification
|
||||
await emit_notification(
|
||||
db,
|
||||
actor_user_id=user.id,
|
||||
target_user_id=user.id,
|
||||
action="excel.import_error",
|
||||
entity_type="upload",
|
||||
entity_id=None,
|
||||
details={
|
||||
"filename": file.filename or "",
|
||||
"error": str(exc)[:500],
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(500, detail=f"Preview failed: {str(exc)[:300]}")
|
||||
|
||||
annotated_rows = [
|
||||
ExcelPreviewRow(
|
||||
row_index=r.get("row_index", 0),
|
||||
pim_id=r.get("pim_id"),
|
||||
produkt_baureihe=r.get("produkt_baureihe"),
|
||||
gewaehltes_produkt=r.get("gewaehltes_produkt"),
|
||||
product_exists=r.get("product_exists", False),
|
||||
product_id=r.get("product_id"),
|
||||
medias_rendering=r.get("medias_rendering"),
|
||||
category_key=r.get("category_key"),
|
||||
has_step=r.get("has_step", False),
|
||||
is_duplicate=r.get("is_duplicate", False),
|
||||
duplicate_of_row=r.get("duplicate_of_row"),
|
||||
)
|
||||
for r in preview.rows
|
||||
]
|
||||
|
||||
all_warnings = preview.warnings + parsed_dict.get("warnings", [])
|
||||
|
||||
if all_warnings:
|
||||
from app.services.notification_service import emit_notification
|
||||
await emit_notification(
|
||||
db,
|
||||
actor_user_id=user.id,
|
||||
target_user_id=user.id,
|
||||
action="excel.import_warnings",
|
||||
entity_type="upload",
|
||||
entity_id=None,
|
||||
details={
|
||||
"filename": file.filename or "",
|
||||
"warning_count": len(all_warnings),
|
||||
"warnings": all_warnings[:10],
|
||||
},
|
||||
)
|
||||
|
||||
return ExcelPreviewResponse(
|
||||
excel_path=str(tmp_path),
|
||||
filename=file.filename or "",
|
||||
category_key=parsed_dict.get("category_key"),
|
||||
row_count=parsed_dict.get("row_count", len(rows)),
|
||||
existing_product_count=preview.existing_product_count,
|
||||
new_product_count=preview.new_product_count,
|
||||
no_pim_id_count=preview.no_pim_id_count,
|
||||
has_step_count=preview.has_step_count,
|
||||
no_step_count=preview.no_step_count,
|
||||
duplicate_count=preview.duplicate_count,
|
||||
warnings=all_warnings,
|
||||
rows=annotated_rows,
|
||||
column_headers=parsed_dict.get("column_headers", []),
|
||||
template_name=parsed_dict.get("template_name"),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/excel/finalize", response_model=OrderDetailOut, status_code=status.HTTP_201_CREATED)
|
||||
async def finalize_excel(
|
||||
body: ExcelFinalizeRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create products + order + lines from a previously parsed Excel file.
|
||||
|
||||
This is the second step: the user has reviewed the preview and confirmed
|
||||
which rows to include and which output types to request.
|
||||
"""
|
||||
# 1. Validate Excel file still exists
|
||||
excel_path = Path(body.excel_path)
|
||||
if not excel_path.is_file():
|
||||
raise HTTPException(404, detail="Excel file not found — please re-upload")
|
||||
|
||||
# 2. Re-parse the Excel
|
||||
try:
|
||||
parsed = parse_excel(excel_path)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(422, detail=str(exc))
|
||||
|
||||
parsed_dict = parsed_excel_to_dict(parsed)
|
||||
all_rows = parsed_dict.get("rows", [])
|
||||
|
||||
# 3. Filter to included rows
|
||||
included_set = set(body.included_row_indices)
|
||||
included_rows = [r for r in all_rows if r.get("row_index") in included_set]
|
||||
|
||||
if not included_rows:
|
||||
raise HTTPException(400, detail="No rows selected")
|
||||
|
||||
# 4. Import into product library (creates/updates products)
|
||||
import_result = await import_excel_to_products(
|
||||
db,
|
||||
included_rows,
|
||||
source_excel=str(excel_path),
|
||||
category_key=parsed_dict.get("category_key"),
|
||||
)
|
||||
|
||||
# 5. Seed material aliases
|
||||
material_mappings = parsed_dict.get("material_mappings", [])
|
||||
if material_mappings:
|
||||
try:
|
||||
from app.services.material_service import seed_material_aliases_from_mappings
|
||||
await seed_material_aliases_from_mappings(db, material_mappings)
|
||||
except Exception:
|
||||
pass # non-critical
|
||||
|
||||
# 6. Create Order
|
||||
order_number = await generate_order_number(db)
|
||||
order = Order(
|
||||
order_number=order_number,
|
||||
template_id=body.template_id,
|
||||
created_by=user.id,
|
||||
source_excel=str(excel_path),
|
||||
notes=body.notes,
|
||||
)
|
||||
db.add(order)
|
||||
await db.flush()
|
||||
|
||||
# 7. Create OrderItems (legacy compat — one per included row)
|
||||
for row in import_result.rows:
|
||||
# If the matched product already has a STEP file linked (from a
|
||||
# previous order or direct product-library upload), inherit it so the
|
||||
# submit validation passes without requiring a re-upload.
|
||||
inherited_cad = (
|
||||
uuid.UUID(row["product_cad_file_id"])
|
||||
if row.get("product_cad_file_id")
|
||||
else None
|
||||
)
|
||||
item = OrderItem(
|
||||
order_id=order.id,
|
||||
row_index=row.get("row_index", 0),
|
||||
ebene1=row.get("ebene1"),
|
||||
ebene2=row.get("ebene2"),
|
||||
baureihe=row.get("baureihe"),
|
||||
pim_id=row.get("pim_id"),
|
||||
produkt_baureihe=row.get("produkt_baureihe"),
|
||||
gewaehltes_produkt=row.get("gewaehltes_produkt"),
|
||||
name_cad_modell=row.get("name_cad_modell"),
|
||||
gewuenschte_bildnummer=row.get("gewuenschte_bildnummer"),
|
||||
lagertyp=row.get("lagertyp"),
|
||||
medias_rendering=row.get("medias_rendering"),
|
||||
components=[
|
||||
c if isinstance(c, dict) else c
|
||||
for c in row.get("components", [])
|
||||
],
|
||||
cad_file_id=inherited_cad,
|
||||
)
|
||||
db.add(item)
|
||||
|
||||
# 8. Build output type selections lookup: row_index → list[UUID]
|
||||
ot_map: dict[int, list[uuid.UUID]] = {}
|
||||
for sel in body.output_type_selections:
|
||||
ot_map[sel.row_index] = sel.output_type_ids
|
||||
|
||||
# 9. Create OrderLines
|
||||
for row in import_result.rows:
|
||||
product_id = row.get("product_id")
|
||||
if not product_id:
|
||||
continue
|
||||
|
||||
row_idx = row.get("row_index", 0)
|
||||
type_ids = ot_map.get(row_idx, [])
|
||||
|
||||
if not type_ids:
|
||||
# Tracking-only line (no output type)
|
||||
line = OrderLine(
|
||||
order_id=order.id,
|
||||
product_id=uuid.UUID(product_id),
|
||||
output_type_id=None,
|
||||
gewuenschte_bildnummer=row.get("gewuenschte_bildnummer"),
|
||||
)
|
||||
db.add(line)
|
||||
else:
|
||||
for type_id in type_ids:
|
||||
line = OrderLine(
|
||||
order_id=order.id,
|
||||
product_id=uuid.UUID(product_id),
|
||||
output_type_id=type_id,
|
||||
gewuenschte_bildnummer=row.get("gewuenschte_bildnummer"),
|
||||
)
|
||||
db.add(line)
|
||||
|
||||
# 10. Commit, then snapshot prices into the new draft order
|
||||
try:
|
||||
await db.commit()
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
# Emit error notification via its own connection (session is now invalid)
|
||||
try:
|
||||
from app.services.notification_service import emit_notification_sync
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
if isinstance(exc, IntegrityError) and "order_number" in str(exc):
|
||||
error_msg = "Duplicate order number — please try again"
|
||||
else:
|
||||
error_msg = str(exc)[:300]
|
||||
emit_notification_sync(
|
||||
actor_user_id=user.id,
|
||||
target_user_id=str(user.id),
|
||||
action="excel.finalize_error",
|
||||
entity_type="upload",
|
||||
entity_id=None,
|
||||
details={
|
||||
"filename": Path(body.excel_path).name,
|
||||
"error": error_msg,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
if isinstance(exc, IntegrityError) and "order_number" in str(exc):
|
||||
raise HTTPException(409, detail="Duplicate order number — please try again")
|
||||
raise HTTPException(500, detail=f"Order creation failed: {str(exc)[:200]}")
|
||||
|
||||
# Snapshot prices into the draft order so the estimate is visible immediately
|
||||
try:
|
||||
from app.services.pricing_service import refresh_order_price
|
||||
await refresh_order_price(db, order.id)
|
||||
except Exception:
|
||||
pass # non-critical — estimate can be computed on first view
|
||||
|
||||
# Load and return full order detail
|
||||
from app.api.routers.orders import _load_order_detail, _order_detail_out
|
||||
order_loaded = await _load_order_detail(db, order.id)
|
||||
return _order_detail_out(order_loaded)
|
||||
|
||||
|
||||
@router.post("/step", response_model=StepUploadResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def upload_step(
|
||||
file: UploadFile = File(...),
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Upload a single STEP/STP CAD file."""
|
||||
suffix = Path(file.filename or "").suffix.lower()
|
||||
if suffix not in ALLOWED_STEP:
|
||||
raise HTTPException(400, detail="Only .stp / .step files are accepted")
|
||||
|
||||
content = await file.read()
|
||||
file_hash = hashlib.sha256(content).hexdigest()
|
||||
|
||||
# Check dedup
|
||||
result = await db.execute(select(CadFile).where(CadFile.file_hash == file_hash))
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing:
|
||||
return StepUploadResponse(
|
||||
cad_file_id=str(existing.id),
|
||||
original_name=existing.original_name,
|
||||
file_hash=file_hash,
|
||||
status="already_exists",
|
||||
)
|
||||
|
||||
# Save file
|
||||
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)
|
||||
|
||||
# Enqueue background processing task (Phase 3)
|
||||
try:
|
||||
from app.tasks.step_tasks import process_step_file
|
||||
process_step_file.delay(str(cad_file.id))
|
||||
except Exception:
|
||||
pass # Worker not configured yet
|
||||
|
||||
return StepUploadResponse(
|
||||
cad_file_id=str(cad_file.id),
|
||||
original_name=file.filename,
|
||||
file_hash=file_hash,
|
||||
status="uploaded",
|
||||
)
|
||||
@@ -0,0 +1,356 @@
|
||||
"""Worker activity router — exposes recent background task status."""
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.cad_file import CadFile, ProcessingStatus
|
||||
from app.models.order_item import OrderItem
|
||||
from app.models.order import Order
|
||||
from app.models.order_line import OrderLine
|
||||
from app.models.product import Product
|
||||
from app.models.user import User
|
||||
from app.utils.auth import get_current_user, require_admin_or_pm
|
||||
|
||||
router = APIRouter(prefix="/worker", tags=["worker"])
|
||||
|
||||
|
||||
class CadActivityEntry(BaseModel):
|
||||
cad_file_id: str
|
||||
original_name: str
|
||||
file_size: int | None
|
||||
processing_status: str
|
||||
error_message: str | None
|
||||
updated_at: str
|
||||
created_at: str
|
||||
order_numbers: list[str]
|
||||
render_log: dict | None
|
||||
|
||||
|
||||
class RenderJobEntry(BaseModel):
|
||||
order_line_id: str
|
||||
order_number: str | None
|
||||
product_name: str | None
|
||||
output_type_name: str | None
|
||||
render_status: str
|
||||
render_backend_used: str | None
|
||||
flamenco_job_id: str | None
|
||||
render_started_at: str | None
|
||||
render_completed_at: str | None
|
||||
updated_at: str
|
||||
|
||||
|
||||
class WorkerActivity(BaseModel):
|
||||
cad_processing: list[CadActivityEntry]
|
||||
active_count: int # files currently in "processing" state
|
||||
failed_count: int # files in "failed" state (recent 50)
|
||||
render_jobs: list[RenderJobEntry] = []
|
||||
render_active_count: int = 0
|
||||
render_failed_count: int = 0
|
||||
|
||||
|
||||
@router.get("/activity", response_model=WorkerActivity)
|
||||
async def get_worker_activity(
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Return recent CAD file processing activity.
|
||||
|
||||
Shows the last 30 processed/failed/processing CAD files so the user can
|
||||
see what the worker is doing without needing Flower or Celery logs.
|
||||
"""
|
||||
# Recent CadFiles ordered by last update, with order_items to resolve order numbers
|
||||
result = await db.execute(
|
||||
select(CadFile)
|
||||
.order_by(CadFile.updated_at.desc())
|
||||
.limit(30)
|
||||
)
|
||||
cad_files = result.scalars().all()
|
||||
|
||||
if not cad_files:
|
||||
return WorkerActivity(cad_processing=[], active_count=0, failed_count=0)
|
||||
|
||||
# Fetch order items referencing these CAD files in one query
|
||||
cad_ids = [cf.id for cf in cad_files]
|
||||
items_result = await db.execute(
|
||||
select(OrderItem)
|
||||
.options(selectinload(OrderItem.order))
|
||||
.where(OrderItem.cad_file_id.in_(cad_ids))
|
||||
)
|
||||
items = items_result.scalars().all()
|
||||
|
||||
# Build cad_file_id → list[order_number] mapping
|
||||
from collections import defaultdict
|
||||
cad_to_orders: dict[str, list[str]] = defaultdict(list)
|
||||
for item in items:
|
||||
if item.order and item.order.order_number:
|
||||
key = str(item.cad_file_id)
|
||||
if item.order.order_number not in cad_to_orders[key]:
|
||||
cad_to_orders[key].append(item.order.order_number)
|
||||
|
||||
entries = []
|
||||
for cf in cad_files:
|
||||
entries.append(CadActivityEntry(
|
||||
cad_file_id=str(cf.id),
|
||||
original_name=cf.original_name or "unknown",
|
||||
file_size=getattr(cf, "file_size", None),
|
||||
processing_status=cf.processing_status.value if cf.processing_status else "unknown",
|
||||
error_message=getattr(cf, "error_message", None),
|
||||
updated_at=cf.updated_at.isoformat() if cf.updated_at else datetime.utcnow().isoformat(),
|
||||
created_at=cf.created_at.isoformat() if cf.created_at else datetime.utcnow().isoformat(),
|
||||
order_numbers=cad_to_orders.get(str(cf.id), []),
|
||||
render_log=getattr(cf, "render_log", None),
|
||||
))
|
||||
|
||||
active_count = sum(
|
||||
1 for cf in cad_files
|
||||
if cf.processing_status == ProcessingStatus.processing
|
||||
)
|
||||
failed_count = sum(
|
||||
1 for cf in cad_files
|
||||
if cf.processing_status == ProcessingStatus.failed
|
||||
)
|
||||
|
||||
# ── Render job activity ──────────────────────────────────────────────
|
||||
render_result = await db.execute(
|
||||
select(OrderLine)
|
||||
.options(
|
||||
selectinload(OrderLine.product),
|
||||
selectinload(OrderLine.output_type),
|
||||
selectinload(OrderLine.order),
|
||||
)
|
||||
.where(OrderLine.output_type_id.isnot(None))
|
||||
.where(OrderLine.render_status != "pending")
|
||||
.order_by(OrderLine.updated_at.desc())
|
||||
.limit(30)
|
||||
)
|
||||
render_lines = render_result.scalars().all()
|
||||
|
||||
render_entries = []
|
||||
for rl in render_lines:
|
||||
render_entries.append(RenderJobEntry(
|
||||
order_line_id=str(rl.id),
|
||||
order_number=rl.order.order_number if rl.order else None,
|
||||
product_name=rl.product.name if rl.product else None,
|
||||
output_type_name=rl.output_type.name if rl.output_type else None,
|
||||
render_status=rl.render_status,
|
||||
render_backend_used=rl.render_backend_used,
|
||||
flamenco_job_id=rl.flamenco_job_id,
|
||||
render_started_at=rl.render_started_at.isoformat() if rl.render_started_at else None,
|
||||
render_completed_at=rl.render_completed_at.isoformat() if rl.render_completed_at else None,
|
||||
updated_at=rl.updated_at.isoformat(),
|
||||
))
|
||||
|
||||
render_active = sum(1 for rl in render_lines if rl.render_status == "processing")
|
||||
render_failed = sum(1 for rl in render_lines if rl.render_status == "failed")
|
||||
|
||||
return WorkerActivity(
|
||||
cad_processing=entries,
|
||||
active_count=active_count,
|
||||
failed_count=failed_count,
|
||||
render_jobs=render_entries,
|
||||
render_active_count=render_active,
|
||||
render_failed_count=render_failed,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/render-log/{order_line_id}")
|
||||
async def get_render_log(
|
||||
order_line_id: str,
|
||||
after: int = 0,
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return render log entries for an order line (polling fallback)."""
|
||||
from app.services.render_log import get_entries, count
|
||||
entries = get_entries(order_line_id, after_index=after)
|
||||
total = count(order_line_id)
|
||||
return {"entries": entries, "total": total, "next_after": total}
|
||||
|
||||
|
||||
@router.get("/render-log/{order_line_id}/stream")
|
||||
async def stream_render_log(
|
||||
order_line_id: str,
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""SSE stream of render log entries for an order line."""
|
||||
import asyncio
|
||||
import json
|
||||
from fastapi.responses import StreamingResponse
|
||||
from app.services.render_log import get_entries, count
|
||||
|
||||
async def event_generator():
|
||||
cursor = 0
|
||||
idle_ticks = 0
|
||||
max_idle = 120 # stop after 2 minutes of no new entries
|
||||
while idle_ticks < max_idle:
|
||||
entries = get_entries(order_line_id, after_index=cursor)
|
||||
if entries:
|
||||
idle_ticks = 0
|
||||
for entry in entries:
|
||||
yield f"data: {json.dumps(entry)}\n\n"
|
||||
cursor += len(entries)
|
||||
else:
|
||||
idle_ticks += 1
|
||||
await asyncio.sleep(1)
|
||||
yield f"data: {json.dumps({'level': 'info', 'msg': 'Stream ended (idle timeout)', 't': ''})}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
from fastapi import status as http_status
|
||||
|
||||
|
||||
@router.post("/activity/{cad_file_id}/reprocess", status_code=http_status.HTTP_202_ACCEPTED)
|
||||
async def reprocess_cad_file(
|
||||
cad_file_id: str,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Re-queue a CAD file for full processing (STEP extraction + thumbnail + glTF)."""
|
||||
result = await db.execute(select(CadFile).where(CadFile.id == cad_file_id))
|
||||
cad_file = result.scalar_one_or_none()
|
||||
if not cad_file:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(404, detail="CAD file not found")
|
||||
|
||||
cad_file.processing_status = ProcessingStatus.pending
|
||||
await db.commit()
|
||||
|
||||
from app.tasks.step_tasks import process_step_file
|
||||
process_step_file.delay(cad_file_id)
|
||||
return {"queued": cad_file_id, "task": "process_step_file"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Queue inspection + control
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MONITORED_QUEUES = ["step_processing", "thumbnail_rendering", "ai_validation"]
|
||||
|
||||
|
||||
def _parse_redis_task(raw: str) -> dict | None:
|
||||
"""Parse a raw Redis Celery message into a simplified dict."""
|
||||
import json, base64
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
headers = msg.get("headers", {})
|
||||
task_name = headers.get("task", "unknown")
|
||||
task_id = headers.get("id", "unknown")
|
||||
argsrepr = headers.get("argsrepr", "")
|
||||
args: list = []
|
||||
try:
|
||||
body = json.loads(base64.b64decode(msg.get("body", "")))
|
||||
if isinstance(body, list) and body:
|
||||
args = list(body[0])
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"task_name": task_name,
|
||||
"args": args,
|
||||
"argsrepr": argsrepr,
|
||||
"status": "pending",
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/queue")
|
||||
async def get_queue_status(user: User = Depends(get_current_user)):
|
||||
"""Return Celery queue depths, pending tasks, and active/reserved tasks."""
|
||||
import asyncio
|
||||
import redis as redis_lib
|
||||
from app.config import settings as app_settings
|
||||
from app.tasks.celery_app import celery_app
|
||||
|
||||
r = redis_lib.from_url(app_settings.redis_url, decode_responses=True)
|
||||
|
||||
# Pending tasks per queue from Redis
|
||||
queue_depths: dict[str, int] = {}
|
||||
pending: list[dict] = []
|
||||
for q in MONITORED_QUEUES:
|
||||
depth = r.llen(q) or 0
|
||||
queue_depths[q] = depth
|
||||
if depth > 0:
|
||||
raw_items = r.lrange(q, 0, 99)
|
||||
for raw in raw_items:
|
||||
task = _parse_redis_task(raw)
|
||||
if task:
|
||||
task["queue"] = q
|
||||
pending.append(task)
|
||||
|
||||
# Active / reserved from Celery inspect (runs in thread, 1.5 s timeout)
|
||||
active: list[dict] = []
|
||||
reserved: list[dict] = []
|
||||
|
||||
def _inspect() -> tuple[dict, dict]:
|
||||
try:
|
||||
insp = celery_app.control.inspect(timeout=1.5)
|
||||
return (insp.active() or {}), (insp.reserved() or {})
|
||||
except Exception:
|
||||
return {}, {}
|
||||
|
||||
act_raw, rsv_raw = await asyncio.to_thread(_inspect)
|
||||
|
||||
for worker, tasks in act_raw.items():
|
||||
for t in (tasks or []):
|
||||
active.append({
|
||||
"task_id": t.get("id", ""),
|
||||
"task_name": t.get("name", ""),
|
||||
"args": list(t.get("args") or []),
|
||||
"argsrepr": t.get("kwargs", {}).get("argsrepr", ""),
|
||||
"status": "active",
|
||||
"worker": worker,
|
||||
})
|
||||
|
||||
for worker, tasks in rsv_raw.items():
|
||||
for t in (tasks or []):
|
||||
reserved.append({
|
||||
"task_id": t.get("id", ""),
|
||||
"task_name": t.get("name", ""),
|
||||
"args": list(t.get("args") or []),
|
||||
"argsrepr": "",
|
||||
"status": "reserved",
|
||||
"worker": worker,
|
||||
})
|
||||
|
||||
return {
|
||||
"queue_depths": queue_depths,
|
||||
"pending_count": sum(queue_depths.values()),
|
||||
"active": active,
|
||||
"reserved": reserved,
|
||||
"pending": pending,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/queue/purge", status_code=http_status.HTTP_202_ACCEPTED)
|
||||
async def purge_queue(user: User = Depends(require_admin_or_pm)):
|
||||
"""Delete all pending tasks from all monitored queues."""
|
||||
import redis as redis_lib
|
||||
from app.config import settings as app_settings
|
||||
|
||||
r = redis_lib.from_url(app_settings.redis_url, decode_responses=True)
|
||||
total = 0
|
||||
for q in MONITORED_QUEUES:
|
||||
count = r.llen(q) or 0
|
||||
if count:
|
||||
r.delete(q)
|
||||
total += count
|
||||
return {"purged": total, "message": f"Removed {total} pending task(s) from queue"}
|
||||
|
||||
|
||||
@router.post("/queue/cancel/{task_id}", status_code=http_status.HTTP_202_ACCEPTED)
|
||||
async def cancel_task(task_id: str, user: User = Depends(require_admin_or_pm)):
|
||||
"""Revoke a task by ID. Terminates it if running, skips it if still pending."""
|
||||
from app.tasks.celery_app import celery_app
|
||||
celery_app.control.revoke(task_id, terminate=True, signal="SIGTERM")
|
||||
return {"revoked": task_id}
|
||||
@@ -0,0 +1,50 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# Database
|
||||
postgres_db: str = "schaeffler"
|
||||
postgres_user: str = "schaeffler"
|
||||
postgres_password: str = "schaeffler"
|
||||
postgres_host: str = "localhost"
|
||||
postgres_port: int = 5432
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
return (
|
||||
f"postgresql+asyncpg://{self.postgres_user}:{self.postgres_password}"
|
||||
f"@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}"
|
||||
)
|
||||
|
||||
@property
|
||||
def database_url_sync(self) -> str:
|
||||
return (
|
||||
f"postgresql://{self.postgres_user}:{self.postgres_password}"
|
||||
f"@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}"
|
||||
)
|
||||
|
||||
# Redis / Celery
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
|
||||
# JWT
|
||||
jwt_secret_key: str = "changeme"
|
||||
jwt_algorithm: str = "HS256"
|
||||
jwt_access_token_expire_minutes: int = 480
|
||||
|
||||
# Azure OpenAI
|
||||
azure_openai_api_key: Optional[str] = None
|
||||
azure_openai_endpoint: Optional[str] = None
|
||||
azure_openai_deployment: str = "gpt-4o"
|
||||
azure_openai_api_version: str = "2024-02-01"
|
||||
|
||||
# File Storage
|
||||
upload_dir: str = "/app/uploads"
|
||||
max_upload_size_mb: int = 500
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = False
|
||||
|
||||
|
||||
settings = Settings()
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,298 @@
|
||||
"""Material alias seed data — derived from naming_scheme.xlsx Materialmapping sheet.
|
||||
|
||||
Each entry maps a SCHAEFFLER library material name to its known aliases:
|
||||
- German description (Col A from Materialmapping)
|
||||
- Intermediate identifier (Col B, e.g. "Steel_black_oxided--Stahl_brueniert")
|
||||
- Schaeffler code as string (e.g. "10102")
|
||||
"""
|
||||
|
||||
MATERIAL_ALIAS_SEEDS: list[dict] = [
|
||||
# --- 01 Metals ---
|
||||
{
|
||||
"material_name": "SCHAEFFLER_010101_Steel-Bare",
|
||||
"aliases": [
|
||||
"Stahl",
|
||||
"Stahl, glänzend",
|
||||
"Stahl, konserviert",
|
||||
"Steel--Stahl",
|
||||
"Steel_bearings--Stahl_Lager",
|
||||
"Steel",
|
||||
"Stahl, gänzend",
|
||||
"10101",
|
||||
],
|
||||
},
|
||||
{
|
||||
"material_name": "SCHAEFFLER_010102_Steel-Burnished",
|
||||
"aliases": [
|
||||
"Stahl, brüniert",
|
||||
"Steel_black_oxided--Stahl_brueniert",
|
||||
"10102",
|
||||
],
|
||||
},
|
||||
{
|
||||
"material_name": "SCHAEFFLER_010103_Steel-Galvanized",
|
||||
"aliases": [
|
||||
"Stahl, verzinkt",
|
||||
"Steel_galvanized--Stahl_verzinkt",
|
||||
"MU-Stahl, Zinnüberzug",
|
||||
"MX-Stahl, Zinnüberzug",
|
||||
"10103",
|
||||
],
|
||||
},
|
||||
{
|
||||
"material_name": "SCHAEFFLER_010104_Steel-Casted",
|
||||
"aliases": [
|
||||
"Stahl Körnung",
|
||||
"Guss",
|
||||
"Steel_cast--Stahl_Guss",
|
||||
"10104",
|
||||
],
|
||||
},
|
||||
{
|
||||
"material_name": "SCHAEFFLER_010105_Steel-Plate",
|
||||
"aliases": [
|
||||
"Stahlblech",
|
||||
"Steel_sheet--Stahlblech",
|
||||
"10105",
|
||||
],
|
||||
},
|
||||
{
|
||||
"material_name": "SCHAEFFLER_010201_Niro",
|
||||
"aliases": [
|
||||
"Niro",
|
||||
"Steel_stainless--Niro",
|
||||
"10201",
|
||||
],
|
||||
},
|
||||
{
|
||||
"material_name": "SCHAEFFLER_010301_Tin",
|
||||
"aliases": [
|
||||
"Zinnüberzug",
|
||||
"Tin--Zinn",
|
||||
"10301",
|
||||
],
|
||||
},
|
||||
{
|
||||
"material_name": "SCHAEFFLER_010401_Aluminium",
|
||||
"aliases": [
|
||||
"Aluminium",
|
||||
"Aluminium--Aluminium",
|
||||
"10401",
|
||||
],
|
||||
},
|
||||
{
|
||||
"material_name": "SCHAEFFLER_010501_Brass",
|
||||
"aliases": [
|
||||
"Messing",
|
||||
"Brass--Messing",
|
||||
"10501",
|
||||
],
|
||||
},
|
||||
{
|
||||
"material_name": "SCHAEFFLER_010601_Bronze",
|
||||
"aliases": [
|
||||
"MU-B; Bronze",
|
||||
"Bronze",
|
||||
"Bronze--Bronze",
|
||||
"10601",
|
||||
],
|
||||
},
|
||||
# --- 02 Coatings ---
|
||||
{
|
||||
"material_name": "SCHAEFFLER_020101_Durotect-Blue",
|
||||
"aliases": [
|
||||
"Stahl, Durotect CMT",
|
||||
"Durotect_CMT--Durotect_CMT",
|
||||
"20101",
|
||||
],
|
||||
},
|
||||
{
|
||||
"material_name": "SCHAEFFLER_020102_Durotect-Black",
|
||||
"aliases": [
|
||||
"Stahl, Durotect M",
|
||||
"Stahl; Durotect M",
|
||||
"Durotect_M--Durotect_M",
|
||||
"20102",
|
||||
],
|
||||
},
|
||||
{
|
||||
"material_name": "SCHAEFFLER_020201_Coat-Black",
|
||||
"aliases": [
|
||||
"Stahl, schwarz",
|
||||
"Steel_coated_black--Stahl_beschichtet_schwarz",
|
||||
"20201",
|
||||
],
|
||||
},
|
||||
# --- 03 Non-metals ---
|
||||
{
|
||||
"material_name": "SCHAEFFLER_030101_Elastomer-Brown",
|
||||
"aliases": [
|
||||
"Elastomer, braun",
|
||||
"Elastomer_brown--Elastomer_braun",
|
||||
"30101",
|
||||
],
|
||||
},
|
||||
{
|
||||
"material_name": "SCHAEFFLER_030102_Elastomer-Green",
|
||||
"aliases": [
|
||||
"Elastomer, grün",
|
||||
"Elastomer_green--Elastomer_gruen",
|
||||
"30102",
|
||||
],
|
||||
},
|
||||
{
|
||||
"material_name": "SCHAEFFLER_030103_Elastomer-Black",
|
||||
"aliases": [
|
||||
"Elastomer, schwarz",
|
||||
"Eslastomer_black--Elastomer_schwarz",
|
||||
"TPU, schwarz",
|
||||
"NBR, schwarz",
|
||||
"30103",
|
||||
],
|
||||
},
|
||||
{
|
||||
"material_name": "SCHAEFFLER_030201_Plastic-Brown",
|
||||
"aliases": [
|
||||
"Kunststoff, braun",
|
||||
"Plastic_brown--Kunststoff_braun",
|
||||
"30201",
|
||||
],
|
||||
},
|
||||
{
|
||||
"material_name": "SCHAEFFLER_030202_Plastic-Green",
|
||||
"aliases": [
|
||||
"Kunststoff, grün",
|
||||
"Plastic_green--Kunststoff_gruen",
|
||||
"30202",
|
||||
],
|
||||
},
|
||||
{
|
||||
"material_name": "SCHAEFFLER_030203_Plastic-Black",
|
||||
"aliases": [
|
||||
"Kunststoff, schwarz",
|
||||
"Plastic_black--Kunststoff_schwarz",
|
||||
"30203",
|
||||
],
|
||||
},
|
||||
{
|
||||
"material_name": "SCHAEFFLER_030204_Plastic-Blue",
|
||||
"aliases": [
|
||||
"Kunststoff, blau",
|
||||
"Plastic_blue--Kunststoff_blau",
|
||||
"30204",
|
||||
],
|
||||
},
|
||||
{
|
||||
"material_name": "SCHAEFFLER_030205_Plastic-White",
|
||||
"aliases": [
|
||||
"Kunststoff, weiß",
|
||||
"Plastic_white--Kunststoff_weiss",
|
||||
"30205",
|
||||
],
|
||||
},
|
||||
{
|
||||
"material_name": "SCHAEFFLER_030301_Plastic-Clear",
|
||||
"aliases": [
|
||||
"Kunststoff, durchsichtig",
|
||||
"Plastic_clear--Kunststoff_durchsichtig",
|
||||
"30301",
|
||||
],
|
||||
},
|
||||
{
|
||||
"material_name": "SCHAEFFLER_030302_Plastic-Translucent-White",
|
||||
"aliases": [
|
||||
"Plastic_translucent_white--Kunststoff_transluzent_weiss",
|
||||
"30302",
|
||||
],
|
||||
},
|
||||
{
|
||||
"material_name": "SCHAEFFLER_030401_TPU-Blue",
|
||||
"aliases": [
|
||||
"TPU, blau",
|
||||
"Elastomer_blue--Elastomer_blau",
|
||||
"30401",
|
||||
],
|
||||
},
|
||||
{
|
||||
"material_name": "SCHAEFFLER_030501_Ceramic-Black",
|
||||
"aliases": [
|
||||
"Keramik, schwarz",
|
||||
"Ceramics_black--Keramik_schwarz",
|
||||
"30501",
|
||||
],
|
||||
},
|
||||
# --- 04 Compounds ---
|
||||
{
|
||||
"material_name": "SCHAEFFLER_040101_E40",
|
||||
"aliases": [
|
||||
"E40",
|
||||
"E40--E40",
|
||||
"40101",
|
||||
],
|
||||
},
|
||||
{
|
||||
"material_name": "SCHAEFFLER_040102_E50",
|
||||
"aliases": [
|
||||
"E50",
|
||||
"E50--E50",
|
||||
"40102",
|
||||
],
|
||||
},
|
||||
{
|
||||
"material_name": "SCHAEFFLER_040201_Elgoglide",
|
||||
"aliases": [
|
||||
"Elgoglide",
|
||||
"Elgoglide--Elgoglide",
|
||||
"40201",
|
||||
],
|
||||
},
|
||||
{
|
||||
"material_name": "SCHAEFFLER_040202_Elgotex",
|
||||
"aliases": [
|
||||
"Elgotex, schwarz",
|
||||
"ELGOTEX, schwarz",
|
||||
"Elgotex--Elgotex",
|
||||
"40202",
|
||||
],
|
||||
},
|
||||
{
|
||||
"material_name": "SCHAEFFLER_040301_PTFE-Niro-Compound",
|
||||
"aliases": [
|
||||
"PTFE-Compound, Niro-Verbund",
|
||||
"PTFE_compound_stainless_steel_composite--PTFE_Compound_Niro_Verbund",
|
||||
"40301",
|
||||
],
|
||||
},
|
||||
{
|
||||
"material_name": "SCHAEFFLER_040302_PTFE-Foil",
|
||||
"aliases": [
|
||||
"PTFE-Folie",
|
||||
"PTFE_film--PTFE_Folie",
|
||||
"40302",
|
||||
],
|
||||
},
|
||||
{
|
||||
"material_name": "SCHAEFFLER_040303_PTFE-Compound-Black",
|
||||
"aliases": [
|
||||
"PTFE-Verbund, schwarz",
|
||||
"PTFE_compound_black--PTFE_Verbund_schwarz",
|
||||
"40303",
|
||||
],
|
||||
},
|
||||
{
|
||||
"material_name": "SCHAEFFLER_040304_PTFE-Compound-Orange",
|
||||
"aliases": [
|
||||
"PTFE-Verbundwerkstoff",
|
||||
"PTFE_composite_material_orange--PTFE_Verbundwerkstoff_orange",
|
||||
"40304",
|
||||
],
|
||||
},
|
||||
{
|
||||
"material_name": "SCHAEFFLER_040305_GFK-PTFE-Compound",
|
||||
"aliases": [
|
||||
"GFK+PTFE Verbundwerkstoff, schwarz",
|
||||
"GFK_PTFE_compound--GFK_PTFE_Verbundwerkstoff",
|
||||
"40305",
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Schaeffler standard materials — single source of truth.
|
||||
|
||||
Naming convention: SCHAEFFLER_[TypeCode(2)][SubType(2)][Consecutive(2)]_[Name-Parts-Dashed]
|
||||
Type codes: 01=Metals, 02=Coatings, 03=Non-metals, 04=Compounds, 05=Misc
|
||||
"""
|
||||
|
||||
SCHAEFFLER_MATERIALS: list[dict] = [
|
||||
# --- 01 Metals ---
|
||||
{"name": "SCHAEFFLER_010101_Steel-Bare", "description": "Stahl / Stahl, glänzend / Stahl, konserviert", "schaeffler_code": 10101, "source": "schaeffler_standard"},
|
||||
{"name": "SCHAEFFLER_010102_Steel-Burnished", "description": "Stahl, brüniert", "schaeffler_code": 10102, "source": "schaeffler_standard"},
|
||||
{"name": "SCHAEFFLER_010103_Steel-Galvanized", "description": "Stahl, verzinkt", "schaeffler_code": 10103, "source": "schaeffler_standard"},
|
||||
{"name": "SCHAEFFLER_010104_Steel-Casted", "description": "Stahl Körnung", "schaeffler_code": 10104, "source": "schaeffler_standard"},
|
||||
{"name": "SCHAEFFLER_010105_Steel-Plate", "description": "Stahlblech", "schaeffler_code": 10105, "source": "schaeffler_standard"},
|
||||
{"name": "SCHAEFFLER_010201_Niro", "description": "Niro", "schaeffler_code": 10201, "source": "schaeffler_standard"},
|
||||
{"name": "SCHAEFFLER_010301_Tin", "description": "MU-Stahl, Zinnüberzug / MX-Stahl, Zinnüberzug", "schaeffler_code": 10301, "source": "schaeffler_standard"},
|
||||
{"name": "SCHAEFFLER_010401_Aluminium", "description": "Aluminium", "schaeffler_code": 10401, "source": "schaeffler_standard"},
|
||||
{"name": "SCHAEFFLER_010501_Brass", "description": "Messing", "schaeffler_code": 10501, "source": "schaeffler_standard"},
|
||||
{"name": "SCHAEFFLER_010601_Bronze", "description": "MU-B, Bronze", "schaeffler_code": 10601, "source": "schaeffler_standard"},
|
||||
# --- 02 Coatings ---
|
||||
{"name": "SCHAEFFLER_020101_Durotect-Blue", "description": "Stahl, Durotect CMT", "schaeffler_code": 20101, "source": "schaeffler_standard"},
|
||||
{"name": "SCHAEFFLER_020102_Durotect-Black", "description": "Stahl, Durotect M", "schaeffler_code": 20102, "source": "schaeffler_standard"},
|
||||
{"name": "SCHAEFFLER_020201_Coat-Black", "description": "", "schaeffler_code": 20201, "source": "schaeffler_standard"},
|
||||
# --- 03 Non-metals ---
|
||||
{"name": "SCHAEFFLER_030101_Elastomer-Brown", "description": "Elastomer, braun", "schaeffler_code": 30101, "source": "schaeffler_standard"},
|
||||
{"name": "SCHAEFFLER_030102_Elastomer-Green", "description": "Elastomer, grün", "schaeffler_code": 30102, "source": "schaeffler_standard"},
|
||||
{"name": "SCHAEFFLER_030103_Elastomer-Black", "description": "Elastomer, schwarz", "schaeffler_code": 30103, "source": "schaeffler_standard"},
|
||||
{"name": "SCHAEFFLER_030201_Plastic-Brown", "description": "Kunststoff, braun", "schaeffler_code": 30201, "source": "schaeffler_standard"},
|
||||
{"name": "SCHAEFFLER_030202_Plastic-Green", "description": "Kunststoff, grün", "schaeffler_code": 30202, "source": "schaeffler_standard"},
|
||||
{"name": "SCHAEFFLER_030203_Plastic-Black", "description": "Kunststoff, schwarz", "schaeffler_code": 30203, "source": "schaeffler_standard"},
|
||||
{"name": "SCHAEFFLER_030204_Plastic-Blue", "description": "Kunststoff, blau", "schaeffler_code": 30204, "source": "schaeffler_standard"},
|
||||
{"name": "SCHAEFFLER_030205_Plastic-White", "description": "Kunststoff, weiß", "schaeffler_code": 30205, "source": "schaeffler_standard"},
|
||||
{"name": "SCHAEFFLER_030301_Plastic-Clear", "description": "Kunststoff, durchsichtig", "schaeffler_code": 30301, "source": "schaeffler_standard"},
|
||||
{"name": "SCHAEFFLER_030302_Plastic-Translucent-White", "description": "", "schaeffler_code": 30302, "source": "schaeffler_standard"},
|
||||
{"name": "SCHAEFFLER_030401_TPU-Blue", "description": "TPU, blau", "schaeffler_code": 30401, "source": "schaeffler_standard"},
|
||||
{"name": "SCHAEFFLER_030501_Ceramic-Black", "description": "Keramik, schwarz", "schaeffler_code": 30501, "source": "schaeffler_standard"},
|
||||
# --- 04 Compounds ---
|
||||
{"name": "SCHAEFFLER_040101_E40", "description": "E40", "schaeffler_code": 40101, "source": "schaeffler_standard"},
|
||||
{"name": "SCHAEFFLER_040102_E50", "description": "E50", "schaeffler_code": 40102, "source": "schaeffler_standard"},
|
||||
{"name": "SCHAEFFLER_040201_Elgoglide", "description": "Elgoglide", "schaeffler_code": 40201, "source": "schaeffler_standard"},
|
||||
{"name": "SCHAEFFLER_040202_Elgotex", "description": "Elgotex, schwarz", "schaeffler_code": 40202, "source": "schaeffler_standard"},
|
||||
{"name": "SCHAEFFLER_040301_PTFE-Niro-Compound", "description": "PTFE-Compound, Niro-Verbund", "schaeffler_code": 40301, "source": "schaeffler_standard"},
|
||||
{"name": "SCHAEFFLER_040302_PTFE-Foil", "description": "PTFE-Folie", "schaeffler_code": 40302, "source": "schaeffler_standard"},
|
||||
{"name": "SCHAEFFLER_040303_PTFE-Compound-Black", "description": "PTFE-Verbund, schwarz", "schaeffler_code": 40303, "source": "schaeffler_standard"},
|
||||
{"name": "SCHAEFFLER_040304_PTFE-Compound-Orange", "description": "PTFE-Verbundwerkstoff", "schaeffler_code": 40304, "source": "schaeffler_standard"},
|
||||
{"name": "SCHAEFFLER_040305_GFK-PTFE-Compound", "description": "GFK+PTFE Verbundwerkstoff, schwarz / TPU, schwarz", "schaeffler_code": 40305, "source": "schaeffler_standard"},
|
||||
# --- 05 Misc ---
|
||||
{"name": "SCHAEFFLER_059999_FailedMaterial", "description": "", "schaeffler_code": 59999, "source": "schaeffler_standard"},
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from app.config import settings
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=False,
|
||||
pool_pre_ping=True,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
)
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
@@ -0,0 +1,71 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
from app.database import engine, Base
|
||||
from app.api.routers import auth, uploads, orders, templates, admin, order_items, cad, materials, worker, analytics, pricing, products, output_types, render_templates, notifications
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Create upload directories
|
||||
for subdir in ("step_files", "excel_files", "thumbnails", "renders", "blend-templates"):
|
||||
Path(settings.upload_dir, subdir).mkdir(parents=True, exist_ok=True)
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Schaeffler Automat API",
|
||||
version="0.1.0",
|
||||
description="Media-creation pipeline for Schaeffler CAD/bearing product orders",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:5173", "http://localhost:3000", "http://frontend:5173", "http://localhost:8888"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Mount static files for thumbnails (dir created in lifespan; skip if not writable)
|
||||
thumbnails_dir = Path(settings.upload_dir) / "thumbnails"
|
||||
try:
|
||||
thumbnails_dir.mkdir(parents=True, exist_ok=True)
|
||||
app.mount("/thumbnails", StaticFiles(directory=str(thumbnails_dir)), name="thumbnails")
|
||||
except (PermissionError, OSError):
|
||||
pass # Running outside Docker without upload dir — thumbnails won't be served statically
|
||||
|
||||
# Mount static files for renders
|
||||
renders_dir = Path(settings.upload_dir) / "renders"
|
||||
try:
|
||||
renders_dir.mkdir(parents=True, exist_ok=True)
|
||||
app.mount("/renders", StaticFiles(directory=str(renders_dir)), name="renders")
|
||||
except (PermissionError, OSError):
|
||||
pass
|
||||
|
||||
# Include routers
|
||||
app.include_router(auth.router, prefix="/api")
|
||||
app.include_router(uploads.router, prefix="/api")
|
||||
app.include_router(orders.router, prefix="/api")
|
||||
app.include_router(templates.router, prefix="/api")
|
||||
app.include_router(admin.router, prefix="/api")
|
||||
app.include_router(order_items.router, prefix="/api")
|
||||
app.include_router(cad.router, prefix="/api")
|
||||
app.include_router(materials.router, prefix="/api")
|
||||
app.include_router(worker.router, prefix="/api")
|
||||
app.include_router(analytics.router, prefix="/api")
|
||||
app.include_router(pricing.router, prefix="/api")
|
||||
app.include_router(products.router, prefix="/api")
|
||||
app.include_router(output_types.router, prefix="/api")
|
||||
app.include_router(render_templates.router, prefix="/api")
|
||||
app.include_router(notifications.router, prefix="/api")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "service": "schaefflerautomat-backend"}
|
||||
@@ -0,0 +1,20 @@
|
||||
from app.models.user import User
|
||||
from app.models.template import Template
|
||||
from app.models.cad_file import CadFile
|
||||
from app.models.order import Order
|
||||
from app.models.order_item import OrderItem
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.pricing_tier import PricingTier
|
||||
from app.models.product import Product
|
||||
from app.models.output_type import OutputType
|
||||
from app.models.order_line import OrderLine
|
||||
from app.models.render_template import RenderTemplate
|
||||
from app.models.material import Material
|
||||
from app.models.material_alias import MaterialAlias
|
||||
from app.models.render_position import ProductRenderPosition
|
||||
|
||||
__all__ = [
|
||||
"User", "Template", "CadFile", "Order", "OrderItem", "AuditLog",
|
||||
"PricingTier", "Product", "OutputType", "OrderLine",
|
||||
"RenderTemplate", "Material", "MaterialAlias", "ProductRenderPosition",
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,28 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Boolean, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_log"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
action: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
entity_type: Mapped[str] = mapped_column(String(100), nullable=True)
|
||||
entity_id: Mapped[str] = mapped_column(String(255), nullable=True)
|
||||
details: Mapped[dict] = mapped_column(JSONB, nullable=True)
|
||||
timestamp: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
# Notification center columns
|
||||
target_user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True,
|
||||
)
|
||||
read_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
notification: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
user: Mapped["User"] = relationship("User", back_populates="audit_logs", foreign_keys=[user_id])
|
||||
target_user: Mapped["User"] = relationship("User", foreign_keys=[target_user_id])
|
||||
@@ -0,0 +1,37 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime, Enum as SAEnum, BigInteger
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from app.database import Base
|
||||
import enum
|
||||
|
||||
|
||||
class ProcessingStatus(str, enum.Enum):
|
||||
pending = "pending"
|
||||
processing = "processing"
|
||||
completed = "completed"
|
||||
failed = "failed"
|
||||
|
||||
|
||||
class CadFile(Base):
|
||||
__tablename__ = "cad_files"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
original_name: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
stored_path: Mapped[str] = mapped_column(String(1000), nullable=False)
|
||||
file_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
|
||||
file_size: Mapped[int] = mapped_column(BigInteger, nullable=True)
|
||||
parsed_objects: Mapped[dict] = mapped_column(JSONB, nullable=True)
|
||||
thumbnail_path: Mapped[str] = mapped_column(String(1000), nullable=True)
|
||||
gltf_path: Mapped[str] = mapped_column(String(1000), nullable=True)
|
||||
processing_status: Mapped[ProcessingStatus] = mapped_column(
|
||||
SAEnum(ProcessingStatus), default=ProcessingStatus.pending, nullable=False
|
||||
)
|
||||
error_message: Mapped[str] = mapped_column(String(2000), nullable=True)
|
||||
render_log: Mapped[dict] = mapped_column(JSONB, nullable=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)
|
||||
|
||||
order_items: Mapped[list["OrderItem"]] = relationship("OrderItem", back_populates="cad_file")
|
||||
products: Mapped[list["Product"]] = relationship("Product", back_populates="cad_file")
|
||||
@@ -0,0 +1,24 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime, Text, ForeignKey, Integer
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Material(Base):
|
||||
__tablename__ = "materials"
|
||||
|
||||
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, unique=True)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=True)
|
||||
source: Mapped[str] = mapped_column(String(20), nullable=False, default="manual")
|
||||
schaeffler_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=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)
|
||||
|
||||
creator: Mapped["User"] = relationship("User", foreign_keys=[created_by], lazy="select") # type: ignore[name-defined]
|
||||
aliases = relationship("MaterialAlias", back_populates="material", cascade="all, delete-orphan")
|
||||
@@ -0,0 +1,19 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class MaterialAlias(Base):
|
||||
__tablename__ = "material_aliases"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
material_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("materials.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
alias: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
material = relationship("Material", back_populates="aliases")
|
||||
@@ -0,0 +1,42 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from sqlalchemy import String, DateTime, Enum as SAEnum, ForeignKey, Text, Integer, Numeric
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from app.database import Base
|
||||
import enum
|
||||
|
||||
|
||||
class OrderStatus(str, enum.Enum):
|
||||
draft = "draft"
|
||||
submitted = "submitted"
|
||||
processing = "processing"
|
||||
completed = "completed"
|
||||
rejected = "rejected"
|
||||
|
||||
|
||||
class Order(Base):
|
||||
__tablename__ = "orders"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
order_number: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
|
||||
template_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("templates.id"), nullable=True)
|
||||
status: Mapped[OrderStatus] = mapped_column(SAEnum(OrderStatus), default=OrderStatus.draft, nullable=False)
|
||||
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
source_excel: Mapped[str] = mapped_column(String(1000), nullable=True)
|
||||
notes: Mapped[str] = mapped_column(Text, nullable=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)
|
||||
submitted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
processing_started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
rejected_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
estimated_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
|
||||
|
||||
template: Mapped["Template"] = relationship("Template", back_populates="orders")
|
||||
created_by_user: Mapped["User"] = relationship("User", back_populates="orders", foreign_keys=[created_by])
|
||||
items: Mapped[list["OrderItem"]] = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
|
||||
lines: Mapped[list["OrderLine"]] = relationship(
|
||||
"OrderLine", back_populates="order", cascade="all, delete-orphan"
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime, Enum as SAEnum, ForeignKey, Integer, Boolean, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from app.database import Base
|
||||
import enum
|
||||
|
||||
|
||||
class ItemStatus(str, enum.Enum):
|
||||
pending = "pending"
|
||||
approved = "approved"
|
||||
rejected = "rejected"
|
||||
|
||||
|
||||
class AIValidationStatus(str, enum.Enum):
|
||||
not_started = "not_started"
|
||||
pending = "pending"
|
||||
completed = "completed"
|
||||
failed = "failed"
|
||||
|
||||
|
||||
class OrderItem(Base):
|
||||
__tablename__ = "order_items"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
order_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("orders.id"), nullable=False)
|
||||
row_index: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
# 11 Standard fields (columns 0-10, skip col 5)
|
||||
ebene1: Mapped[str] = mapped_column(String(500), nullable=True)
|
||||
ebene2: Mapped[str] = mapped_column(String(500), nullable=True)
|
||||
baureihe: Mapped[str] = mapped_column(String(500), nullable=True)
|
||||
pim_id: Mapped[str] = mapped_column(String(500), nullable=True)
|
||||
produkt_baureihe: Mapped[str] = mapped_column(String(500), nullable=True)
|
||||
# col 5 is skipped (separator)
|
||||
gewaehltes_produkt: Mapped[str] = mapped_column(String(500), nullable=True)
|
||||
name_cad_modell: Mapped[str] = mapped_column(String(500), nullable=True)
|
||||
gewuenschte_bildnummer: Mapped[str] = mapped_column(String(500), nullable=True)
|
||||
lagertyp: Mapped[str] = mapped_column(String(500), nullable=True)
|
||||
medias_rendering: Mapped[bool] = mapped_column(Boolean, nullable=True)
|
||||
|
||||
# Component pairs (cols 11+): [{part_name, material, component_type, column_index}]
|
||||
components: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
||||
|
||||
# CAD linkage
|
||||
cad_file_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("cad_files.id"), nullable=True)
|
||||
thumbnail_path: Mapped[str] = mapped_column(String(1000), nullable=True)
|
||||
# Material assignments per CAD part: [{part_name, material}]
|
||||
cad_part_materials: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
||||
|
||||
# AI validation
|
||||
ai_validation_status: Mapped[AIValidationStatus] = mapped_column(
|
||||
SAEnum(AIValidationStatus), default=AIValidationStatus.not_started, nullable=False
|
||||
)
|
||||
ai_validation_result: Mapped[dict] = mapped_column(JSONB, nullable=True)
|
||||
|
||||
item_status: Mapped[ItemStatus] = mapped_column(SAEnum(ItemStatus), default=ItemStatus.pending, nullable=False)
|
||||
notes: Mapped[str] = mapped_column(Text, nullable=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)
|
||||
|
||||
order: Mapped["Order"] = relationship("Order", back_populates="items")
|
||||
cad_file: Mapped["CadFile"] = relationship("CadFile", back_populates="order_items")
|
||||
|
||||
@property
|
||||
def cad_parsed_objects(self) -> list[str] | None:
|
||||
"""Part names extracted from the linked STEP file, for Pydantic serialization."""
|
||||
if self.cad_file and self.cad_file.parsed_objects:
|
||||
return self.cad_file.parsed_objects.get("objects") or []
|
||||
return None
|
||||
@@ -0,0 +1,52 @@
|
||||
import uuid
|
||||
import enum
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from sqlalchemy import String, DateTime, Text, ForeignKey, Numeric
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class OrderLine(Base):
|
||||
__tablename__ = "order_lines"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
order_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("orders.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
product_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("products.id"), nullable=False, index=True
|
||||
)
|
||||
output_type_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("output_types.id"), nullable=True
|
||||
)
|
||||
gewuenschte_bildnummer: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
item_status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
|
||||
render_status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
|
||||
result_path: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
render_log: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
ai_validation_status: Mapped[str] = mapped_column(String(20), nullable=False, default="not_started")
|
||||
ai_validation_result: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
flamenco_job_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
render_backend_used: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
render_started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
render_completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
unit_price: Mapped[Decimal | None] = mapped_column(Numeric(10, 2), nullable=True)
|
||||
render_position_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("product_render_positions.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=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
|
||||
)
|
||||
|
||||
order: Mapped["Order"] = relationship("Order", back_populates="lines")
|
||||
product: Mapped["Product"] = relationship("Product", back_populates="order_lines")
|
||||
output_type: Mapped["OutputType | None"] = relationship("OutputType", back_populates="order_lines")
|
||||
render_position: Mapped["ProductRenderPosition | None"] = relationship(
|
||||
"ProductRenderPosition", back_populates="order_lines"
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime, Boolean, Text, Integer, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
VALID_RENDER_BACKENDS = {"celery", "flamenco", "auto"}
|
||||
from app.database import Base
|
||||
|
||||
|
||||
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="threejs")
|
||||
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="auto", 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")
|
||||
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)
|
||||
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="output_type")
|
||||
pricing_tier: Mapped["PricingTier | None"] = relationship("PricingTier", back_populates="output_types")
|
||||
@@ -0,0 +1,25 @@
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from sqlalchemy import String, Boolean, DateTime, Text, Numeric, Integer, UniqueConstraint, Index
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class PricingTier(Base):
|
||||
__tablename__ = "pricing_tiers"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
category_key: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
quality_level: Mapped[str] = mapped_column(String(50), nullable=False, default="Normal")
|
||||
price_per_item: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
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)
|
||||
|
||||
output_types: Mapped[list["OutputType"]] = relationship("OutputType", back_populates="pricing_tier")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("category_key", "quality_level", name="uq_pricing_tier"),
|
||||
Index("ix_pricing_tiers_category_key", "category_key"),
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime, Boolean, Text, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Product(Base):
|
||||
__tablename__ = "products"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
pim_id: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
name: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
category_key: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
|
||||
ebene1: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
ebene2: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
baureihe: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
produkt_baureihe: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
lagertyp: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
name_cad_modell: Mapped[str | None] = mapped_column(String(500), nullable=True, index=True)
|
||||
gewuenschte_bildnummer: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
medias_rendering: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
components: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
||||
cad_part_materials: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
||||
cad_file_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("cad_files.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
arbeitspaket: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
source_excel: Mapped[str | None] = mapped_column(String(1000), nullable=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
|
||||
)
|
||||
|
||||
cad_file: Mapped["CadFile | None"] = relationship("CadFile", back_populates="products")
|
||||
order_lines: Mapped[list["OrderLine"]] = relationship(
|
||||
"OrderLine", back_populates="product", cascade="all, delete-orphan"
|
||||
)
|
||||
render_positions: Mapped[list["ProductRenderPosition"]] = relationship(
|
||||
"ProductRenderPosition", back_populates="product",
|
||||
cascade="all, delete-orphan", order_by="ProductRenderPosition.sort_order"
|
||||
)
|
||||
|
||||
@property
|
||||
def thumbnail_url(self) -> str | None:
|
||||
if self.cad_file and self.cad_file.thumbnail_path:
|
||||
from pathlib import Path
|
||||
return f"/thumbnails/{Path(self.cad_file.thumbnail_path).name}"
|
||||
return None
|
||||
|
||||
@property
|
||||
def processing_status(self) -> str | None:
|
||||
if self.cad_file:
|
||||
return self.cad_file.processing_status.value if hasattr(
|
||||
self.cad_file.processing_status, 'value'
|
||||
) else str(self.cad_file.processing_status)
|
||||
return None
|
||||
|
||||
@property
|
||||
def cad_parsed_objects(self) -> list[str] | None:
|
||||
if self.cad_file and self.cad_file.parsed_objects:
|
||||
return self.cad_file.parsed_objects.get("objects") or []
|
||||
return None
|
||||
@@ -0,0 +1,28 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime, Boolean, Integer, Float, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from app.database import Base
|
||||
|
||||
|
||||
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)
|
||||
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")
|
||||
@@ -0,0 +1,30 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime, Boolean, Text, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
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")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="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)
|
||||
|
||||
output_type = relationship("OutputType", lazy="joined")
|
||||
@@ -0,0 +1,11 @@
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, String, Text, DateTime
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class SystemSetting(Base):
|
||||
__tablename__ = "system_settings"
|
||||
|
||||
key = Column(String(100), primary_key=True)
|
||||
value = Column(Text, nullable=True)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user