import math import os import uuid as _uuid from datetime import datetime, timedelta from pathlib import Path from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, Request, UploadFile from fastapi.responses import FileResponse, 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.vikunja import create_task as vikunja_create, delete_task as vikunja_delete, get_tasks as vikunja_tasks, update_task as vikunja_update from app.constants import COLORS, MATERIALS from app.estimation import estimate # also used in start_job from app.filamentdb import (available_colors, available_materials, find_matching_ams_roll, get_stock, log_print_on_roll, spool_counts) from app.stl import get_stl_dir, list_stl_files from app.stl_analysis import analyse_in_background, get_cache_map, is_computing # noqa: F401 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() stl_files = list_stl_files() stl_cache = get_cache_map(stl_files, db) bracket_gross_total = None if active_bracket: total = 0.0 has_any = False for j in active_bracket.jobs: cached = stl_cache.get(j.file_name) if cached and cached.volume_ccm: total += estimate(cached.volume_ccm, j.job_material)["gross_price"] has_any = True if has_any: bracket_gross_total = round(total, 2) 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, "bracket_gross_total": bracket_gross_total, "printers": db.query(Printer).order_by(Printer.name).all(), "brackets": db.query(JobBracket).order_by(JobBracket.name).all(), "stl_files": stl_files, "stl_cache": stl_cache, "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, background_tasks: BackgroundTasks, 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") file_missing = False computing = False stl = None if job.file_name: file_path = get_stl_dir() / job.file_name if not file_path.exists(): file_missing = True else: stl = db.query(StlCache).filter_by(filename=job.file_name).first() if stl is None and not job.file_name.endswith(".gcode"): if not is_computing(job.file_name): background_tasks.add_task(analyse_in_background, job.file_name) computing = True 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, "file_missing": file_missing, "computing": computing, "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.get("/files/{filename}") def download_file(filename: str): from fastapi import HTTPException stl_dir = get_stl_dir() path = (stl_dir / filename).resolve() if not str(path).startswith(str(stl_dir.resolve())): raise HTTPException(400, "Invalid filename") if not path.exists(): raise HTTPException(404, "File not found") return FileResponse(path, filename=filename, media_type="application/octet-stream") @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}")) # Log filament usage against matching AMS roll in FilamentDB filament_low = False if job.job_material and job.job_color: stl = db.query(StlCache).filter_by(filename=job.file_name).first() if job.file_name else None if stl and stl.volume_ccm: weight_g = math.ceil(estimate(stl.volume_ccm, job.job_material)["weight_g"]) roll = find_matching_ams_roll(job.job_material, job.job_color) if roll: note = (f"POPS {job.job_uuid[:8] if job.job_uuid else job.id}: " f"{job.file_name or job.name} on {printer.name}") if log_print_on_roll(roll["roll_id"], weight_g, note): db.add(JobLog(job_id=job_id, message=( f"FilamentDB: logged {weight_g} g against roll #{roll['roll_id']} " f"(AMS slot {roll['slot']} — {roll['material']} {roll['color']}, " f"{roll['weight_remaining_g']} g remaining, least-full roll selected)" ))) if roll["weight_remaining_g"] - weight_g < 100: filament_low = True else: db.add(JobLog(job_id=job_id, message=f"FilamentDB: print log failed for roll #{roll['roll_id']}")) else: db.add(JobLog(job_id=job_id, message=( f"FilamentDB: no {job.job_material} {job.job_color} roll found in AMS" ))) db.commit() if filament_low: vikunja_create("Check filament status") return RedirectResponse(url=f"/jobs/{job_id}", status_code=HTTP_303_SEE_OTHER) @router.post("/jobs/{job_id}/cancel") def cancel_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") if job.printer and job.status == JobStatus.printing: job.printer.status = PrinterStatus.idle job.status = JobStatus.cancelled db.add(JobLog(job_id=job_id, message="Job cancelled")) 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}")) # Every 3rd completed print on this printer → cleaning reminder cleaning_todo = False if job.printer_id: done_count = db.query(PrintJob).filter( PrintJob.printer_id == job.printer_id, PrintJob.status == JobStatus.done, ).count() if (done_count + 1) % 3 == 0: cleaning_todo = True db.commit() if cleaning_todo: vikunja_create(f"Clean build plate on {printer_name}") 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, background_tasks: BackgroundTasks, 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() background_tasks.add_task(analyse_in_background, job.file_name) return RedirectResponse(url=f"/jobs/{job_id}", status_code=HTTP_303_SEE_OTHER) @router.post("/jobs/{job_id}/geometry") def set_geometry( job_id: int, volume_ccm: float = Form(...), cube_x: float = Form(...), cube_y: float = Form(...), cube_z: float = Form(...), 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") stl = db.query(StlCache).filter_by(filename=job.file_name).first() if stl: stl.volume_ccm = volume_ccm stl.cube_x = cube_x stl.cube_y = cube_y stl.cube_z = cube_z stl.manually_set = True else: db.add(StlCache( filename=job.file_name, volume_ccm=volume_ccm, cube_x=cube_x, cube_y=cube_y, cube_z=cube_z, manually_set=True, )) db.commit() 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 (Vikunja) ──────────────────────────────────────────────────────────── @router.get("/todos", response_class=HTMLResponse) def todos_page(request: Request): tasks = vikunja_tasks() tasks.sort(key=lambda t: (t.get("done", False), t.get("created", "")), reverse=False) return templates.TemplateResponse("todos.html", { "request": request, "active": "todos", "todos": tasks, }) @router.post("/todos") def create_todo(title: str = Form(...)): vikunja_create(title.strip()) return RedirectResponse(url="/todos", status_code=HTTP_303_SEE_OTHER) @router.post("/todos/{task_id}/done") def done_todo(task_id: int): vikunja_update(task_id, done=True) return RedirectResponse(url="/todos", status_code=HTTP_303_SEE_OTHER) @router.post("/todos/{task_id}/delete") def delete_todo(task_id: int): vikunja_delete(task_id) return RedirectResponse(url="/todos", status_code=HTTP_303_SEE_OTHER) # ── 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(), })