Add job creation with UUID, STL upload/library, search, job log, TZ, and CLAUDE.md

- PrintJob: job_uuid (UUID4), customer, logs relationship
- JobLog model + migration 0004 (also includes job_logs table)
- POST /jobs: upload STL or select from library, auto-logs 'Job created'
- GET /jobs?q=: search by customer or filename across all statuses
- app/stl.py: STL_UPLOAD_DIR helper (from env, default /data/stl)
- docker-compose: named volume stl_files mounted at /data/stl
- .env.install: added STL_UPLOAD_DIR and TZ=Europe/Berlin
- CLAUDE.md: full project context for future sessions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Martin Hohenberg
2026-06-18 21:55:49 +02:00
parent c78f8c91cd
commit fe594e8675
10 changed files with 386 additions and 27 deletions

View File

@@ -3,3 +3,5 @@ MYSQL_PORT=
MYSQL_DATABASE= MYSQL_DATABASE=
MYSQL_USER= MYSQL_USER=
MYSQL_PASSWORD= MYSQL_PASSWORD=
STL_UPLOAD_DIR=/data/stl
TZ=Europe/Berlin

90
CLAUDE.md Normal file
View File

@@ -0,0 +1,90 @@
# POPS — Claude Code context
## What this project is
Web-based production management system for a 3D print farm.
FastAPI backend, Jinja2 server-rendered UI, MySQL 8 (external server), Docker deployment.
## Running locally
```bash
docker compose up --build # starts on http://localhost:8720
```
Alembic migrations run automatically at startup (stamps if tables exist, upgrades if not).
## Environment
Copy `.env.install``.env` and fill in values.
Key variables:
| Variable | Purpose |
|---|---|
| `MYSQL_*` | External MySQL 8 connection |
| `STL_UPLOAD_DIR` | Where STL/3MF/gcode files are stored (default `/data/stl`) |
| `TZ` | Timezone for all timestamps (e.g. `Europe/Berlin`) |
The `docker-compose.yml` mounts a named volume `stl_files` at `/data/stl`.
## Database
- External MySQL 8 server — user needs `ALL PRIVILEGES` on the database (Alembic requires DDL).
- `create_db.sql` — run once as root to create the DB and user.
- `db.sql` — raw schema reference (not used for migrations, kept for reference).
- Migrations live in `alembic/versions/`, numbered `0001_…`, `0002_…` etc.
To add a migration:
```bash
docker compose run --rm app alembic revision --autogenerate -m "describe change"
# then review and commit the generated file
```
## Project structure
```
app/
main.py — FastAPI app, lifespan (DB check + auto-migration)
db.py — SQLAlchemy engine, SessionLocal, get_db, run_migrations
stl.py — STL file directory helpers (get_stl_dir, list_stl_files)
models/ — SQLAlchemy 2.x declarative models (one file per table)
routers/
ui.py — All HTML routes (GET + form POST → redirect)
printers.py — JSON API: /api/printers
printer_types.py
filaments.py
print_jobs.py
todos.py
pricing.py
templates/ — Jinja2 templates extending base.html
alembic/
env.py — reads MYSQL_* env vars at runtime
versions/ — numbered migration files
```
## Conventions
- **API routes** live under `/api/*` (JSON, for programmatic access).
- **UI routes** live at root (`/`, `/printers`, `/jobs`, …) in `app/routers/ui.py`.
- Form POSTs redirect with HTTP 303 — no JavaScript needed for basic operations.
- All models have a `to_dict()` method from `Base` for JSON serialization in API routes.
- Timestamps use the container's `TZ` env var — keep it consistent with MySQL server timezone.
- `job_uuid` (UUID4) is generated in Python on job creation, not by MySQL.
- Job creation always writes a "Job created" entry to `job_logs`.
- Printer log and job log both use `ondelete="CASCADE"` — deleting a printer/job cleans up logs.
## Models at a glance
| Model | Table | Notes |
|---|---|---|
| `PrinterType` | `printer_types` | name + bed X/Y/Z mm |
| `Printer` | `printers` | FK → printer_type; lifecycle dates; logs |
| `PrinterLog` | `printer_logs` | timestamped text per printer |
| `Filament` | `filaments` | material enum + color + weight + price |
| `PrintJob` | `print_jobs` | UUID, customer, STL filename, FK → printer + filament |
| `JobLog` | `job_logs` | timestamped text per job (auto-entry on creation) |
| `Todo` | `todos` | optional FK → printer |
| `PricingConfig` | `pricing_config` | electricity rate, markup %, etc. |
## UI pages
| URL | Description |
|---|---|
| `/` | Dashboard — stats cards + printer overview + recent jobs |
| `/printers` | Printer list + Add Printer modal |
| `/printers/{id}` | Printer detail — lifecycle info + log |
| `/printer-types` | Manage printer types (name + bed volume) |
| `/jobs` | Job list with search (customer / filename) + Add Job modal |
| `/filaments` | Filament inventory with progress bars |
| `/todos` | To-do list |
| `/pricing` | Pricing config cards |

