Files
POPS/app/routers/ui.py
Martin Hohenberg 6a1f146d8e Background STL analysis with computing spinner; manual geometry override
- analyse_in_background() runs in FastAPI BackgroundTasks with its own
  DB session; _in_progress set prevents duplicate concurrent analyses
- Job detail shows a spinner + auto-reloads every 3s while computing
- 'Compute now' button also triggers background analysis
- StlCache.manually_set flag (migration 0011): entries marked manual are
  never overwritten by auto-analysis
- 'Override values' collapsible form on job detail lets user set
  bounding box and volume directly from slicer data

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 11:07:44 +02:00

583 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import math
import os
import uuid as _uuid
from datetime import datetime, timedelta
from pathlib import Path
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, Request, UploadFile
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import case, or_
from sqlalchemy.orm import Session
from starlette.status import HTTP_303_SEE_OTHER
from app.db import get_db
from app.models.job_bracket import JobBracket
from app.models.stl_cache import StlCache
from app.models.job_log import JobLog
from app.models.print_job import JobStatus, PrintJob # noqa: F401
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.vikunja import create_task as vikunja_create, delete_task as vikunja_delete, get_tasks as vikunja_tasks, update_task as vikunja_update
from app.constants import COLORS, MATERIALS
from app.estimation import estimate # also used in start_job
from app.filamentdb import (available_colors, available_materials, find_matching_ams_roll,
get_stock, log_print_on_roll, spool_counts)
from app.stl import get_stl_dir, list_stl_files
from app.stl_analysis import analyse_in_background, get_cache_map, is_computing # noqa: F401
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": sum(r.get("rolls_in_stock", 0) for r in get_stock()),
"filamentdb_url": os.environ.get("FILAMENTDB_URL", ""),
"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 = "", bracket_id: str = "", show_all: 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))
)
active_bracket = None
if bracket_id:
query = query.filter(PrintJob.bracket_id == int(bracket_id))
active_bracket = db.query(JobBracket).filter(JobBracket.id == int(bracket_id)).first()
# Hide finished jobs older than 24h unless show_all
finished = [JobStatus.done, JobStatus.failed, JobStatus.cancelled]
cutoff = datetime.now() - timedelta(hours=24)
hidden_count = 0
if not show_all:
hidden_count = query.filter(
PrintJob.status.in_(finished), PrintJob.created_at < cutoff
).count()
query = query.filter(
or_(~PrintJob.status.in_(finished), PrintJob.created_at >= cutoff)
)
# Sort: printing → queued → rest, then priority, then oldest first
status_order = case(
(PrintJob.status == JobStatus.printing, 0),
(PrintJob.status == JobStatus.queued, 1),
else_=2,
)
jobs = query.order_by(status_order, PrintJob.priority.desc(), PrintJob.created_at.asc()).all()
stl_files = list_stl_files()
stl_cache = get_cache_map(stl_files, db)
bracket_gross_total = None
if active_bracket:
total = 0.0
has_any = False
for j in active_bracket.jobs:
cached = stl_cache.get(j.file_name)
if cached and cached.volume_ccm:
total += estimate(cached.volume_ccm, j.job_material)["gross_price"]
has_any = True
if has_any:
bracket_gross_total = round(total, 2)
return templates.TemplateResponse("jobs.html", {
"request": request,
"active": "jobs",
"jobs": jobs,
"q": q,
"bracket_id": bracket_id,
"show_all": bool(show_all),
"hidden_count": hidden_count,
"active_bracket": active_bracket,
"bracket_gross_total": bracket_gross_total,
"printers": db.query(Printer).order_by(Printer.name).all(),
"brackets": db.query(JobBracket).order_by(JobBracket.name).all(),
"stl_files": stl_files,
"stl_cache": stl_cache,
"materials": available_materials() or MATERIALS,
"colors": available_colors() or COLORS,
"spool_map": spool_counts(),
})
@router.post("/jobs")
async def create_job(
printer_id: str = Form(""),
customer: str = Form(""),
job_material: str = Form(""),
job_color: str = Form(""),
priority: str = Form(""),
bracket_id: str = Form(""),
amount: int = Form(1),
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
amount = max(1, amount)
customer_str = customer.strip() or None
job_name = Path(filename).stem if filename else (customer_str or "Unnamed")
# Resolve bracket — auto-create one when printing multiple copies
effective_bracket_id = int(bracket_id) if bracket_id else None
if amount > 1 and not effective_bracket_id:
bracket_name = f"{job_name} ×{amount}"
if customer_str:
bracket_name = f"{customer_str} {bracket_name}"
new_bracket = JobBracket(name=bracket_name, customer=customer_str)
db.add(new_bracket)
db.flush()
effective_bracket_id = new_bracket.id
for _ in range(amount):
job = PrintJob(
job_uuid=str(_uuid.uuid4()),
name=job_name,
file_name=filename,
printer_id=int(printer_id) if printer_id else None,
bracket_id=effective_bracket_id,
customer=customer_str,
job_material=job_material or None,
job_color=job_color or None,
priority=bool(priority),
notes=notes.strip() or None,
)
db.add(job)
db.flush()
db.add(JobLog(job_id=job.id, message="Job created"))
db.commit()
if effective_bracket_id:
return RedirectResponse(url=f"/jobs?bracket_id={effective_bracket_id}", status_code=HTTP_303_SEE_OTHER)
return RedirectResponse(url="/jobs", status_code=HTTP_303_SEE_OTHER)
@router.get("/jobs/{job_id}", response_class=HTMLResponse)
def job_detail(job_id: int, request: Request, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
from fastapi import HTTPException
job = db.query(PrintJob).filter(PrintJob.id == job_id).first()
if not job:
raise HTTPException(status_code=404, detail="Job not found")
file_missing = False
computing = False
stl = None
if job.file_name:
file_path = get_stl_dir() / job.file_name
if not file_path.exists():
file_missing = True
else:
stl = db.query(StlCache).filter_by(filename=job.file_name).first()
if stl is None and not job.file_name.endswith(".gcode"):
if not is_computing(job.file_name):
background_tasks.add_task(analyse_in_background, job.file_name)
computing = True
estimation = estimate(stl.volume_ccm, job.job_material) if stl and stl.volume_ccm else None
return templates.TemplateResponse("job_detail.html", {
"request": request,
"active": "jobs",
"job": job,
"stl": stl,
"file_missing": file_missing,
"computing": computing,
"estimation": estimation,
"printers": db.query(Printer).order_by(Printer.name).all(),
"brackets": db.query(JobBracket).order_by(JobBracket.name).all(),
"materials": available_materials() or MATERIALS,
"colors": available_colors() or COLORS,
"all_statuses": [s.value for s in JobStatus],
"available_printers": db.query(Printer).filter(
Printer.status != PrinterStatus.printing
).order_by(Printer.name).all(),
})
@router.post("/jobs/{job_id}/edit")
def edit_job(
job_id: int,
customer: str = Form(""),
job_material: str = Form(""),
job_color: str = Form(""),
priority: str = Form(""),
bracket_id: str = Form(""),
printer_id: str = Form(""),
status: str = Form(""),
notes: str = Form(""),
db: Session = Depends(get_db),
):
from fastapi import HTTPException
job = db.query(PrintJob).filter(PrintJob.id == job_id).first()
if not job:
raise HTTPException(status_code=404, detail="Job not found")
job.customer = customer.strip() or None
job.job_material = job_material or None
job.job_color = job_color or None
job.priority = bool(priority)
job.bracket_id = int(bracket_id) if bracket_id else None
job.printer_id = int(printer_id) if printer_id else None
job.notes = notes.strip() or None
if status:
job.status = JobStatus(status)
db.add(JobLog(job_id=job.id, message=f"Job updated"))
db.commit()
return RedirectResponse(url=f"/jobs/{job_id}", status_code=HTTP_303_SEE_OTHER)
@router.get("/files/{filename}")
def download_file(filename: str):
from fastapi import HTTPException
stl_dir = get_stl_dir()
path = (stl_dir / filename).resolve()
if not str(path).startswith(str(stl_dir.resolve())):
raise HTTPException(400, "Invalid filename")
if not path.exists():
raise HTTPException(404, "File not found")
return FileResponse(path, filename=filename, media_type="application/octet-stream")
@router.post("/jobs/{job_id}/start")
def start_job(job_id: int, printer_id: int = Form(...), db: Session = Depends(get_db)):
from fastapi import HTTPException
job = db.query(PrintJob).filter(PrintJob.id == job_id).first()
if not job:
raise HTTPException(404, "Job not found")
printer = db.query(Printer).filter(Printer.id == printer_id).first()
if not printer:
raise HTTPException(404, "Printer not found")
job.status = JobStatus.printing
job.printer_id = printer_id
job.started_at = datetime.now()
printer.status = PrinterStatus.printing
db.add(JobLog(job_id=job_id, message=f"Job started on {printer.name}"))
# Log filament usage against matching AMS roll in FilamentDB
filament_low = False
if job.job_material and job.job_color:
stl = db.query(StlCache).filter_by(filename=job.file_name).first() if job.file_name else None
if stl and stl.volume_ccm:
weight_g = math.ceil(estimate(stl.volume_ccm, job.job_material)["weight_g"])
roll = find_matching_ams_roll(job.job_material, job.job_color)
if roll:
note = (f"POPS {job.job_uuid[:8] if job.job_uuid else job.id}: "
f"{job.file_name or job.name} on {printer.name}")
if log_print_on_roll(roll["roll_id"], weight_g, note):
db.add(JobLog(job_id=job_id, message=(
f"FilamentDB: logged {weight_g} g against roll #{roll['roll_id']} "
f"(AMS slot {roll['slot']}{roll['material']} {roll['color']}, "
f"{roll['weight_remaining_g']} g remaining, least-full roll selected)"
)))
if roll["weight_remaining_g"] - weight_g < 100:
filament_low = True
else:
db.add(JobLog(job_id=job_id, message=f"FilamentDB: print log failed for roll #{roll['roll_id']}"))
else:
db.add(JobLog(job_id=job_id, message=(
f"FilamentDB: no {job.job_material} {job.job_color} roll found in AMS"
)))
db.commit()
if filament_low:
vikunja_create("Check filament status")
return RedirectResponse(url=f"/jobs/{job_id}", status_code=HTTP_303_SEE_OTHER)
@router.post("/jobs/{job_id}/cancel")
def cancel_job(job_id: int, db: Session = Depends(get_db)):
from fastapi import HTTPException
job = db.query(PrintJob).filter(PrintJob.id == job_id).first()
if not job:
raise HTTPException(404, "Job not found")
if job.printer and job.status == JobStatus.printing:
job.printer.status = PrinterStatus.idle
job.status = JobStatus.cancelled
db.add(JobLog(job_id=job_id, message="Job cancelled"))
db.commit()
return RedirectResponse(url=f"/jobs/{job_id}", status_code=HTTP_303_SEE_OTHER)
@router.post("/jobs/{job_id}/stop")
def stop_job(job_id: int, db: Session = Depends(get_db)):
from fastapi import HTTPException
job = db.query(PrintJob).filter(PrintJob.id == job_id).first()
if not job:
raise HTTPException(404, "Job not found")
printer_name = job.printer.name if job.printer else "unknown printer"
if job.printer:
job.printer.status = PrinterStatus.idle
job.status = JobStatus.queued
job.printer_id = None
job.started_at = None
db.add(JobLog(job_id=job_id, message=f"Job stopped (was on {printer_name})"))
db.commit()
return RedirectResponse(url=f"/jobs/{job_id}", status_code=HTTP_303_SEE_OTHER)
@router.post("/jobs/{job_id}/finish")
def finish_job(job_id: int, db: Session = Depends(get_db)):
from fastapi import HTTPException
job = db.query(PrintJob).filter(PrintJob.id == job_id).first()
if not job:
raise HTTPException(404, "Job not found")
now = datetime.now()
job.status = JobStatus.done
job.finished_at = now
if job.started_at:
job.duration_minutes = max(1, int((now - job.started_at).total_seconds() / 60))
printer_name = job.printer.name if job.printer else "unknown printer"
if job.printer:
job.printer.status = PrinterStatus.idle
db.add(JobLog(job_id=job_id, message=f"Job finished on {printer_name}"))
# Every 3rd completed print on this printer → cleaning reminder
cleaning_todo = False
if job.printer_id:
done_count = db.query(PrintJob).filter(
PrintJob.printer_id == job.printer_id,
PrintJob.status == JobStatus.done,
).count()
if (done_count + 1) % 3 == 0:
cleaning_todo = True
db.commit()
if cleaning_todo:
vikunja_create(f"Clean build plate on {printer_name}")
return RedirectResponse(url=f"/jobs/{job_id}", status_code=HTTP_303_SEE_OTHER)
@router.post("/jobs/{job_id}/analyse")
def analyse_job_file(job_id: int, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
from fastapi import HTTPException
job = db.query(PrintJob).filter(PrintJob.id == job_id).first()
if not job or not job.file_name:
raise HTTPException(status_code=404, detail="Job or file not found")
db.query(StlCache).filter_by(filename=job.file_name).delete()
db.commit()
background_tasks.add_task(analyse_in_background, job.file_name)
return RedirectResponse(url=f"/jobs/{job_id}", status_code=HTTP_303_SEE_OTHER)
@router.post("/jobs/{job_id}/geometry")
def set_geometry(
job_id: int,
volume_ccm: float = Form(...),
cube_x: float = Form(...),
cube_y: float = Form(...),
cube_z: float = Form(...),
db: Session = Depends(get_db),
):
from fastapi import HTTPException
job = db.query(PrintJob).filter(PrintJob.id == job_id).first()
if not job or not job.file_name:
raise HTTPException(status_code=404, detail="Job or file not found")
stl = db.query(StlCache).filter_by(filename=job.file_name).first()
if stl:
stl.volume_ccm = volume_ccm
stl.cube_x = cube_x
stl.cube_y = cube_y
stl.cube_z = cube_z
stl.manually_set = True
else:
db.add(StlCache(
filename=job.file_name,
volume_ccm=volume_ccm,
cube_x=cube_x,
cube_y=cube_y,
cube_z=cube_z,
manually_set=True,
))
db.commit()
return RedirectResponse(url=f"/jobs/{job_id}", status_code=HTTP_303_SEE_OTHER)
@router.post("/jobs/{job_id}/log")
def add_job_log(job_id: int, message: str = Form(...), db: Session = Depends(get_db)):
db.add(JobLog(job_id=job_id, message=message.strip()))
db.commit()
return RedirectResponse(url=f"/jobs/{job_id}", 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}")
def bracket_detail_redirect(bracket_id: int):
return RedirectResponse(url=f"/jobs?bracket_id={bracket_id}", status_code=HTTP_303_SEE_OTHER)
# ── To-Do (Vikunja) ────────────────────────────────────────────────────────────
@router.get("/todos", response_class=HTMLResponse)
def todos_page(request: Request):
tasks = vikunja_tasks()
tasks.sort(key=lambda t: (t.get("done", False), t.get("created", "")), reverse=False)
return templates.TemplateResponse("todos.html", {
"request": request,
"active": "todos",
"todos": tasks,
})
@router.post("/todos")
def create_todo(title: str = Form(...)):
vikunja_create(title.strip())
return RedirectResponse(url="/todos", status_code=HTTP_303_SEE_OTHER)
@router.post("/todos/{task_id}/done")
def done_todo(task_id: int):
vikunja_update(task_id, done=True)
return RedirectResponse(url="/todos", status_code=HTTP_303_SEE_OTHER)
@router.post("/todos/{task_id}/delete")
def delete_todo(task_id: int):
vikunja_delete(task_id)
return RedirectResponse(url="/todos", status_code=HTTP_303_SEE_OTHER)
# ── 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(),
})