Add printer lifecycle fields and printer log

- Printer: bought_at, last_maintenance_at, decommissioned_at (all Date, nullable)
- PrinterLog: per-printer timestamped text entries (CASCADE delete)
- Migration 0003: adds columns + printer_logs table
- /printers/{id}: detail page showing lifecycle info + scrollable log
- POST /printers/{id}/log: add a log entry via modal form
- Printer names in list are now links to the detail page
- Add Printer modal includes optional purchase date

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Martin Hohenberg
2026-06-18 21:47:48 +02:00
parent 750edbbcec
commit c78f8c91cd
7 changed files with 206 additions and 4 deletions

View File

@@ -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")

View File

@@ -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"]

View File

@@ -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()"
)

17
app/models/printer_log.py Normal file
View File

@@ -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")

View File

@@ -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", {

View File

@@ -0,0 +1,109 @@
{% extends "base.html" %}
{% block title %}{{ printer.name }} — POPS{% endblock %}
{% block content %}
<div class="d-flex align-items-center gap-3 mb-4">
<a href="/printers" class="text-secondary text-decoration-none"><i class="bi bi-arrow-left"></i></a>
<h5 class="text-white mb-0">{{ printer.name }}</h5>
{% set s = printer.status.value %}
{% if s == 'printing' %}<span class="badge bg-primary">printing</span>
{% elif s == 'idle' %}<span class="badge bg-success">idle</span>
{% elif s == 'error' %}<span class="badge bg-danger">error</span>
{% else %}<span class="badge bg-secondary">offline</span>
{% endif %}
{% if printer.decommissioned_at %}
<span class="badge bg-warning text-dark">decommissioned</span>
{% endif %}
</div>
<div class="row g-3 mb-4">
{# ── Info card ── #}
<div class="col-md-6">
<div class="card h-100">
<div class="card-header text-secondary small text-uppercase fw-normal" style="border-color:#21262d">Details</div>
<div class="card-body">
<table class="table table-dark table-sm mb-0" style="font-size:.875rem">
<tbody>
<tr><td class="text-secondary border-0" style="width:40%">Type</td>
<td class="border-0">{{ printer.printer_type.name if printer.printer_type else '—' }}</td></tr>
<tr><td class="text-secondary">Bed volume</td>
<td>{% 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 %}</td></tr>
<tr><td class="text-secondary">Location</td>
<td>{{ printer.location or '—' }}</td></tr>
</tbody>
</table>
</div>
</div>
</div>
{# ── Lifecycle card ── #}
<div class="col-md-6">
<div class="card h-100">
<div class="card-header text-secondary small text-uppercase fw-normal" style="border-color:#21262d">Lifecycle</div>
<div class="card-body">
<table class="table table-dark table-sm mb-0" style="font-size:.875rem">
<tbody>
<tr><td class="text-secondary border-0" style="width:50%">Purchased</td>
<td class="border-0">{{ printer.bought_at.strftime('%d.%m.%Y') if printer.bought_at else '—' }}</td></tr>
<tr><td class="text-secondary">Last maintenance</td>
<td>{{ printer.last_maintenance_at.strftime('%d.%m.%Y') if printer.last_maintenance_at else '—' }}</td></tr>
<tr><td class="text-secondary">Decommissioned</td>
<td>{{ printer.decommissioned_at.strftime('%d.%m.%Y') if printer.decommissioned_at else '—' }}</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
{# ── Log ── #}
<div class="d-flex align-items-center justify-content-between mb-3">
<h6 class="text-white mb-0">Log</h6>
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#addLogModal">
<i class="bi bi-plus-lg me-1"></i>Add entry
</button>
</div>
<div class="card">
{% if printer.logs %}
<table class="table table-dark table-hover mb-0 align-middle">
<tbody>
{% for entry in printer.logs %}
<tr>
<td class="text-secondary small" style="width:140px;white-space:nowrap">
{{ entry.created_at.strftime('%d.%m.%Y %H:%M') }}
</td>
<td>{{ entry.message }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="card-body text-secondary">No log entries yet.</div>
{% endif %}
</div>
{# ── Add log entry modal ── #}
<div class="modal fade" id="addLogModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content bg-dark border-secondary">
<form method="POST" action="/printers/{{ printer.id }}/log">
<div class="modal-header border-secondary">
<h6 class="modal-title text-white">Add Log Entry</h6>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<textarea name="message" class="form-control bg-dark border-secondary text-white"
rows="3" placeholder="What happened?" required autofocus></textarea>
</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</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -38,7 +38,7 @@
<tbody>
{% for p in printers %}
<tr>
<td class="fw-medium text-white">{{ p.name }}</td>
<td><a href="/printers/{{ p.id }}" class="text-white text-decoration-none fw-medium">{{ p.name }}</a></td>
<td class="text-secondary">{{ p.printer_type.name if p.printer_type else '—' }}</td>
<td class="text-secondary small">
{% if p.printer_type %}
@@ -96,6 +96,11 @@
placeholder="e.g. Workshop, Shelf 2">
</div>
<div>
<label class="form-label text-secondary small">Purchase date</label>
<input type="date" name="bought_at" class="form-control bg-dark border-secondary text-white">
</div>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>