Files
POPS/app/routers/ui.py
Martin Hohenberg 4a3c370317 Job list: sort by status/priority/age, hide old finished jobs by default
Sort order: printing → queued → rest, then priority desc, then created_at asc.
Finished jobs (done/failed/cancelled) older than 24h are hidden by default;
a footer note shows the count with a 'Show all' link (?show_all=1).
Filter params (bracket_id, q) are preserved on the show-all link.

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

361 lines
14 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 os
import uuid as _uuid
from datetime import datetime, timedelta
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 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.models.todo import Todo
from app.constants import COLORS, MATERIALS
from app.filamentdb import available_colors, available_materials, get_stock, spool_counts
from app.stl import get_stl_dir, list_stl_files
from app.stl_analysis import analyse, get_cache_map # noqa: F401 used in job_detail route
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()
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,
"printers": db.query(Printer).order_by(Printer.name).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),
"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, 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")
stl = db.query(StlCache).filter_by(filename=job.file_name).first() if job.file_name else None
return templates.TemplateResponse("job_detail.html", {
"request": request,
"active": "jobs",
"job": job,
"stl": stl,
"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],
})
@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.post("/jobs/{job_id}/analyse")
def analyse_job_file(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 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()
analyse(job.file_name, db)
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 ──────────────────────────────────────────────────────────────────────
@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(),
})