diff --git a/alembic/versions/0003_printer_lifecycle_and_logs.py b/alembic/versions/0003_printer_lifecycle_and_logs.py
new file mode 100644
index 0000000..4b556f4
--- /dev/null
+++ b/alembic/versions/0003_printer_lifecycle_and_logs.py
@@ -0,0 +1,39 @@
+"""printer lifecycle fields and printer_logs table
+
+Revision ID: 0003
+Revises: 0002
+Create Date: 2026-06-18
+
+"""
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "0003"
+down_revision: Union[str, None] = "0002"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.add_column("printers", sa.Column("bought_at", sa.Date(), nullable=True))
+ op.add_column("printers", sa.Column("last_maintenance_at", sa.Date(), nullable=True))
+ op.add_column("printers", sa.Column("decommissioned_at", sa.Date(), nullable=True))
+
+ op.create_table(
+ "printer_logs",
+ sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column("printer_id", sa.Integer(), nullable=False),
+ sa.Column("message", sa.Text(), nullable=False),
+ sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
+ sa.ForeignKeyConstraint(["printer_id"], ["printers.id"], name="fk_log_printer", ondelete="CASCADE"),
+ sa.PrimaryKeyConstraint("id"),
+ )
+
+
+def downgrade() -> None:
+ op.drop_table("printer_logs")
+ op.drop_column("printers", "decommissioned_at")
+ op.drop_column("printers", "last_maintenance_at")
+ op.drop_column("printers", "bought_at")
diff --git a/app/models/__init__.py b/app/models/__init__.py
index ee9945f..67dab53 100644
--- a/app/models/__init__.py
+++ b/app/models/__init__.py
@@ -3,7 +3,8 @@ 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_log import PrinterLog
from app.models.printer_type import PrinterType
from app.models.todo import Todo
-__all__ = ["Base", "Printer", "PrinterType", "Filament", "PrintJob", "Todo", "PricingConfig"]
+__all__ = ["Base", "Printer", "PrinterLog", "PrinterType", "Filament", "PrintJob", "Todo", "PricingConfig"]
diff --git a/app/models/printer.py b/app/models/printer.py
index be8c19b..561403e 100644
--- a/app/models/printer.py
+++ b/app/models/printer.py
@@ -1,7 +1,7 @@
import enum
-from datetime import datetime
+from datetime import date, datetime
-from sqlalchemy import DateTime, Enum, ForeignKey, String, func
+from sqlalchemy import Date, DateTime, Enum, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
@@ -24,6 +24,9 @@ class Printer(Base):
status: Mapped[PrinterStatus] = mapped_column(
Enum(PrinterStatus), default=PrinterStatus.offline
)
+ bought_at: Mapped[date | None] = mapped_column(Date)
+ last_maintenance_at: Mapped[date | None] = mapped_column(Date)
+ decommissioned_at: Mapped[date | None] = mapped_column(Date)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now()
@@ -32,3 +35,6 @@ class Printer(Base):
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")
+ logs: Mapped[list["PrinterLog"]] = relationship(
+ back_populates="printer", order_by="PrinterLog.created_at.desc()"
+ )
diff --git a/app/models/printer_log.py b/app/models/printer_log.py
new file mode 100644
index 0000000..bae36fc
--- /dev/null
+++ b/app/models/printer_log.py
@@ -0,0 +1,17 @@
+from datetime import datetime
+
+from sqlalchemy import DateTime, ForeignKey, Text, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.models.base import Base
+
+
+class PrinterLog(Base):
+ __tablename__ = "printer_logs"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id"))
+ message: Mapped[str] = mapped_column(Text)
+ created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+ printer: Mapped["Printer"] = relationship(back_populates="logs")
diff --git a/app/routers/ui.py b/app/routers/ui.py
index 63287ea..f136ff1 100644
--- a/app/routers/ui.py
+++ b/app/routers/ui.py
@@ -8,6 +8,7 @@ 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_log import PrinterLog
from app.models.printer_type import PrinterType
from app.models.pricing import PricingConfig
from app.models.todo import Todo
@@ -48,18 +49,42 @@ 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)):
+ printer = db.query(Printer).filter(Printer.id == printer_id).first()
+ if not printer:
+ from fastapi import HTTPException
+ 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)):
+ entry = PrinterLog(printer_id=printer_id, message=message.strip())
+ db.add(entry)
+ db.commit()
+ return RedirectResponse(url=f"/printers/{printer_id}", 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", {
diff --git a/app/templates/printer_detail.html b/app/templates/printer_detail.html
new file mode 100644
index 0000000..c665523
--- /dev/null
+++ b/app/templates/printer_detail.html
@@ -0,0 +1,109 @@
+{% extends "base.html" %}
+{% block title %}{{ printer.name }} — POPS{% endblock %}
+
+{% block content %}
+
+
+
{{ printer.name }}
+ {% set s = printer.status.value %}
+ {% if s == 'printing' %}
printing
+ {% elif s == 'idle' %}
idle
+ {% elif s == 'error' %}
error
+ {% else %}
offline
+ {% endif %}
+ {% if printer.decommissioned_at %}
+
decommissioned
+ {% endif %}
+
+
+
+
+ {# ── Info card ── #}
+
+
+
+
+
+
+ | Type |
+ {{ printer.printer_type.name if printer.printer_type else '—' }} |
+ | Bed volume |
+ {% if printer.printer_type %}{{ printer.printer_type.bed_x_mm }}×{{ printer.printer_type.bed_y_mm }}×{{ printer.printer_type.bed_z_mm }} mm{% else %}—{% endif %} |
+ | Location |
+ {{ printer.location or '—' }} |
+
+
+
+
+
+
+ {# ── Lifecycle card ── #}
+
+
+
+
+
+
+ | Purchased |
+ {{ printer.bought_at.strftime('%d.%m.%Y') if printer.bought_at else '—' }} |
+ | Last maintenance |
+ {{ printer.last_maintenance_at.strftime('%d.%m.%Y') if printer.last_maintenance_at else '—' }} |
+ | Decommissioned |
+ {{ printer.decommissioned_at.strftime('%d.%m.%Y') if printer.decommissioned_at else '—' }} |
+
+
+
+
+
+
+
+
+{# ── Log ── #}
+
+
Log
+
+
+
+
+ {% if printer.logs %}
+
+
+ {% for entry in printer.logs %}
+
+ |
+ {{ entry.created_at.strftime('%d.%m.%Y %H:%M') }}
+ |
+ {{ entry.message }} |
+
+ {% endfor %}
+
+
+ {% else %}
+
No log entries yet.
+ {% endif %}
+
+
+{# ── Add log entry modal ── #}
+
+{% endblock %}
diff --git a/app/templates/printers.html b/app/templates/printers.html
index c5af27f..bf40582 100644
--- a/app/templates/printers.html
+++ b/app/templates/printers.html
@@ -38,7 +38,7 @@
{% for p in printers %}
- | {{ p.name }} |
+ {{ p.name }} |
{{ p.printer_type.name if p.printer_type else '—' }} |
{% if p.printer_type %}
@@ -96,6 +96,11 @@
placeholder="e.g. Workshop, Shelf 2">
+
+
+
+
+
|