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"} VALID_ENGINES = {"cycles", "eevee"} VALID_FORMATS = {"jpg", "png"} VALID_STL_QUALITIES = {"low", "high"} VALID_CYCLES_DEVICES = {"auto", "gpu", "cpu"} SETTINGS_DEFAULTS: dict[str, str] = { "thumbnail_renderer": "blender", "blender_engine": "cycles", "blender_cycles_samples": "256", "blender_eevee_samples": "64", "thumbnail_format": "jpg", "stl_quality": "low", "blender_smooth_angle": "30", "cycles_device": "auto", "render_backend": "celery", "blender_max_concurrent_renders": "3", "product_thumbnail_priority": '["latest_render","cad_thumbnail"]', "render_stall_timeout_minutes": "120", } class SettingsOut(BaseModel): thumbnail_renderer: str = "blender" blender_engine: str = "cycles" blender_cycles_samples: int = 256 blender_eevee_samples: int = 64 thumbnail_format: str = "jpg" stl_quality: str = "low" blender_smooth_angle: int = 30 cycles_device: str = "auto" render_backend: str = "celery" 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 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 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"]), 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"], 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.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.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.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.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 renderer services.""" from app.services.render_blender import find_blender, is_blender_available blender_available = is_blender_available() blender_bin = find_blender() return { "pillow": {"available": True, "note": "Built-in (always available)"}, "blender": { "available": blender_available, "note": ( f"render-worker subprocess ({blender_bin})" if blender_available else "Blender not found — check render-worker container and BLENDER_BIN" ), }, }