View File

@@ -0,0 +1,39 @@
"""add uuid, customer to print_jobs and job_logs table
Revision ID: 0004
Revises: 0003
Create Date: 2026-06-18
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0004"
down_revision: Union[str, None] = "0003"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("print_jobs", sa.Column("job_uuid", sa.String(36), nullable=True))
op.add_column("print_jobs", sa.Column("customer", sa.String(200), nullable=True))
op.create_unique_constraint("uq_print_job_uuid", "print_jobs", ["job_uuid"])
op.create_table(
"job_logs",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("job_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(["job_id"], ["print_jobs.id"], name="fk_joblog_job", ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
def downgrade() -> None:
op.drop_table("job_logs")
op.drop_constraint("uq_print_job_uuid", "print_jobs", type_="unique")
op.drop_column("print_jobs", "customer")
op.drop_column("print_jobs", "job_uuid")

View File

@@ -1,5 +1,6 @@
from app.models.base import Base from app.models.base import Base
from app.models.filament import Filament from app.models.filament import Filament
from app.models.job_log import JobLog
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
@@ -7,4 +8,4 @@ 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", "PrinterLog", "PrinterType", "Filament", "PrintJob", "Todo", "PricingConfig"] __all__ = ["Base", "Printer", "PrinterLog", "PrinterType", "Filament", "JobLog", "PrintJob", "Todo", "PricingConfig"]

17
app/models/job_log.py Normal file
View 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 JobLog(Base):
__tablename__ = "job_logs"
id: Mapped[int] = mapped_column(primary_key=True)
job_id: Mapped[int] = mapped_column(ForeignKey("print_jobs.id"))
message: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
job: Mapped["PrintJob"] = relationship(back_populates="logs")

View File

@@ -1,4 +1,5 @@
import enum import enum
import uuid as _uuid
from datetime import datetime from datetime import datetime
from decimal import Decimal from decimal import Decimal
@@ -20,9 +21,11 @@ class PrintJob(Base):
__tablename__ = "print_jobs" __tablename__ = "print_jobs"
id: Mapped[int] = mapped_column(primary_key=True) 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] = mapped_column(ForeignKey("printers.id")) printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id"))
filament_id: Mapped[int | None] = mapped_column(ForeignKey("filaments.id")) filament_id: Mapped[int | None] = mapped_column(ForeignKey("filaments.id"))
name: Mapped[str] = mapped_column(String(200)) name: Mapped[str] = mapped_column(String(200))
customer: Mapped[str | None] = mapped_column(String(200))
file_name: Mapped[str | None] = mapped_column(String(255)) file_name: Mapped[str | None] = mapped_column(String(255))
status: Mapped[JobStatus] = mapped_column(Enum(JobStatus), default=JobStatus.queued) status: Mapped[JobStatus] = mapped_column(Enum(JobStatus), default=JobStatus.queued)
queue_position: Mapped[int | None] = mapped_column(Integer) queue_position: Mapped[int | None] = mapped_column(Integer)
@@ -39,3 +42,6 @@ class PrintJob(Base):
printer: Mapped["Printer"] = relationship(back_populates="jobs") printer: Mapped["Printer"] = relationship(back_populates="jobs")
filament: Mapped["Filament | None"] = relationship(back_populates="jobs") filament: Mapped["Filament | None"] = relationship(back_populates="jobs")
logs: Mapped[list["JobLog"]] = relationship(
back_populates="job", order_by="JobLog.created_at.desc()"
)

View File

@@ -1,22 +1,30 @@
from fastapi import APIRouter, Depends, Form, Request import uuid as _uuid
from pathlib import Path
from fastapi import APIRouter, Depends, File, Form, Request, UploadFile
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlalchemy import or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from starlette.status import HTTP_303_SEE_OTHER from starlette.status import HTTP_303_SEE_OTHER
from app.db import get_db from app.db import get_db
from app.models.filament import Filament from app.models.filament import Filament
from app.models.job_log import JobLog
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_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
from app.stl import get_stl_dir, list_stl_files
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="app/templates") templates = Jinja2Templates(directory="app/templates")
# ── Dashboard ──────────────────────────────────────────────────────────────────
@router.get("/", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse)
def dashboard(request: Request, db: Session = Depends(get_db)): def dashboard(request: Request, db: Session = Depends(get_db)):
printers = db.query(Printer).order_by(Printer.name).all() printers = db.query(Printer).order_by(Printer.name).all()
@@ -34,6 +42,8 @@ def dashboard(request: Request, db: Session = Depends(get_db)):
}) })
# ── Printers ───────────────────────────────────────────────────────────────────
@router.get("/printers", response_class=HTMLResponse) @router.get("/printers", response_class=HTMLResponse)
def printers_page(request: Request, db: Session = Depends(get_db)): def printers_page(request: Request, db: Session = Depends(get_db)):
return templates.TemplateResponse("printers.html", { return templates.TemplateResponse("printers.html", {
@@ -66,9 +76,9 @@ def create_printer(
@router.get("/printers/{printer_id}", response_class=HTMLResponse) @router.get("/printers/{printer_id}", response_class=HTMLResponse)
def printer_detail(printer_id: int, request: Request, db: Session = Depends(get_db)): def printer_detail(printer_id: int, request: Request, db: Session = Depends(get_db)):
from fastapi import HTTPException
printer = db.query(Printer).filter(Printer.id == printer_id).first() printer = db.query(Printer).filter(Printer.id == printer_id).first()
if not printer: if not printer:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Printer not found") raise HTTPException(status_code=404, detail="Printer not found")
return templates.TemplateResponse("printer_detail.html", { return templates.TemplateResponse("printer_detail.html", {
"request": request, "request": request,
@@ -79,12 +89,13 @@ def printer_detail(printer_id: int, request: Request, db: Session = Depends(get_
@router.post("/printers/{printer_id}/log") @router.post("/printers/{printer_id}/log")
def add_printer_log(printer_id: int, message: str = Form(...), db: Session = Depends(get_db)): 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(PrinterLog(printer_id=printer_id, message=message.strip()))
db.add(entry)
db.commit() db.commit()
return RedirectResponse(url=f"/printers/{printer_id}", status_code=HTTP_303_SEE_OTHER) return RedirectResponse(url=f"/printers/{printer_id}", status_code=HTTP_303_SEE_OTHER)
# ── Printer types ──────────────────────────────────────────────────────────────
@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", {
@@ -102,21 +113,67 @@ def create_printer_type(
bed_z_mm: int = Form(...), bed_z_mm: int = Form(...),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
pt = PrinterType(name=name.strip(), bed_x_mm=bed_x_mm, bed_y_mm=bed_y_mm, bed_z_mm=bed_z_mm) db.add(PrinterType(name=name.strip(), bed_x_mm=bed_x_mm, bed_y_mm=bed_y_mm, bed_z_mm=bed_z_mm))
db.add(pt)
db.commit() db.commit()
return RedirectResponse(url="/printer-types", status_code=HTTP_303_SEE_OTHER) return RedirectResponse(url="/printer-types", status_code=HTTP_303_SEE_OTHER)
# ── Jobs ───────────────────────────────────────────────────────────────────────
@router.get("/jobs", response_class=HTMLResponse) @router.get("/jobs", response_class=HTMLResponse)
def jobs_page(request: Request, db: Session = Depends(get_db)): def jobs_page(request: Request, q: str = "", db: Session = Depends(get_db)):
query = db.query(PrintJob)
if q:
like = f"%{q}%"
query = query.filter(
or_(PrintJob.customer.ilike(like), PrintJob.file_name.ilike(like))
)
return templates.TemplateResponse("jobs.html", { return templates.TemplateResponse("jobs.html", {
"request": request, "request": request,
"active": "jobs", "active": "jobs",
"jobs": db.query(PrintJob).order_by(PrintJob.created_at.desc()).all(), "jobs": query.order_by(PrintJob.created_at.desc()).all(),
"q": q,
"printers": db.query(Printer).order_by(Printer.name).all(),
"filaments": db.query(Filament).order_by(Filament.material, Filament.color).all(),
"stl_files": list_stl_files(),
}) })
@router.post("/jobs")
async def create_job(
printer_id: int = Form(...),
customer: str = Form(""),
filament_id: str = Form(""),
notes: str = Form(""),
stl_file: UploadFile = File(None),
stl_select: str = Form(""),
db: Session = Depends(get_db),
):
filename = None
if stl_file and stl_file.filename:
filename = stl_file.filename
(get_stl_dir() / filename).write_bytes(await stl_file.read())
elif stl_select:
filename = stl_select
job = PrintJob(
job_uuid=str(_uuid.uuid4()),
name=Path(filename).stem if filename else (customer.strip() or "Unnamed"),
file_name=filename,
printer_id=printer_id,
filament_id=int(filament_id) if filament_id else None,
customer=customer.strip() or None,
notes=notes.strip() or None,
)
db.add(job)
db.flush()
db.add(JobLog(job_id=job.id, message="Job created"))
db.commit()
return RedirectResponse(url="/jobs", status_code=HTTP_303_SEE_OTHER)
# ── Filaments ──────────────────────────────────────────────────────────────────
@router.get("/filaments", response_class=HTMLResponse) @router.get("/filaments", response_class=HTMLResponse)
def filaments_page(request: Request, db: Session = Depends(get_db)): def filaments_page(request: Request, db: Session = Depends(get_db)):
return templates.TemplateResponse("filaments.html", { return templates.TemplateResponse("filaments.html", {
@@ -126,6 +183,8 @@ def filaments_page(request: Request, db: Session = Depends(get_db)):
}) })
# ── To-Do ──────────────────────────────────────────────────────────────────────
@router.get("/todos", response_class=HTMLResponse) @router.get("/todos", response_class=HTMLResponse)
def todos_page(request: Request, db: Session = Depends(get_db)): def todos_page(request: Request, db: Session = Depends(get_db)):
return templates.TemplateResponse("todos.html", { return templates.TemplateResponse("todos.html", {
@@ -135,6 +194,8 @@ def todos_page(request: Request, db: Session = Depends(get_db)):
}) })
# ── Pricing ────────────────────────────────────────────────────────────────────
@router.get("/pricing", response_class=HTMLResponse) @router.get("/pricing", response_class=HTMLResponse)
def pricing_page(request: Request, db: Session = Depends(get_db)): def pricing_page(request: Request, db: Session = Depends(get_db)):
return templates.TemplateResponse("pricing.html", { return templates.TemplateResponse("pricing.html", {

20
app/stl.py Normal file
View File

@@ -0,0 +1,20 @@
import os
from pathlib import Path
_SUPPORTED = {".stl", ".3mf", ".gcode"}
def get_stl_dir() -> Path:
d = Path(os.environ.get("STL_UPLOAD_DIR", "/data/stl"))
d.mkdir(parents=True, exist_ok=True)
return d
def list_stl_files() -> list[str]:
try:
return sorted(
f.name for f in get_stl_dir().iterdir()
if f.suffix.lower() in _SUPPORTED
)
except FileNotFoundError:
return []

View File

@@ -2,28 +2,54 @@
{% block title %}Jobs — POPS{% endblock %} {% block title %}Jobs — POPS{% endblock %}
{% block content %} {% block content %}
<h5 class="text-white mb-4">Print Jobs</h5> <div class="d-flex align-items-center justify-content-between mb-4">
<h5 class="text-white mb-0">Print Jobs</h5>
<button class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#addJobModal">
<i class="bi bi-plus-lg me-1"></i>Add Job
</button>
</div>
{# ── Search bar ── #}
<form method="GET" action="/jobs" class="mb-3">
<div class="input-group input-group-sm" style="max-width:360px">
<span class="input-group-text bg-dark border-secondary text-secondary"><i class="bi bi-search"></i></span>
<input type="text" name="q" value="{{ q }}"
class="form-control bg-dark border-secondary text-white"
placeholder="Search by customer or filename…">
{% if q %}
<a href="/jobs" class="btn btn-outline-secondary">×</a>
{% endif %}
</div>
</form>
{% if q %}
<p class="text-secondary small mb-3">Results for <strong class="text-white">{{ q }}</strong></p>
{% endif %}
<div class="card"> <div class="card">
{% if jobs %} {% if jobs %}
<table class="table table-dark table-hover mb-0 align-middle"> <table class="table table-dark table-hover mb-0 align-middle" style="font-size:.875rem">
<thead> <thead>
<tr class="text-secondary small"> <tr class="text-secondary small">
<th class="fw-normal">Name</th> <th class="fw-normal">UUID</th>
<th class="fw-normal">Customer</th>
<th class="fw-normal">File</th>
<th class="fw-normal">Printer</th> <th class="fw-normal">Printer</th>
<th class="fw-normal">Filament</th> <th class="fw-normal">Material</th>
<th class="fw-normal">Status</th> <th class="fw-normal">Status</th>
<th class="fw-normal">Duration</th>
<th class="fw-normal text-end">Cost</th>
<th class="fw-normal text-end">Created</th> <th class="fw-normal text-end">Created</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for j in jobs %} {% for j in jobs %}
<tr> <tr>
<td class="fw-medium text-white">{{ j.name }}</td> <td class="font-monospace text-secondary" style="font-size:.75rem">
<td class="text-secondary small">{{ j.printer.name if j.printer else '—' }}</td> {{ j.job_uuid[:8] if j.job_uuid else '—' }}
<td class="text-secondary small"> </td>
{% if j.filament %}{{ j.filament.material.value }} {{ j.filament.color or '' }}{% else %}—{% endif %} <td class="text-white">{{ j.customer or '' }}</td>
<td class="text-secondary">{{ j.file_name or '—' }}</td>
<td class="text-secondary">{{ j.printer.name if j.printer else '—' }}</td>
<td class="text-secondary">
{% if j.filament %}{{ j.filament.material.value }}{% if j.filament.color %} · {{ j.filament.color }}{% endif %}{% else %}—{% endif %}
</td> </td>
<td> <td>
{% set s = j.status.value %} {% set s = j.status.value %}
@@ -34,19 +60,111 @@
{% else %}<span class="badge bg-secondary">queued</span> {% else %}<span class="badge bg-secondary">queued</span>
{% endif %} {% endif %}
</td> </td>
<td class="text-secondary small"> <td class="text-secondary text-end">{{ j.created_at.strftime('%d.%m.%Y %H:%M') }}</td>
{% if j.duration_minutes %}{{ j.duration_minutes }} min{% else %}—{% endif %}
</td>
<td class="text-secondary small text-end">
{% if j.cost_eur %}€ {{ "%.2f"|format(j.cost_eur) }}{% else %}—{% endif %}
</td>
<td class="text-secondary small text-end">{{ j.created_at.strftime('%d.%m %H:%M') }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% else %} {% else %}
<div class="card-body text-secondary">No jobs yet.</div> <div class="card-body text-secondary">
{% if q %}No jobs match "{{ q }}".{% else %}No jobs yet.{% endif %}
</div>
{% endif %} {% endif %}
</div> </div>
{# ── Add Job Modal ── #}
<div class="modal fade" id="addJobModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content bg-dark border-secondary">
<form method="POST" action="/jobs" enctype="multipart/form-data">
<div class="modal-header border-secondary">
<h6 class="modal-title text-white">Add Print Job</h6>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body d-flex flex-column gap-3">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label text-secondary small">Printer <span class="text-danger">*</span></label>
<select name="printer_id" class="form-select bg-dark border-secondary text-white" required>
<option value="">— select —</option>
{% for p in printers %}
<option value="{{ p.id }}">{{ p.name }}{% if p.location %} ({{ p.location }}){% endif %}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label text-secondary small">Customer</label>
<input type="text" name="customer" class="form-control bg-dark border-secondary text-white"
placeholder="Customer name or reference">
</div>
</div>
{# ── STL file ── #}
<div>
<label class="form-label text-secondary small">STL / File</label>
<ul class="nav nav-pills mb-2" id="stlTabs" role="tablist">
<li class="nav-item">
<button class="nav-link active py-1 px-3" style="font-size:.8rem"
data-bs-toggle="pill" data-bs-target="#uploadPane" type="button">
<i class="bi bi-upload me-1"></i>Upload
</button>
</li>
<li class="nav-item">
<button class="nav-link py-1 px-3" style="font-size:.8rem"
data-bs-toggle="pill" data-bs-target="#libraryPane" type="button">
<i class="bi bi-folder2-open me-1"></i>Library
<span class="badge bg-secondary ms-1">{{ stl_files | length }}</span>
</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="uploadPane">
<input type="file" name="stl_file"
class="form-control bg-dark border-secondary text-white"
accept=".stl,.3mf,.gcode">
</div>
<div class="tab-pane fade" id="libraryPane">
{% if stl_files %}
<select name="stl_select" class="form-select bg-dark border-secondary text-white">
<option value="">— select file —</option>
{% for f in stl_files %}
<option value="{{ f }}">{{ f }}</option>
{% endfor %}
</select>
{% else %}
<p class="text-secondary small mb-0">No files in library yet. Upload one first.</p>
{% endif %}
</div>
</div>
</div>
<div>
<label class="form-label text-secondary small">Material & Color</label>
<select name="filament_id" class="form-select bg-dark border-secondary text-white">
<option value="">— none —</option>
{% for f in filaments %}
<option value="{{ f.id }}">
{{ f.material.value }}{% if f.color %} · {{ f.color }}{% endif %}
{% if f.brand %} ({{ f.brand }}){% endif %}
</option>
{% endfor %}
</select>
</div>
<div>
<label class="form-label text-secondary small">Notes</label>
<textarea name="notes" class="form-control bg-dark border-secondary text-white"
rows="2" placeholder="Optional notes"></textarea>
</div>
</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">Create Job</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %} {% endblock %}

View File

@@ -4,4 +4,9 @@ services:
ports: ports:
- "8720:8720" - "8720:8720"
env_file: .env env_file: .env
volumes:
- stl_files:/data/stl
restart: unless-stopped restart: unless-stopped
volumes:
stl_files: