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.estimation import estimate 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 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, "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.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}")) 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}")) 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(), })