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:
39
alembic/versions/0003_printer_lifecycle_and_logs.py
Normal file
39
alembic/versions/0003_printer_lifecycle_and_logs.py
Normal 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")
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
17
app/models/printer_log.py
Normal 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")
|
||||
@@ -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", {
|
||||
|
||||
109
app/templates/printer_detail.html
Normal file
109
app/templates/printer_detail.html
Normal 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 %}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user