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.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_log import PrinterLog
|
||||||
from app.models.printer_type import PrinterType
|
from app.models.printer_type import PrinterType
|
||||||
from app.models.todo import Todo
|
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
|
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 sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.models.base import Base
|
from app.models.base import Base
|
||||||
@@ -24,6 +24,9 @@ class Printer(Base):
|
|||||||
status: Mapped[PrinterStatus] = mapped_column(
|
status: Mapped[PrinterStatus] = mapped_column(
|
||||||
Enum(PrinterStatus), default=PrinterStatus.offline
|
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())
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime, server_default=func.now(), onupdate=func.now()
|
DateTime, server_default=func.now(), onupdate=func.now()
|
||||||
@@ -32,3 +35,6 @@ class Printer(Base):
|
|||||||
printer_type: Mapped["PrinterType | None"] = relationship(back_populates="printers")
|
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")
|
||||||
|
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.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_log import PrinterLog
|
||||||
from app.models.printer_type import PrinterType
|
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
|
||||||
@@ -48,18 +49,42 @@ def create_printer(
|
|||||||
name: str = Form(...),
|
name: str = Form(...),
|
||||||
location: str = Form(""),
|
location: str = Form(""),
|
||||||
type_id: str = Form(""),
|
type_id: str = Form(""),
|
||||||
|
bought_at: str = Form(""),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
from datetime import date
|
||||||
printer = Printer(
|
printer = Printer(
|
||||||
name=name.strip(),
|
name=name.strip(),
|
||||||
location=location.strip() or None,
|
location=location.strip() or None,
|
||||||
type_id=int(type_id) if type_id else 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.add(printer)
|
||||||
db.commit()
|
db.commit()
|
||||||
return RedirectResponse(url="/printers", status_code=HTTP_303_SEE_OTHER)
|
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)
|
@router.get("/printer-types", response_class=HTMLResponse)
|
||||||
def printer_types_page(request: Request, db: Session = Depends(get_db)):
|
def printer_types_page(request: Request, db: Session = Depends(get_db)):
|
||||||
return templates.TemplateResponse("printer_types.html", {
|
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>
|
<tbody>
|
||||||
{% for p in printers %}
|
{% for p in printers %}
|
||||||
<tr>
|
<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">{{ p.printer_type.name if p.printer_type else '—' }}</td>
|
||||||
<td class="text-secondary small">
|
<td class="text-secondary small">
|
||||||
{% if p.printer_type %}
|
{% if p.printer_type %}
|
||||||
@@ -96,6 +96,11 @@
|
|||||||
placeholder="e.g. Workshop, Shelf 2">
|
placeholder="e.g. Workshop, Shelf 2">
|
||||||
</div>
|
</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>
|
||||||
<div class="modal-footer border-secondary">
|
<div class="modal-footer border-secondary">
|
||||||
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user