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:
@@ -5,3 +5,4 @@ MYSQL_USER=
|
||||
MYSQL_PASSWORD=
|
||||
STL_UPLOAD_DIR=/data/stl
|
||||
TZ=Europe/Berlin
|
||||
FILAMENTDB_URL=https://web.filamentdb.orb.local
|
||||
|
||||
41
alembic/versions/0009_remove_filaments.py
Normal file
41
alembic/versions/0009_remove_filaments.py
Normal 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
66
app/filamentdb.py
Normal 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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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")
|
||||
@@ -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()"
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
151
app/templates/job_detail.html
Normal file
151
app/templates/job_detail.html
Normal 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 & 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user