Filament removal:
- Deleted app/models/filament.py, routers/filaments.py, templates/filaments.html
- Migration 0009: drops filaments table and filament_id FK from print_jobs
- Removed Filaments from sidebar and all internal references
FilamentDB integration (read-only):
- app/filamentdb.py: GET /reports/stock with 5-min in-memory cache
- available_materials() / available_colors() populate job form dropdowns
- Falls back to static constants if FilamentDB unreachable
- Dashboard filament count now from FilamentDB rolls_in_stock
- FILAMENTDB_URL added to .env.install; httpx added to requirements
Job detail page (/jobs/{id}):
- Two cards: Job info (customer, material, printer, bracket, timing, cost)
and File & Geometry (bounding box + volume from stl_cache)
- Log timeline with Add Entry modal
- Filename in jobs list links to detail page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
329 lines
13 KiB
Python
329 lines
13 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.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
|
||
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
|
||
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": sum(r.get("rolls_in_stock", 0) for r in get_stock()),
|
||
"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),
|
||
"materials": available_materials() or MATERIALS,
|
||
"colors": available_colors() or COLORS,
|
||
})
|
||
|
||
|
||
@router.post("/jobs")
|
||
async def create_job(
|
||
printer_id: str = Form(""),
|
||
customer: str = Form(""),
|
||
job_material: str = Form(""),
|
||
job_color: 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,
|
||
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 and amount > 1:
|
||
return RedirectResponse(url=f"/brackets/{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,
|
||
})
|
||
|
||
|
||
@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}", 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),
|
||
"materials": available_materials() or MATERIALS,
|
||
"colors": available_colors() or COLORS,
|
||
})
|
||
|
||
|
||
@router.post("/brackets/{bracket_id}/jobs")
|
||
async def add_jobs_to_bracket(
|
||
bracket_id: int,
|
||
quantity: int = Form(1),
|
||
job_material: str = Form(""),
|
||
job_color: 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,
|
||
job_material=job_material or None,
|
||
job_color=job_color 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=f"/brackets/{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(),
|
||
})
|