diff --git a/alembic/versions/0002_printer_types.py b/alembic/versions/0002_printer_types.py
new file mode 100644
index 0000000..e1c882d
--- /dev/null
+++ b/alembic/versions/0002_printer_types.py
@@ -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")
diff --git a/app/main.py b/app/main.py
index fda4b98..9436af0 100644
--- a/app/main.py
+++ b/app/main.py
@@ -5,7 +5,7 @@ from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
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
logging.basicConfig(
@@ -39,6 +39,7 @@ async def unhandled_exception(request: Request, exc: Exception):
# JSON API
+app.include_router(printer_types.router, prefix="/api")
app.include_router(printers.router, prefix="/api")
app.include_router(filaments.router, prefix="/api")
app.include_router(print_jobs.router, prefix="/api")
diff --git a/app/models/__init__.py b/app/models/__init__.py
index c76a13d..ee9945f 100644
--- a/app/models/__init__.py
+++ b/app/models/__init__.py
@@ -3,6 +3,7 @@ from app.models.filament import Filament
from app.models.pricing import PricingConfig
from app.models.print_job import PrintJob
from app.models.printer import Printer
+from app.models.printer_type import PrinterType
from app.models.todo import Todo
-__all__ = ["Base", "Printer", "Filament", "PrintJob", "Todo", "PricingConfig"]
+__all__ = ["Base", "Printer", "PrinterType", "Filament", "PrintJob", "Todo", "PricingConfig"]
diff --git a/app/models/printer.py b/app/models/printer.py
index d0a9192..be8c19b 100644
--- a/app/models/printer.py
+++ b/app/models/printer.py
@@ -1,7 +1,7 @@
import enum
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 app.models.base import Base
@@ -19,8 +19,8 @@ class Printer(Base):
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100))
- model: 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(
Enum(PrinterStatus), default=PrinterStatus.offline
)
@@ -29,5 +29,6 @@ class Printer(Base):
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")
todos: Mapped[list["Todo"]] = relationship(back_populates="printer")
diff --git a/app/models/printer_type.py b/app/models/printer_type.py
new file mode 100644
index 0000000..231dc4d
--- /dev/null
+++ b/app/models/printer_type.py
@@ -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")
diff --git a/app/routers/printer_types.py b/app/routers/printer_types.py
new file mode 100644
index 0000000..58d9e20
--- /dev/null
+++ b/app/routers/printer_types.py
@@ -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}
diff --git a/app/routers/ui.py b/app/routers/ui.py
index 0d56313..63287ea 100644
--- a/app/routers/ui.py
+++ b/app/routers/ui.py
@@ -1,12 +1,14 @@
-from fastapi import APIRouter, Depends, Request
-from fastapi.responses import HTMLResponse
+from fastapi import APIRouter, Depends, Form, Request
+from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
+from starlette.status import HTTP_303_SEE_OTHER
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.printer_type import PrinterType
from app.models.pricing import PricingConfig
from app.models.todo import Todo
@@ -37,9 +39,50 @@ def printers_page(request: Request, db: Session = Depends(get_db)):
"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(""),
+ 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)
def jobs_page(request: Request, db: Session = Depends(get_db)):
return templates.TemplateResponse("jobs.html", {
diff --git a/app/templates/printer_types.html b/app/templates/printer_types.html
new file mode 100644
index 0000000..2620a78
--- /dev/null
+++ b/app/templates/printer_types.html
@@ -0,0 +1,86 @@
+{% extends "base.html" %}
+{% block title %}Printer Types — POPS{% endblock %}
+
+{% block content %}
+
+
+
+ {% if printer_types %}
+
+
+
+ | Name |
+ Bed X (mm) |
+ Bed Y (mm) |
+ Bed Z (mm) |
+ Volume |
+
+
+
+ {% for t in printer_types %}
+
+ | {{ t.name }} |
+ {{ t.bed_x_mm }} |
+ {{ t.bed_y_mm }} |
+ {{ t.bed_z_mm }} |
+
+ {{ (t.bed_x_mm * t.bed_y_mm * t.bed_z_mm / 1000) | round(0) | int }} cm³
+ |
+
+ {% endfor %}
+
+
+ {% else %}
+
No printer types defined yet.
+ {% endif %}
+
+
+{# ── Add Type Modal ── #}
+
+{% endblock %}
diff --git a/app/templates/printers.html b/app/templates/printers.html
index 79da7b6..c5af27f 100644
--- a/app/templates/printers.html
+++ b/app/templates/printers.html
@@ -2,24 +2,49 @@
{% block title %}Printers — POPS{% endblock %}
{% block content %}
-Printers
+
+
+{% if not printer_types %}
+
+{% endif %}
+
{% if printers %}
| Name |
- Model |
+ Type |
+ Bed volume |
Location |
Status |
- Added |
{% for p in printers %}
| {{ p.name }} |
- {{ p.model or '—' }} |
+ {{ p.printer_type.name if p.printer_type else '—' }} |
+
+ {% 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 %}
+ |
{{ p.location or '—' }} |
{% set s = p.status.value %}
@@ -29,13 +54,55 @@
{% else %}offline
{% endif %}
|
- {{ p.created_at.strftime('%d.%m.%Y') }} |
{% endfor %}
{% else %}
-
No printers configured yet.
+
No printers added yet.
{% endif %}
+
+{# ── Add Printer Modal ── #}
+
{% endblock %}