Remove internal filament management, integrate FilamentDB, add job detail page

Filament removal:
- Deleted app/models/filament.py, routers/filaments.py, templates/filaments.html
- Migration 0009: drops filaments table and filament_id FK from print_jobs
- Removed Filaments from sidebar and all internal references

FilamentDB integration (read-only):
- app/filamentdb.py: GET /reports/stock with 5-min in-memory cache
- available_materials() / available_colors() populate job form dropdowns
- Falls back to static constants if FilamentDB unreachable
- Dashboard filament count now from FilamentDB rolls_in_stock
- FILAMENTDB_URL added to .env.install; httpx added to requirements

Job detail page (/jobs/{id}):
- Two cards: Job info (customer, material, printer, bracket, timing, cost)
  and File & Geometry (bounding box + volume from stl_cache)
- Log timeline with Add Entry modal
- Filename in jobs list links to detail page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Martin Hohenberg
2026-06-18 22:12:47 +02:00
parent 6868862d77
commit 1259a7294a
14 changed files with 294 additions and 138 deletions

View File

@@ -5,3 +5,4 @@ MYSQL_USER=
MYSQL_PASSWORD=
STL_UPLOAD_DIR=/data/stl
TZ=Europe/Berlin
FILAMENTDB_URL=https://web.filamentdb.orb.local

View File

