Files
POPS/app/routers/ui.py
Martin Hohenberg 4c7acb1c52 Analyse STL/3MF on upload: bounding box + volume, cached in stl_cache
- StlCache model: filename, cube_x/y/z (mm), volume_ccm
- Migration 0007: stl_cache table
- app/stl_analysis.py: trimesh-based analyse() + get_cache_map()
  - Runs immediately after upload in both /jobs and /brackets/{id}/jobs
  - Skips .gcode (no geometry), caches per filename, idempotent
- Library selector in both modals now shows dimensions and volume
- requirements: trimesh==4.5.3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 22:04:11 +02:00

287 lines
11 KiB
Python

import uuid as _uuid
from pathlib import Path
from fastapi import APIRouter, Depends, File, Form, Request, UploadFile
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import or_
from sqlalchemy.orm import Session
from starlette.status import HTTP_303_SEE_OTHER
from app.db import get_db
from app.models.filament import Filament
from app.models.job_bracket import JobBracket
from app.models.job_log import JobLog
from app.models.print_job import JobStatus, PrintJob
from app.models.printer import Printer, PrinterStatus
from app.models.printer_log import PrinterLog
from app.models.printer_type import PrinterType
from app.models.pricing import PricingConfig
from app.models.todo import Todo
from app.stl import get_stl_dir, list_stl_files
from app.stl_analysis import analyse, get_cache_map
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
# ── Dashboard ──────────────────────────────────────────────────────────────────
@router.get("/", response_class=HTMLResponse)
def dashboard(request: Request, db: Session = Depends(get_db)):
printers = db.query(Printer).order_by(Printer.name).all()
return templates.TemplateResponse("dashboard.html", {
"request": request,
"active": "dashboard",
"printers": printers,
"total_printers": len(printers),
"printing_count": sum(1 for p in printers if p.status == PrinterStatus.printing),
"active_jobs": db.query(PrintJob).filter(
PrintJob.status.in_([JobStatus.queued, JobStatus.printing])
).count(),
"total_filaments": db.query(Filament).count(),
"recent_jobs": db.query(PrintJob).order_by(PrintJob.created_at.desc()).limit(5).all(),
})
# ── Printers ───────────────────────────────────────────────────────────────────
@router.get("/printers", response_class=HTMLResponse)
def printers_page(request: Request, db: Session = Depends(get_db)):
return templates.TemplateResponse("printers.html", {
"request": request,
"active": "printers",
"printers": db.query(Printer).order_by(Printer.name).all(),
"printer_types": db.query(PrinterType).order_by(PrinterType.name).all(),
})
@router.post("/printers")
def create_printer(
name: str = Form(...),
location: str = Form(""),
type_id: str = Form(""),
bought_at: str = Form(""),
db: Session = Depends(get_db),
):
from datetime import date
printer = Printer(
name=name.strip(),
location=location.strip() or None,
type_id=int(type_id) if type_id else None,
bought_at=date.fromisoformat(bought_at) if bought_at else None,
)
db.add(printer)
db.commit()
return RedirectResponse(url="/printers", status_code=HTTP_303_SEE_OTHER)
@router.get("/printers/{printer_id}", response_class=HTMLResponse)
def printer_detail(printer_id: int, request: Request, db: Session = Depends(get_db)):
from fastapi import HTTPException
printer = db.query(Printer).filter(Printer.id == printer_id).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
return templates.TemplateResponse("printer_detail.html", {
"request": request,
"active": "printers",
"printer": printer,
})
@router.post("/printers/{printer_id}/log")
def add_printer_log(printer_id: int, message: str = Form(...), db: Session = Depends(get_db)):
db.add(PrinterLog(printer_id=printer_id, message=message.strip()))
db.commit()
return RedirectResponse(url=f"/printers/{printer_id}", status_code=HTTP_303_SEE_OTHER)
# ── Printer types ──────────────────────────────────────────────────────────────
@router.get("/printer-types", response_class=HTMLResponse)
def printer_types_page(request: Request, db: Session = Depends(get_db)):
return templates.TemplateResponse("printer_types.html", {
"request": request,
"active": "printers",
"printer_types": db.query(PrinterType).order_by(PrinterType.name).all(),
})
@router.post("/printer-types")
def create_printer_type(
name: str = Form(...),
bed_x_mm: int = Form(...),
bed_y_mm: int = Form(...),
bed_z_mm: int = Form(...),
db: Session = Depends(get_db),
):
db.add(PrinterType(name=name.strip(), bed_x_mm=bed_x_mm, bed_y_mm=bed_y_mm, bed_z_mm=bed_z_mm))
db.commit()
return RedirectResponse(url="/printer-types", status_code=HTTP_303_SEE_OTHER)
# ── Jobs ───────────────────────────────────────────────────────────────────────
@router.get("/jobs", response_class=HTMLResponse)
def jobs_page(request: Request, q: str = "", db: Session = Depends(get_db)):
query = db.query(PrintJob)
if q:
like = f"%{q}%"
query = query.filter(
or_(PrintJob.customer.ilike(like), PrintJob.file_name.ilike(like))
)
return templates.TemplateResponse("jobs.html", {
"request": request,
"active": "jobs",
"jobs": query.order_by(PrintJob.created_at.desc()).all(),
"q": q,
"printers": db.query(Printer).order_by(Printer.name).all(),
"filaments": db.query(Filament).order_by(Filament.material, Filament.color).all(),
"brackets": db.query(JobBracket).order_by(JobBracket.name).all(),
"stl_files": (stl_files := list_stl_files()),
"stl_cache": get_cache_map(stl_files, db),
})
@router.post("/jobs")
async def create_job(
printer_id: str = Form(""),
customer: str = Form(""),
filament_id: str = Form(""),
bracket_id: str = Form(""),
notes: str = Form(""),
stl_file: UploadFile = File(None),
stl_select: str = Form(""),
db: Session = Depends(get_db),
):
filename = None
if stl_file and stl_file.filename:
filename = stl_file.filename
(get_stl_dir() / filename).write_bytes(await stl_file.read())
analyse(filename, db)
elif stl_select:
filename = stl_select
job = PrintJob(
job_uuid=str(_uuid.uuid4()),
name=Path(filename).stem if filename else (customer.strip() or "Unnamed"),
file_name=filename,
printer_id=int(printer_id) if printer_id else None,
filament_id=int(filament_id) if filament_id else None,
bracket_id=int(bracket_id) if bracket_id else None,
customer=customer.strip() or None,
notes=notes.strip() or None,
)
db.add(job)
db.flush()
db.add(JobLog(job_id=job.id, message="Job created"))
db.commit()
return RedirectResponse(url="/jobs", status_code=HTTP_303_SEE_OTHER)
# ── Brackets ───────────────────────────────────────────────────────────────────
@router.get("/brackets", response_class=HTMLResponse)
def brackets_page(request: Request, db: Session = Depends(get_db)):
return templates.TemplateResponse("brackets.html", {
"request": request,
"active": "brackets",
"brackets": db.query(JobBracket).order_by(JobBracket.created_at.desc()).all(),
})
@router.post("/brackets")
def create_bracket(
name: str = Form(...),
customer: str = Form(""),
notes: str = Form(""),
db: Session = Depends(get_db),
):
db.add(JobBracket(name=name.strip(), customer=customer.strip() or None, notes=notes.strip() or None))
db.commit()
return RedirectResponse(url="/brackets", status_code=HTTP_303_SEE_OTHER)
@router.get("/brackets/{bracket_id}", response_class=HTMLResponse)
def bracket_detail(bracket_id: int, request: Request, db: Session = Depends(get_db)):
from fastapi import HTTPException
bracket = db.query(JobBracket).filter(JobBracket.id == bracket_id).first()
if not bracket:
raise HTTPException(status_code=404, detail="Bracket not found")
return templates.TemplateResponse("bracket_detail.html", {
"request": request,
"active": "brackets",
"bracket": bracket,
"filaments": db.query(Filament).order_by(Filament.material, Filament.color).all(),
"stl_files": (stl_files := list_stl_files()),
"stl_cache": get_cache_map(stl_files, db),
})
@router.post("/brackets/{bracket_id}/jobs")
async def add_jobs_to_bracket(
bracket_id: int,
quantity: int = Form(1),
filament_id: str = Form(""),
notes: str = Form(""),
stl_file: UploadFile = File(None),
stl_select: str = Form(""),
db: Session = Depends(get_db),
):
filename = None
if stl_file and stl_file.filename:
filename = stl_file.filename
(get_stl_dir() / filename).write_bytes(await stl_file.read())
analyse(filename, db)
elif stl_select:
filename = stl_select
for _ in range(max(1, min(quantity, 500))):
job = PrintJob(
job_uuid=str(_uuid.uuid4()),
name=Path(filename).stem if filename else "Unnamed",
file_name=filename,
bracket_id=bracket_id,
filament_id=int(filament_id) if filament_id else None,
notes=notes.strip() or None,
)
db.add(job)
db.flush()
db.add(JobLog(job_id=job.id, message="Job created"))
db.commit()
return RedirectResponse(url=f"/brackets/{bracket_id}", status_code=HTTP_303_SEE_OTHER)
# ── Filaments ──────────────────────────────────────────────────────────────────
@router.get("/filaments", response_class=HTMLResponse)
def filaments_page(request: Request, db: Session = Depends(get_db)):
return templates.TemplateResponse("filaments.html", {
"request": request,
"active": "filaments",
"filaments": db.query(Filament).order_by(Filament.brand, Filament.material).all(),
})
# ── To-Do ──────────────────────────────────────────────────────────────────────
@router.get("/todos", response_class=HTMLResponse)
def todos_page(request: Request, db: Session = Depends(get_db)):
return templates.TemplateResponse("todos.html", {
"request": request,
"active": "todos",
"todos": db.query(Todo).order_by(Todo.done, Todo.created_at.desc()).all(),
})
# ── Pricing ────────────────────────────────────────────────────────────────────
@router.get("/pricing", response_class=HTMLResponse)
def pricing_page(request: Request, db: Session = Depends(get_db)):
return templates.TemplateResponse("pricing.html", {
"request": request,
"active": "pricing",
"configs": db.query(PricingConfig).order_by(PricingConfig.valid_from.desc()).all(),
})