Add job brackets for grouping multi-part or repeated jobs

- JobBracket model: name, customer, notes
- Migration 0006: job_brackets table + bracket_id FK on print_jobs
- /brackets: list with per-bracket progress bar
- /brackets/{id}: detail with jobs table + Add Job(s) modal (quantity field
  creates N identical jobs in one shot, each with its own UUID and log entry)
- /jobs Add Job modal: optional bracket selector
- Sidebar: Brackets nav item added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Martin Hohenberg
2026-06-18 22:03:01 +02:00
parent 977e0deb24
commit ccf6a64787
9 changed files with 398 additions and 1 deletions

View File

@@ -10,6 +10,7 @@ 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
@@ -135,6 +136,7 @@ def jobs_page(request: Request, q: str = "", db: Session = Depends(get_db)):
"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": list_stl_files(),
})
@@ -144,6 +146,7 @@ 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(""),
@@ -162,6 +165,7 @@ async def create_job(
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,
)
@@ -172,6 +176,78 @@ async def create_job(
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": list_stl_files(),
})
@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())
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)