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 ── #} +
+
+
Details
+
+ + + + + + + + + +
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 ── #} +
+
+
Lifecycle
+
+ + + + + + + + + +
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 %} + + + + + {% endfor %} + +
+ {{ entry.created_at.strftime('%d.%m.%Y %H:%M') }} + {{ entry.message }}
+ {% 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"> +
+ + +
+