ced64055f2
- Backend: VALID_RENDERERS = {"blender"} only; remove pillow from renderer-status response
- Frontend: remove renderer picker (pillow/blender buttons) — Blender is the only renderer
- Blender options always visible, grouped into Render Quality / Performance / Output sections
- Maintenance buttons in 2-column grid with descriptions
- Page reorder: Pricing Summary → Users → Blender Settings → Render Templates →
Asset Libraries → Output Types → Pricing Tiers → SMTP → Templates
- Pillow code kept internally as fallback (not exposed in UI)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
473 lines
19 KiB
Python
473 lines
19 KiB
Python
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 = {"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",
|
||
# SMTP (email notifications — disabled by default)
|
||
"smtp_enabled": "false",
|
||
"smtp_host": "",
|
||
"smtp_port": "587",
|
||
"smtp_user": "",
|
||
"smtp_password": "",
|
||
"smtp_from_address": "",
|
||
}
|
||
|
||
|
||
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
|
||
smtp_enabled: bool = False
|
||
smtp_host: str = ""
|
||
smtp_port: int = 587
|
||
smtp_user: str = ""
|
||
smtp_password: str = ""
|
||
smtp_from_address: str = ""
|
||
|
||
|
||
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
|
||
smtp_enabled: bool | None = None
|
||
smtp_host: str | None = None
|
||
smtp_port: int | None = None
|
||
smtp_user: str | None = None
|
||
smtp_password: str | None = None
|
||
smtp_from_address: str | 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")),
|
||
smtp_enabled=raw.get("smtp_enabled", "false").lower() == "true",
|
||
smtp_host=raw.get("smtp_host", ""),
|
||
smtp_port=int(raw.get("smtp_port", "587")),
|
||
smtp_user=raw.get("smtp_user", ""),
|
||
smtp_password=raw.get("smtp_password", ""),
|
||
smtp_from_address=raw.get("smtp_from_address", ""),
|
||
)
|
||
|
||
|
||
@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
|
||
if body.smtp_enabled is not None:
|
||
updates["smtp_enabled"] = "true" if body.smtp_enabled else "false"
|
||
if body.smtp_host is not None:
|
||
updates["smtp_host"] = body.smtp_host
|
||
if body.smtp_port is not None:
|
||
if not (1 <= body.smtp_port <= 65535):
|
||
raise HTTPException(400, detail="smtp_port must be 1–65535")
|
||
updates["smtp_port"] = str(body.smtp_port)
|
||
if body.smtp_user is not None:
|
||
updates["smtp_user"] = body.smtp_user
|
||
if body.smtp_password is not None:
|
||
updates["smtp_password"] = body.smtp_password
|
||
if body.smtp_from_address is not None:
|
||
updates["smtp_from_address"] = body.smtp_from_address
|
||
|
||
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.post("/settings/seed-workflows", status_code=status.HTTP_200_OK)
|
||
async def seed_workflows(
|
||
admin: User = Depends(require_admin),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Create the standard workflow definitions if they do not already exist."""
|
||
from app.domains.rendering.models import WorkflowDefinition
|
||
|
||
STANDARD_WORKFLOWS = [
|
||
{
|
||
"name": "Still Image — Cycles",
|
||
"config": {
|
||
"type": "still",
|
||
"params": {"render_engine": "cycles", "samples": 256, "resolution": [1920, 1080]},
|
||
},
|
||
},
|
||
{
|
||
"name": "Still Image — EEVEE",
|
||
"config": {
|
||
"type": "still",
|
||
"params": {"render_engine": "eevee", "samples": 64, "resolution": [1920, 1080]},
|
||
},
|
||
},
|
||
{
|
||
"name": "Turntable Animation",
|
||
"config": {
|
||
"type": "turntable",
|
||
"params": {"render_engine": "cycles", "samples": 64, "fps": 24, "duration_s": 5},
|
||
},
|
||
},
|
||
{
|
||
"name": "Multi-Angle (0° / 45° / 90°)",
|
||
"config": {
|
||
"type": "multi_angle",
|
||
"params": {"render_engine": "cycles", "samples": 128, "angles": [0, 45, 90]},
|
||
},
|
||
},
|
||
]
|
||
|
||
existing_result = await db.execute(select(WorkflowDefinition))
|
||
existing_names = {wf.name for wf in existing_result.scalars().all()}
|
||
|
||
created = 0
|
||
for wf_data in STANDARD_WORKFLOWS:
|
||
if wf_data["name"] not in existing_names:
|
||
db.add(WorkflowDefinition(
|
||
name=wf_data["name"],
|
||
config=wf_data["config"],
|
||
is_active=True,
|
||
))
|
||
created += 1
|
||
|
||
await db.commit()
|
||
return {"created": created, "message": f"Created {created} workflow definition(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 {
|
||
"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"
|
||
),
|
||
},
|
||
}
|
||
|
||
|