@@ -0,0 +1,41 @@
"""remove internal filaments table — filament data now from FilamentDB
Revision ID: 0009
Revises: 0008
Create Date: 2026-06-18
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0009"
down_revision: Union[str, None] = "0008"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.drop_constraint("fk_job_filament", "print_jobs", type_="foreignkey")
op.drop_column("print_jobs", "filament_id")
op.drop_table("filaments")
def downgrade() -> None:
op.create_table(
"filaments",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("brand", sa.String(100), nullable=True),
sa.Column("material", sa.String(50), nullable=False, server_default="PLA"),
sa.Column("color", sa.String(100), nullable=True),
sa.Column("color_hex", sa.String(7), nullable=True),
sa.Column("weight_total_g", sa.Numeric(8, 2), nullable=False),
sa.Column("weight_remaining_g", sa.Numeric(8, 2), nullable=False),
sa.Column("price_per_kg_eur", sa.Numeric(8, 2), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")),
sa.PrimaryKeyConstraint("id"),
)
op.add_column("print_jobs", sa.Column("filament_id", sa.Integer(), nullable=True))
op.create_foreign_key("fk_job_filament", "print_jobs", "filaments", ["filament_id"], ["id"])

66
app/filamentdb.py Normal file
View File

@@ -0,0 +1,66 @@
import logging
import os
import time
import httpx
logger = logging.getLogger("pops")
_cache: dict[str, tuple[float, list]] = {}
_TTL = 300 # seconds
def _base() -> str:
return os.environ.get("FILAMENTDB_URL", "https://web.filamentdb.orb.local").rstrip("/")
def _get(path: str, params: dict | None = None) -> list | dict:
r = httpx.get(f"{_base()}{path}", params=params, timeout=5, verify=False)
r.raise_for_status()
return r.json()
def _cached(key: str, fetch):
now = time.monotonic()
if key in _cache and now - _cache[key][0] < _TTL:
return _cache[key][1]
try:
result = fetch()
_cache[key] = (now, result)
return result
except Exception:
logger.warning("FilamentDB unreachable — using cached/fallback data")
return _cache.get(key, (0, []))[1]
def get_stock() -> list[dict]:
"""All filament types with stock counts from /reports/stock."""
return _cached("stock", lambda: _get("/reports/stock"))
def available_materials() -> list[str]:
"""Unique materials that have at least one roll in stock."""
rows = get_stock()
seen, out = set(), []
for r in rows:
m = r.get("material", "")
if m and r.get("rolls_in_stock", 0) > 0 and m not in seen:
seen.add(m)
out.append(m)
return sorted(out)
def available_colors(material: str | None = None) -> list[str]:
"""Unique color names in stock, optionally filtered by material."""
rows = get_stock()
seen, out = set(), []
for r in rows:
if r.get("rolls_in_stock", 0) <= 0:
continue
if material and r.get("material") != material:
continue
c = r.get("color_name") or ""
if c and c not in seen:
seen.add(c)
out.append(c)
return sorted(out)

View File

@@ -5,7 +5,7 @@ from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from app.db import check_db, run_migrations
from app.routers import filaments, pricing, print_jobs, printer_types, printers, todos
from app.routers import pricing, print_jobs, printer_types, printers, todos
from app.routers import ui
logging.basicConfig(
@@ -41,7 +41,6 @@ async def unhandled_exception(request: Request, exc: Exception):
# JSON API
app.include_router(printer_types.router, prefix="/api")
app.include_router(printers.router, prefix="/api")
app.include_router(filaments.router, prefix="/api")
app.include_router(print_jobs.router, prefix="/api")
app.include_router(todos.router, prefix="/api")
app.include_router(pricing.router, prefix="/api")

View File

@@ -1,5 +1,4 @@
from app.models.base import Base
from app.models.filament import Filament
from app.models.job_bracket import JobBracket
from app.models.job_log import JobLog
from app.models.pricing import PricingConfig
@@ -10,4 +9,4 @@ from app.models.printer_type import PrinterType
from app.models.stl_cache import StlCache
from app.models.todo import Todo
__all__ = ["Base", "Printer", "PrinterLog", "PrinterType", "Filament", "JobBracket", "JobLog", "PrintJob", "StlCache", "Todo", "PricingConfig"]
__all__ = ["Base", "Printer", "PrinterLog", "PrinterType", "JobBracket", "JobLog", "PrintJob", "StlCache", "Todo", "PricingConfig"]

View File

@@ -1,38 +0,0 @@
import enum
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, Enum, Numeric, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
class Material(enum.Enum):
PLA = "PLA"
PETG = "PETG"
ABS = "ABS"
ASA = "ASA"
TPU = "TPU"
Nylon = "Nylon"
PC = "PC"
Other = "Other"
class Filament(Base):
__tablename__ = "filaments"
id: Mapped[int] = mapped_column(primary_key=True)
brand: Mapped[str | None] = mapped_column(String(100))
material: Mapped[Material] = mapped_column(Enum(Material), default=Material.PLA)
color: Mapped[str | None] = mapped_column(String(100))
color_hex: Mapped[str | None] = mapped_column(String(7))
weight_total_g: Mapped[Decimal] = mapped_column(Numeric(8, 2))
weight_remaining_g: Mapped[Decimal] = mapped_column(Numeric(8, 2))
price_per_kg_eur: Mapped[Decimal | None] = mapped_column(Numeric(8, 2))
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()
)
jobs: Mapped[list["PrintJob"]] = relationship(back_populates="filament")

View File

@@ -3,7 +3,7 @@ import uuid as _uuid
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, Numeric, String, Text, func
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
@@ -23,7 +23,6 @@ class PrintJob(Base):
id: Mapped[int] = mapped_column(primary_key=True)
job_uuid: Mapped[str | None] = mapped_column(String(36), unique=True, default=lambda: str(_uuid.uuid4()))
printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id"))
filament_id: Mapped[int | None] = mapped_column(ForeignKey("filaments.id"))
bracket_id: Mapped[int | None] = mapped_column(ForeignKey("job_brackets.id"))
name: Mapped[str] = mapped_column(String(200))
customer: Mapped[str | None] = mapped_column(String(200))
@@ -44,7 +43,6 @@ class PrintJob(Base):
)
printer: Mapped["Printer"] = relationship(back_populates="jobs")
filament: Mapped["Filament | None"] = relationship(back_populates="jobs")
bracket: Mapped["JobBracket | None"] = relationship(back_populates="jobs")
logs: Mapped[list["JobLog"]] = relationship(
back_populates="job", order_by="JobLog.created_at.desc()"

View File

@@ -1,21 +0,0 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.db import get_db
from app.models.filament import Filament
router = APIRouter(prefix="/filaments", tags=["filaments"])
@router.get("/")
def list_filaments(db: Session = Depends(get_db)):
return [f.to_dict() for f in db.query(Filament).all()]
@router.get("/{filament_id}")
def get_filament(filament_id: int, db: Session = Depends(get_db)):
filament = db.query(Filament).filter(Filament.id == filament_id).first()
if not filament:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Filament not found")
return filament.to_dict()

View File

@@ -9,8 +9,8 @@ from sqlalchemy.orm import Session
from starlette.status import HTTP_303_SEE_OTHER
from app.db import get_db
from app.models.filament import Filament
from app.models.job_bracket import JobBracket
from app.models.stl_cache import StlCache
from app.models.job_log import JobLog
from app.models.print_job import JobStatus, PrintJob
from app.models.printer import Printer, PrinterStatus
@@ -19,6 +19,7 @@ from app.models.printer_type import PrinterType
from app.models.pricing import PricingConfig
from app.models.todo import Todo
from app.constants import COLORS, MATERIALS
from app.filamentdb import available_colors, available_materials, get_stock
from app.stl import get_stl_dir, list_stl_files
from app.stl_analysis import analyse, get_cache_map
@@ -40,7 +41,7 @@ def dashboard(request: Request, db: Session = Depends(get_db)):
"active_jobs": db.query(PrintJob).filter(
PrintJob.status.in_([JobStatus.queued, JobStatus.printing])
).count(),
"total_filaments": db.query(Filament).count(),
"total_filaments": sum(r.get("rolls_in_stock", 0) for r in get_stock()),
"recent_jobs": db.query(PrintJob).order_by(PrintJob.created_at.desc()).limit(5).all(),
})
@@ -141,8 +142,8 @@ def jobs_page(request: Request, q: str = "", db: Session = Depends(get_db)):
"brackets": db.query(JobBracket).order_by(JobBracket.name).all(),
"stl_files": (stl_files := list_stl_files()),
"stl_cache": get_cache_map(stl_files, db),
"materials": MATERIALS,
"colors": COLORS,
"materials": available_materials() or MATERIALS,
"colors": available_colors() or COLORS,
})
@@ -205,6 +206,28 @@ async def create_job(
return RedirectResponse(url="/jobs", status_code=HTTP_303_SEE_OTHER)
@router.get("/jobs/{job_id}", response_class=HTMLResponse)
def job_detail(job_id: int, request: Request, db: Session = Depends(get_db)):
from fastapi import HTTPException
job = db.query(PrintJob).filter(PrintJob.id == job_id).first()
if not job:
raise HTTPException(status_code=404, detail="Job not found")
stl = db.query(StlCache).filter_by(filename=job.file_name).first() if job.file_name else None
return templates.TemplateResponse("job_detail.html", {
"request": request,
"active": "jobs",
"job": job,
"stl": stl,
})
@router.post("/jobs/{job_id}/log")
def add_job_log(job_id: int, message: str = Form(...), db: Session = Depends(get_db)):
db.add(JobLog(job_id=job_id, message=message.strip()))
db.commit()
return RedirectResponse(url=f"/jobs/{job_id}", status_code=HTTP_303_SEE_OTHER)
# ── Brackets ───────────────────────────────────────────────────────────────────
@router.get("/brackets", response_class=HTMLResponse)
@@ -241,8 +264,8 @@ def bracket_detail(bracket_id: int, request: Request, db: Session = Depends(get_
"filaments": db.query(Filament).order_by(Filament.material, Filament.color).all(),
"stl_files": (stl_files := list_stl_files()),
"stl_cache": get_cache_map(stl_files, db),
"materials": MATERIALS,
"colors": COLORS,
"materials": available_materials() or MATERIALS,
"colors": available_colors() or COLORS,
})
@@ -283,17 +306,6 @@ async def add_jobs_to_bracket(
return RedirectResponse(url=f"/brackets/{bracket_id}", status_code=HTTP_303_SEE_OTHER)
# ── Filaments ──────────────────────────────────────────────────────────────────
@router.get("/filaments", response_class=HTMLResponse)
def filaments_page(request: Request, db: Session = Depends(get_db)):
return templates.TemplateResponse("filaments.html", {
"request": request,
"active": "filaments",
"filaments": db.query(Filament).order_by(Filament.brand, Filament.material).all(),
})
# ── To-Do ──────────────────────────────────────────────────────────────────────
@router.get("/todos", response_class=HTMLResponse)

View File

@@ -50,8 +50,7 @@
<li><a href="/printers" class="nav-link {% if active == 'printers' %}active{% endif %}"><i class="bi bi-printer me-2"></i>Printers</a></li>
<li><a href="/jobs" class="nav-link {% if active == 'jobs' %}active{% endif %}"><i class="bi bi-list-task me-2"></i>Jobs</a></li>
<li><a href="/brackets" class="nav-link {% if active == 'brackets' %}active{% endif %}"><i class="bi bi-collection me-2"></i>Brackets</a></li>
<li><a href="/filaments" class="nav-link {% if active == 'filaments' %}active{% endif %}"><i class="bi bi-record-circle me-2"></i>Filaments</a></li>
<li><a href="/todos" class="nav-link {% if active == 'todos' %}active{% endif %}"><i class="bi bi-check2-square me-2"></i>To-Do</a></li>
<li><a href="/todos" class="nav-link {% if active == 'todos' %}active{% endif %}"><i class="bi bi-check2-square me-2"></i>To-Do</a></li>
<li><a href="/pricing" class="nav-link {% if active == 'pricing' %}active{% endif %}"><i class="bi bi-currency-euro me-2"></i>Pricing</a></li>
</ul>
</nav>

View File

@@ -1,52 +0,0 @@
{% extends "base.html" %}
{% block title %}Filaments — POPS{% endblock %}
{% block content %}
<h5 class="text-white mb-4">Filament Inventory</h5>
<div class="card">
{% if filaments %}
<table class="table table-dark table-hover mb-0 align-middle">
<thead>
<tr class="text-secondary small">
<th class="fw-normal">Brand</th>
<th class="fw-normal">Material</th>
<th class="fw-normal">Color</th>
<th class="fw-normal">Remaining</th>
<th class="fw-normal text-end">Price / kg</th>
</tr>
</thead>
<tbody>
{% for f in filaments %}
{% set pct = (f.weight_remaining_g / f.weight_total_g * 100) | int if f.weight_total_g else 0 %}
<tr>
<td class="fw-medium text-white">{{ f.brand or '—' }}</td>
<td><span class="badge bg-secondary">{{ f.material.value }}</span></td>
<td>
<div class="d-flex align-items-center gap-2">
{% if f.color_hex %}
<span style="display:inline-block;width:12px;height:12px;border-radius:3px;background:{{ f.color_hex }};border:1px solid #444"></span>
{% endif %}
{{ f.color or '—' }}
</div>
</td>
<td style="min-width:160px">
<div class="d-flex align-items-center gap-2">
<div class="progress flex-grow-1" style="height:6px">
<div class="progress-bar {% if pct < 20 %}bg-danger{% elif pct < 50 %}bg-warning{% else %}bg-success{% endif %}"
style="width:{{ pct }}%"></div>
</div>
<span class="text-secondary small" style="min-width:70px">{{ f.weight_remaining_g | int }}g / {{ f.weight_total_g | int }}g</span>
</div>
</td>
<td class="text-secondary small text-end">
{% if f.price_per_kg_eur %}€ {{ "%.2f"|format(f.price_per_kg_eur) }}{% else %}—{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="card-body text-secondary">No filaments in inventory yet.</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,151 @@
{% extends "base.html" %}
{% block title %}Job {{ job.job_uuid[:8] if job.job_uuid else job.id }} — POPS{% endblock %}
{% block content %}
<div class="d-flex align-items-center gap-3 mb-4">
<a href="{{ '/brackets/' ~ job.bracket.id if job.bracket else '/jobs' }}" class="text-secondary text-decoration-none">
<i class="bi bi-arrow-left"></i>
</a>
<div class="flex-grow-1">
<div class="d-flex align-items-center gap-2">
<h5 class="text-white mb-0">{{ job.file_name or job.name }}</h5>
{% set s = job.status.value %}
{% if s == 'printing' %}<span class="badge bg-primary">printing</span>
{% elif s == 'done' %}<span class="badge bg-success">done</span>
{% elif s == 'failed' %}<span class="badge bg-danger">failed</span>
{% elif s == 'cancelled' %}<span class="badge bg-warning text-dark">cancelled</span>
{% else %}<span class="badge bg-secondary">queued</span>
{% endif %}
</div>
<div class="text-secondary small font-monospace mt-1">{{ job.job_uuid or '—' }}</div>
</div>
</div>
<div class="row g-3 mb-4">
{# ── Job info ── #}
<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">Job</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%">Customer</td>
<td class="border-0">{{ job.customer or '—' }}</td></tr>
<tr><td class="text-secondary">Material</td>
<td>{{ job.job_material or '—' }}{% if job.job_color %} · {{ job.job_color }}{% endif %}</td></tr>
<tr><td class="text-secondary">Printer</td>
<td>{{ job.printer.name if job.printer else '—' }}</td></tr>
<tr><td class="text-secondary">Bracket</td>
<td>{% if job.bracket %}<a href="/brackets/{{ job.bracket.id }}" class="text-white">{{ job.bracket.name }}</a>{% else %}—{% endif %}</td></tr>
<tr><td class="text-secondary">Created</td>
<td>{{ job.created_at.strftime('%d.%m.%Y %H:%M') }}</td></tr>
{% if job.started_at %}
<tr><td class="text-secondary">Started</td>
<td>{{ job.started_at.strftime('%d.%m.%Y %H:%M') }}</td></tr>
{% endif %}
{% if job.finished_at %}
<tr><td class="text-secondary">Finished</td>
<td>{{ job.finished_at.strftime('%d.%m.%Y %H:%M') }}</td></tr>
{% endif %}
{% if job.duration_minutes %}
<tr><td class="text-secondary">Duration</td>
<td>{{ job.duration_minutes }} min</td></tr>
{% endif %}
{% if job.cost_eur %}
<tr><td class="text-secondary">Cost</td>
<td>€ {{ "%.2f"|format(job.cost_eur) }}</td></tr>
{% endif %}
{% if job.notes %}
<tr><td class="text-secondary">Notes</td>
<td>{{ job.notes }}</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
{# ── STL geometry ── #}
<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">
File &amp; Geometry
</div>
<div class="card-body">
{% if job.file_name %}
<div class="text-white mb-3 font-monospace" style="font-size:.85rem">{{ job.file_name }}</div>
{% endif %}
{% if stl %}
<table class="table table-dark table-sm mb-0" style="font-size:.875rem">
<tbody>
<tr><td class="text-secondary border-0">Bounding box</td>
<td class="border-0">
{{ stl.cube_x|round(1) }} × {{ stl.cube_y|round(1) }} × {{ stl.cube_z|round(1) }} mm
</td></tr>
<tr><td class="text-secondary">Volume</td>
<td>{{ stl.volume_ccm }} cm³</td></tr>
</tbody>
</table>
{% elif job.file_name and job.file_name.endswith('.gcode') %}
<p class="text-secondary small mb-0">Geometry not available for gcode files.</p>
{% elif job.file_name %}
<p class="text-secondary small mb-0">Not yet analysed — will be computed on next upload.</p>
{% else %}
<p class="text-secondary small mb-0">No file attached.</p>
{% endif %}
</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 job.logs %}
<table class="table table-dark table-hover mb-0 align-middle">
<tbody>
{% for entry in job.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.</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="/jobs/{{ job.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

@@ -46,7 +46,7 @@
{{ j.job_uuid[:8] if j.job_uuid else '—' }}
</td>
<td class="text-white">{{ j.customer or '—' }}</td>
<td class="text-secondary">{{ j.file_name or '—' }}</td>
<td><a href="/jobs/{{ j.id }}" class="text-secondary text-decoration-none">{{ j.file_name or j.name or '—' }}</a></td>
<td class="text-secondary">{{ j.printer.name if j.printer else '—' }}</td>
<td class="text-secondary">
{% if j.job_material %}{{ j.job_material }}{% if j.job_color %} · {{ j.job_color }}{% endif %}{% else %}—{% endif %}

View File

@@ -7,3 +7,4 @@ cryptography==44.0.3
jinja2==3.1.4
python-multipart==0.0.20
trimesh==4.5.3
httpx==0.28.1