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:
@@ -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
90
CLAUDE.md
Normal 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 |
|
||||||
39
alembic/versions/0004_print_job_uuid_customer.py
Normal file
39
alembic/versions/0004_print_job_uuid_customer.py
Normal 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")
|
||||||
@@ -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
17
app/models/job_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 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")
|
||||||
@@ -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()"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
20
app/stl.py
Normal 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 []
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user