Add printer types with bed volume + add-printer UI

- PrinterType model: name, bed_x_mm/y/z, linked to Printer via type_id
- Migration 0002: creates printer_types, adds type_id to printers, drops model
- /printer-types page: table + Add Type modal form
- /printers page: Add Printer modal with type dropdown (disabled if no types)
- API: GET/POST/DELETE /api/printer-types

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Martin Hohenberg
2026-06-18 21:46:26 +02:00
parent 5fb15b36bf
commit 750edbbcec
9 changed files with 309 additions and 12 deletions

View File

@@ -0,0 +1,40 @@
"""printer_types table and link to printers
Revision ID: 0002
Revises: 0001
Create Date: 2026-06-18
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0002"
down_revision: Union[str, None] = "0001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"printer_types",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("bed_x_mm", sa.Integer(), nullable=False),
sa.Column("bed_y_mm", sa.Integer(), nullable=False),
sa.Column("bed_z_mm", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
op.add_column("printers", sa.Column("type_id", sa.Integer(), nullable=True))
op.create_foreign_key("fk_printer_type", "printers", "printer_types", ["type_id"], ["id"])
op.drop_column("printers", "model")
def downgrade() -> None:
op.drop_constraint("fk_printer_type", "printers", type_="foreignkey")
op.drop_column("printers", "type_id")
op.add_column("printers", sa.Column("model", sa.String(length=100), nullable=True))
op.drop_table("printer_types")

View File

@@ -5,7 +5,7 @@ from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from app.db import check_db, run_migrations from app.db import check_db, run_migrations
from app.routers import filaments, pricing, print_jobs, printers, todos from app.routers import filaments, pricing, print_jobs, printer_types, printers, todos
from app.routers import ui from app.routers import ui
logging.basicConfig( logging.basicConfig(
@@ -39,6 +39,7 @@ async def unhandled_exception(request: Request, exc: Exception):
# JSON API # JSON API
app.include_router(printer_types.router, prefix="/api")
app.include_router(printers.router, prefix="/api") app.include_router(printers.router, prefix="/api")
app.include_router(filaments.router, prefix="/api") app.include_router(filaments.router, prefix="/api")
app.include_router(print_jobs.router, prefix="/api") app.include_router(print_jobs.router, prefix="/api")

View File

@@ -3,6 +3,7 @@ from app.models.filament import Filament
from app.models.pricing import PricingConfig from app.models.pricing import PricingConfig
from app.models.print_job import PrintJob from app.models.print_job import PrintJob
from app.models.printer import Printer from app.models.printer import Printer
from app.models.printer_type import PrinterType
from app.models.todo import Todo from app.models.todo import Todo
__all__ = ["Base", "Printer", "Filament", "PrintJob", "Todo", "PricingConfig"] __all__ = ["Base", "Printer", "PrinterType", "Filament", "PrintJob", "Todo", "PricingConfig"]

View File

@@ -1,7 +1,7 @@
import enum import enum
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, Enum, String, func from sqlalchemy import DateTime, Enum, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base from app.models.base import Base
@@ -19,8 +19,8 @@ class Printer(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100)) name: Mapped[str] = mapped_column(String(100))
model: Mapped[str | None] = mapped_column(String(100))
location: Mapped[str | None] = mapped_column(String(100)) location: Mapped[str | None] = mapped_column(String(100))
type_id: Mapped[int | None] = mapped_column(ForeignKey("printer_types.id"))
status: Mapped[PrinterStatus] = mapped_column( status: Mapped[PrinterStatus] = mapped_column(
Enum(PrinterStatus), default=PrinterStatus.offline Enum(PrinterStatus), default=PrinterStatus.offline
) )
@@ -29,5 +29,6 @@ class Printer(Base):
DateTime, server_default=func.now(), onupdate=func.now() DateTime, server_default=func.now(), onupdate=func.now()
) )
printer_type: Mapped["PrinterType | None"] = relationship(back_populates="printers")
jobs: Mapped[list["PrintJob"]] = relationship(back_populates="printer") jobs: Mapped[list["PrintJob"]] = relationship(back_populates="printer")
todos: Mapped[list["Todo"]] = relationship(back_populates="printer") todos: Mapped[list["Todo"]] = relationship(back_populates="printer")

View File

@@ -0,0 +1,19 @@
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
class PrinterType(Base):
__tablename__ = "printer_types"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100), unique=True)
bed_x_mm: Mapped[int] = mapped_column(Integer)
bed_y_mm: Mapped[int] = mapped_column(Integer)
bed_z_mm: Mapped[int] = mapped_column(Integer)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
printers: Mapped[list["Printer"]] = relationship(back_populates="printer_type")

View File

@@ -0,0 +1,39 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.db import get_db
from app.models.printer_type import PrinterType
router = APIRouter(prefix="/printer-types", tags=["printer-types"])
class PrinterTypeCreate(BaseModel):
name: str
bed_x_mm: int
bed_y_mm: int
bed_z_mm: int
@router.get("/")
def list_printer_types(db: Session = Depends(get_db)):
return [t.to_dict() for t in db.query(PrinterType).order_by(PrinterType.name).all()]
@router.post("/")
def create_printer_type(data: PrinterTypeCreate, db: Session = Depends(get_db)):
pt = PrinterType(**data.model_dump())
db.add(pt)
db.commit()
db.refresh(pt)
return pt.to_dict()
@router.delete("/{type_id}")
def delete_printer_type(type_id: int, db: Session = Depends(get_db)):
pt = db.query(PrinterType).filter(PrinterType.id == type_id).first()
if not pt:
raise HTTPException(status_code=404, detail="Printer type not found")
db.delete(pt)
db.commit()
return {"ok": True}

View File

@@ -1,12 +1,14 @@
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from starlette.status import HTTP_303_SEE_OTHER
from app.db import get_db from app.db import get_db
from app.models.filament import Filament from app.models.filament import Filament
from app.models.print_job import JobStatus, PrintJob from app.models.print_job import JobStatus, PrintJob
from app.models.printer import Printer, PrinterStatus from app.models.printer import Printer, PrinterStatus
from app.models.printer_type import PrinterType
from app.models.pricing import PricingConfig from app.models.pricing import PricingConfig
from app.models.todo import Todo from app.models.todo import Todo
@@ -37,9 +39,50 @@ def printers_page(request: Request, db: Session = Depends(get_db)):
"request": request, "request": request,
"active": "printers", "active": "printers",
"printers": db.query(Printer).order_by(Printer.name).all(), "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(""),
db: Session = Depends(get_db),
):
printer = Printer(
name=name.strip(),
location=location.strip() or None,
type_id=int(type_id) if type_id else None,
)
db.add(printer)
db.commit()
return RedirectResponse(url="/printers", status_code=HTTP_303_SEE_OTHER)
@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),
):
pt = PrinterType(name=name.strip(), bed_x_mm=bed_x_mm, bed_y_mm=bed_y_mm, bed_z_mm=bed_z_mm)
db.add(pt)
db.commit()
return RedirectResponse(url="/printer-types", status_code=HTTP_303_SEE_OTHER)
@router.get("/jobs", response_class=HTMLResponse) @router.get("/jobs", response_class=HTMLResponse)
def jobs_page(request: Request, db: Session = Depends(get_db)): def jobs_page(request: Request, db: Session = Depends(get_db)):
return templates.TemplateResponse("jobs.html", { return templates.TemplateResponse("jobs.html", {

View File

@@ -0,0 +1,86 @@
{% extends "base.html" %}
{% block title %}Printer Types — POPS{% endblock %}
{% block content %}
<div class="d-flex align-items-center justify-content-between mb-4">
<div class="d-flex align-items-center gap-3">
<a href="/printers" class="text-secondary text-decoration-none"><i class="bi bi-arrow-left"></i></a>
<h5 class="text-white mb-0">Printer Types</h5>
</div>
<button class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#addTypeModal">
<i class="bi bi-plus-lg me-1"></i>Add Type
</button>
</div>
<div class="card">
{% if printer_types %}
<table class="table table-dark table-hover mb-0 align-middle">
<thead>
<tr class="text-secondary small">
<th class="fw-normal">Name</th>
<th class="fw-normal text-center">Bed X (mm)</th>
<th class="fw-normal text-center">Bed Y (mm)</th>
<th class="fw-normal text-center">Bed Z (mm)</th>
<th class="fw-normal text-center">Volume</th>
</tr>
</thead>
<tbody>
{% for t in printer_types %}
<tr>
<td class="fw-medium text-white">{{ t.name }}</td>
<td class="text-secondary text-center">{{ t.bed_x_mm }}</td>
<td class="text-secondary text-center">{{ t.bed_y_mm }}</td>
<td class="text-secondary text-center">{{ t.bed_z_mm }}</td>
<td class="text-secondary small text-center">
{{ (t.bed_x_mm * t.bed_y_mm * t.bed_z_mm / 1000) | round(0) | int }} cm³
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="card-body text-secondary">No printer types defined yet.</div>
{% endif %}
</div>
{# ── Add Type Modal ── #}
<div class="modal fade" id="addTypeModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content bg-dark border-secondary">
<form method="POST" action="/printer-types">
<div class="modal-header border-secondary">
<h6 class="modal-title text-white">Add Printer Type</h6>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body d-flex flex-column gap-3">
<div>
<label class="form-label text-secondary small">Name <span class="text-danger">*</span></label>
<input type="text" name="name" class="form-control bg-dark border-secondary text-white"
placeholder="e.g. Bambulab X1C" required autofocus>
</div>
<div>
<label class="form-label text-secondary small mb-2">Print bed volume (mm) <span class="text-danger">*</span></label>
<div class="d-flex gap-2 align-items-center">
<input type="number" name="bed_x_mm" class="form-control bg-dark border-secondary text-white text-center"
placeholder="X" min="1" required>
<span class="text-secondary">×</span>
<input type="number" name="bed_y_mm" class="form-control bg-dark border-secondary text-white text-center"
placeholder="Y" min="1" required>
<span class="text-secondary">×</span>
<input type="number" name="bed_z_mm" class="form-control bg-dark border-secondary text-white text-center"
placeholder="Z" min="1" required>
</div>
</div>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary btn-sm">Add Type</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -2,24 +2,49 @@
{% block title %}Printers — POPS{% endblock %} {% block title %}Printers — POPS{% endblock %}
{% block content %} {% block content %}
<h5 class="text-white mb-4">Printers</h5> <div class="d-flex align-items-center justify-content-between mb-4">
<h5 class="text-white mb-0">Printers</h5>
<div class="d-flex gap-2">
<a href="/printer-types" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-tags me-1"></i>Manage Types
</a>
<button class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#addPrinterModal"
{% if not printer_types %}disabled title="Add a printer type first"{% endif %}>
<i class="bi bi-plus-lg me-1"></i>Add Printer
</button>
</div>
</div>
{% if not printer_types %}
<div class="alert alert-secondary border-secondary" role="alert">
<i class="bi bi-info-circle me-2"></i>
No printer types defined yet.
<a href="/printer-types" class="alert-link">Add a printer type</a> before adding printers.
</div>
{% endif %}
<div class="card"> <div class="card">
{% if printers %} {% if printers %}
<table class="table table-dark table-hover mb-0 align-middle"> <table class="table table-dark table-hover mb-0 align-middle">
<thead> <thead>
<tr class="text-secondary small"> <tr class="text-secondary small">
<th class="fw-normal">Name</th> <th class="fw-normal">Name</th>
<th class="fw-normal">Model</th> <th class="fw-normal">Type</th>
<th class="fw-normal">Bed volume</th>
<th class="fw-normal">Location</th> <th class="fw-normal">Location</th>
<th class="fw-normal">Status</th> <th class="fw-normal">Status</th>
<th class="fw-normal">Added</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for p in printers %} {% for p in printers %}
<tr> <tr>
<td class="fw-medium text-white">{{ p.name }}</td> <td class="fw-medium text-white">{{ p.name }}</td>
<td class="text-secondary">{{ p.model or '—' }}</td> <td class="text-secondary">{{ p.printer_type.name if p.printer_type else '—' }}</td>
<td class="text-secondary small">
{% if p.printer_type %}
{{ p.printer_type.bed_x_mm }}×{{ p.printer_type.bed_y_mm }}×{{ p.printer_type.bed_z_mm }} mm
{% else %}—{% endif %}
</td>
<td class="text-secondary">{{ p.location or '—' }}</td> <td class="text-secondary">{{ p.location or '—' }}</td>
<td> <td>
{% set s = p.status.value %} {% set s = p.status.value %}
@@ -29,13 +54,55 @@
{% else %}<span class="badge bg-secondary">offline</span> {% else %}<span class="badge bg-secondary">offline</span>
{% endif %} {% endif %}
</td> </td>
<td class="text-secondary small">{{ p.created_at.strftime('%d.%m.%Y') }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% else %} {% else %}
<div class="card-body text-secondary">No printers configured yet.</div> <div class="card-body text-secondary">No printers added yet.</div>
{% endif %} {% endif %}
</div> </div>
{# ── Add Printer Modal ── #}
<div class="modal fade" id="addPrinterModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content bg-dark border-secondary">
<form method="POST" action="/printers">
<div class="modal-header border-secondary">
<h6 class="modal-title text-white">Add Printer</h6>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body d-flex flex-column gap-3">
<div>
<label class="form-label text-secondary small">Name <span class="text-danger">*</span></label>
<input type="text" name="name" class="form-control bg-dark border-secondary text-white"
placeholder="e.g. Bambu #1" required autofocus>
</div>
<div>
<label class="form-label text-secondary small">Type <span class="text-danger">*</span></label>
<select name="type_id" class="form-select bg-dark border-secondary text-white" required>
<option value="">— select —</option>
{% for t in printer_types %}
<option value="{{ t.id }}">{{ t.name }} ({{ t.bed_x_mm }}×{{ t.bed_y_mm }}×{{ t.bed_z_mm }} mm)</option>
{% endfor %}
</select>
</div>
<div>
<label class="form-label text-secondary small">Location</label>
<input type="text" name="location" class="form-control bg-dark border-secondary text-white"
placeholder="e.g. Workshop, Shelf 2">
</div>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary btn-sm">Add Printer</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %} {% endblock %}