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(), })