From ee0b1b39e90ab49b2008220645a6b674eea0beb2 Mon Sep 17 00:00:00 2001 From: Martin Hohenberg Date: Thu, 18 Jun 2026 21:26:23 +0200 Subject: [PATCH] Add Jinja2 web UI with Bootstrap 5 dark theme - app/templates/: base layout with sidebar nav + 6 pages (dashboard, printers, jobs, filaments, todos, pricing) - app/routers/ui.py: HTML routes querying the DB live - API routes moved to /api/* prefix - requirements: jinja2, python-multipart Co-Authored-By: Claude Sonnet 4.6 --- app/main.py | 18 +++--- app/routers/ui.py | 76 +++++++++++++++++++++++++ app/templates/base.html | 65 +++++++++++++++++++++ app/templates/dashboard.html | 106 +++++++++++++++++++++++++++++++++++ app/templates/filaments.html | 52 +++++++++++++++++ app/templates/jobs.html | 52 +++++++++++++++++ app/templates/pricing.html | 30 ++++++++++ app/templates/printers.html | 41 ++++++++++++++ app/templates/todos.html | 40 +++++++++++++ requirements.txt | 2 + 10 files changed, 473 insertions(+), 9 deletions(-) create mode 100644 app/routers/ui.py create mode 100644 app/templates/base.html create mode 100644 app/templates/dashboard.html create mode 100644 app/templates/filaments.html create mode 100644 app/templates/jobs.html create mode 100644 app/templates/pricing.html create mode 100644 app/templates/printers.html create mode 100644 app/templates/todos.html diff --git a/app/main.py b/app/main.py index 9fc1ce2..b02bce4 100644 --- a/app/main.py +++ b/app/main.py @@ -5,6 +5,7 @@ from fastapi import FastAPI from app.db import check_db, run_migrations from app.routers import filaments, pricing, print_jobs, printers, todos +from app.routers import ui logger = logging.getLogger("pops") @@ -22,16 +23,15 @@ async def lifespan(app: FastAPI): app = FastAPI(title="POPS", lifespan=lifespan) -app.include_router(printers.router) -app.include_router(filaments.router) -app.include_router(print_jobs.router) -app.include_router(todos.router) -app.include_router(pricing.router) +# JSON API +app.include_router(printers.router, prefix="/api") +app.include_router(filaments.router, prefix="/api") +app.include_router(print_jobs.router, prefix="/api") +app.include_router(todos.router, prefix="/api") +app.include_router(pricing.router, prefix="/api") - -@app.get("/") -def root(): - return {"message": "Hello from POPS"} +# Web UI (must come after API to avoid route shadowing) +app.include_router(ui.router) @app.get("/health") diff --git a/app/routers/ui.py b/app/routers/ui.py new file mode 100644 index 0000000..0d56313 --- /dev/null +++ b/app/routers/ui.py @@ -0,0 +1,76 @@ +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy.orm import Session + +from app.db import get_db +from app.models.filament import Filament +from app.models.print_job import JobStatus, PrintJob +from app.models.printer import Printer, PrinterStatus +from app.models.pricing import PricingConfig +from app.models.todo import Todo + +router = APIRouter() +templates = Jinja2Templates(directory="app/templates") + + +@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": db.query(Filament).count(), + "recent_jobs": db.query(PrintJob).order_by(PrintJob.created_at.desc()).limit(5).all(), + }) + + +@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(), + }) + + +@router.get("/jobs", response_class=HTMLResponse) +def jobs_page(request: Request, db: Session = Depends(get_db)): + return templates.TemplateResponse("jobs.html", { + "request": request, + "active": "jobs", + "jobs": db.query(PrintJob).order_by(PrintJob.created_at.desc()).all(), + }) + + +@router.get("/filaments", response_class=HTMLResponse) +def filaments_page(request: Request, db: Session = Depends(get_db)): + return templates.TemplateResponse("filaments.html", { + "request": request, + "active": "filaments", + "filaments": db.query(Filament).order_by(Filament.brand, Filament.material).all(), + }) + + +@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(), + }) + + +@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(), + }) diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..a259910 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,65 @@ + + + + + + {% block title %}POPS{% endblock %} + + + + + +
+ + + +
+ {% block content %}{% endblock %} +
+ +
+ + + diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100644 index 0000000..7f41896 --- /dev/null +++ b/app/templates/dashboard.html @@ -0,0 +1,106 @@ +{% extends "base.html" %} +{% block title %}Dashboard — POPS{% endblock %} + +{% block content %} +
Dashboard
+ +{# ── Stats row ── #} +
+
+
+
Printers
+
{{ total_printers }}
+
+
+
+
+
Printing Now
+
{{ printing_count }}
+
+
+
+
+
Active Jobs
+
{{ active_jobs }}
+
+
+
+
+
Filament Spools
+
{{ total_filaments }}
+
+
+
+ +
+ + {# ── Printer overview ── #} +
+
+
Printers
+ {% if printers %} + + + {% for p in printers %} + + + + + + {% endfor %} + +
{{ p.name }}{{ p.model or '—' }} + {% set s = p.status.value %} + {% if s == 'printing' %}printing + {% elif s == 'idle' %}idle + {% elif s == 'error' %}error + {% else %}offline + {% endif %} +
+ {% else %} +
No printers configured yet.
+ {% endif %} +
+
+ + {# ── Recent jobs ── #} +
+
+
Recent Jobs
+ {% if recent_jobs %} + + + + + + + + + + + {% for j in recent_jobs %} + + + + + + + {% endfor %} + +
NamePrinterStatusDate
{{ j.name }}{{ j.printer.name if j.printer else '—' }} + {% set s = j.status.value %} + {% if s == 'printing' %}printing + {% elif s == 'done' %}done + {% elif s == 'failed' %}failed + {% elif s == 'cancelled' %}cancelled + {% else %}queued + {% endif %} + {{ j.created_at.strftime('%d.%m %H:%M') }}
+ {% else %} +
No jobs yet.
+ {% endif %} +
+
+ +
+{% endblock %} diff --git a/app/templates/filaments.html b/app/templates/filaments.html new file mode 100644 index 0000000..7fb8f32 --- /dev/null +++ b/app/templates/filaments.html @@ -0,0 +1,52 @@ +{% extends "base.html" %} +{% block title %}Filaments — POPS{% endblock %} + +{% block content %} +
Filament Inventory
+
+ {% if filaments %} + + + + + + + + + + + + {% for f in filaments %} + {% set pct = (f.weight_remaining_g / f.weight_total_g * 100) | int if f.weight_total_g else 0 %} + + + + + + + + {% endfor %} + +
BrandMaterialColorRemainingPrice / kg
{{ f.brand or '—' }}{{ f.material.value }} +
+ {% if f.color_hex %} + + {% endif %} + {{ f.color or '—' }} +
+
+
+
+
+
+ {{ f.weight_remaining_g | int }}g / {{ f.weight_total_g | int }}g +
+
+ {% if f.price_per_kg_eur %}€ {{ "%.2f"|format(f.price_per_kg_eur) }}{% else %}—{% endif %} +
+ {% else %} +
No filaments in inventory yet.
+ {% endif %} +
+{% endblock %} diff --git a/app/templates/jobs.html b/app/templates/jobs.html new file mode 100644 index 0000000..3237eb6 --- /dev/null +++ b/app/templates/jobs.html @@ -0,0 +1,52 @@ +{% extends "base.html" %} +{% block title %}Jobs — POPS{% endblock %} + +{% block content %} +
Print Jobs
+
+ {% if jobs %} + + + + + + + + + + + + + + {% for j in jobs %} + + + + + + + + + + {% endfor %} + +
NamePrinterFilamentStatusDurationCostCreated
{{ j.name }}{{ j.printer.name if j.printer else '—' }} + {% if j.filament %}{{ j.filament.material.value }} {{ j.filament.color or '' }}{% else %}—{% endif %} + + {% set s = j.status.value %} + {% if s == 'printing' %}printing + {% elif s == 'done' %}done + {% elif s == 'failed' %}failed + {% elif s == 'cancelled' %}cancelled + {% else %}queued + {% endif %} + + {% if j.duration_minutes %}{{ j.duration_minutes }} min{% else %}—{% endif %} + + {% if j.cost_eur %}€ {{ "%.2f"|format(j.cost_eur) }}{% else %}—{% endif %} + {{ j.created_at.strftime('%d.%m %H:%M') }}
+ {% else %} +
No jobs yet.
+ {% endif %} +
+{% endblock %} diff --git a/app/templates/pricing.html b/app/templates/pricing.html new file mode 100644 index 0000000..f5bf74f --- /dev/null +++ b/app/templates/pricing.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% block title %}Pricing — POPS{% endblock %} + +{% block content %} +
Pricing Configuration
+{% if configs %} +
+{% for c in configs %} +
+
+
+ {{ c.name }} + from {{ c.valid_from.strftime('%d.%m.%Y') }} +
+ + + + + + + +
Electricity€ {{ "%.4f"|format(c.electricity_kwh_eur) }} / kWh
Printer draw{{ c.printer_watt }} W
Hourly rate€ {{ "%.2f"|format(c.hourly_rate_eur) }} / h
Markup{{ c.markup_percent }} %
+
+
+{% endfor %} +
+{% else %} +
No pricing config yet.
+{% endif %} +{% endblock %} diff --git a/app/templates/printers.html b/app/templates/printers.html new file mode 100644 index 0000000..79da7b6 --- /dev/null +++ b/app/templates/printers.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} +{% block title %}Printers — POPS{% endblock %} + +{% block content %} +
Printers
+
+ {% if printers %} + + + + + + + + + + + + {% for p in printers %} + + + + + + + + {% endfor %} + +
NameModelLocationStatusAdded
{{ p.name }}{{ p.model or '—' }}{{ p.location or '—' }} + {% set s = p.status.value %} + {% if s == 'printing' %}printing + {% elif s == 'idle' %}idle + {% elif s == 'error' %}error + {% else %}offline + {% endif %} + {{ p.created_at.strftime('%d.%m.%Y') }}
+ {% else %} +
No printers configured yet.
+ {% endif %} +
+{% endblock %} diff --git a/app/templates/todos.html b/app/templates/todos.html new file mode 100644 index 0000000..3b1d62e --- /dev/null +++ b/app/templates/todos.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block title %}To-Do — POPS{% endblock %} + +{% block content %} +
To-Do
+
+ {% if todos %} + + + + + + + + + + + {% for t in todos %} + + + + + + + {% endfor %} + +
TaskPrinterDoneCreated
+ {{ t.title }} + {{ t.printer.name if t.printer else '—' }} + {% if t.done %} + + {% else %} + + {% endif %} + {{ t.created_at.strftime('%d.%m.%Y') }}
+ {% else %} +
No to-do items yet.
+ {% endif %} +
+{% endblock %} diff --git a/requirements.txt b/requirements.txt index 310ac42..bf30a37 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,5 @@ sqlalchemy==2.0.36 alembic==1.14.0 pymysql==1.1.1 cryptography==44.0.3 +jinja2==3.1.4 +python-multipart==0.0.20