feat: initial commit

This commit is contained in:
2026-03-05 22:12:38 +01:00
commit bce762a783
380 changed files with 51955 additions and 0 deletions
View File
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.
View File
Binary file not shown.
Binary file not shown.
View File
+486
View File
@@ -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 14096")
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 11024")
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 0180 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 116")
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 116")
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 1010080 (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 116")
# 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.",
}
+200
View File
@@ -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],
)
+47
View File
@@ -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
+360
View File
@@ -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,
}
+326
View File
@@ -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)
+157
View File
@@ -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}
+365
View File
@@ -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
+126
View File
@@ -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()
+152
View File
@@ -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)
+931
View File
@@ -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()
+360
View File
@@ -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()
+78
View File
@@ -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
+411
View File
@@ -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",
)
+356
View File
@@ -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}
+50
View File
@@ -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()
View File
+298
View File
@@ -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",
],
},
]
+48
View File
@@ -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"},
]
+29
View File
@@ -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()
+71
View File
@@ -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"}
+20
View File
@@ -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.
+28
View File
@@ -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])
+37
View File
@@ -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")
+24
View 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")
+19
View File
@@ -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")
+42
View File
@@ -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"
)
+71
View File
@@ -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
+52
View File
@@ -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"
)
+36
View File
@@ -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")
+25
View File
@@ -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"),
)
+66
View File
@@ -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
+28
View File
@@ -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")
+30
View File
@@ -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")
+11
View File
@@ -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