diff --git a/.env.install b/.env.install index b4fed65..91329f3 100644 --- a/.env.install +++ b/.env.install @@ -3,3 +3,5 @@ MYSQL_PORT= MYSQL_DATABASE= MYSQL_USER= MYSQL_PASSWORD= +STL_UPLOAD_DIR=/data/stl +TZ=Europe/Berlin diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a87589c --- /dev/null +++ b/CLAUDE.md @@ -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 | diff --git a/alembic/versions/0004_print_job_uuid_customer.py b/alembic/versions/0004_print_job_uuid_customer.py new file mode 100644 index 0000000..004974f --- /dev/null +++ b/alembic/versions/0004_print_job_uuid_customer.py @@ -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") diff --git a/app/models/__init__.py b/app/models/__init__.py index 67dab53..0375ca3 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,5 +1,6 @@ from app.models.base import Base from app.models.filament import Filament +from app.models.job_log import JobLog from app.models.pricing import PricingConfig from app.models.print_job import PrintJob 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.todo import Todo -__all__ = ["Base", "Printer", "PrinterLog", "PrinterType", "Filament", "PrintJob", "Todo", "PricingConfig"] +__all__ = ["Base", "Printer", "PrinterLog", "PrinterType", "Filament", "JobLog", "PrintJob", "Todo", "PricingConfig"] diff --git a/app/models/job_log.py b/app/models/job_log.py new file mode 100644 index 0000000..f06e79e --- /dev/null +++ b/app/models/job_log.py @@ -0,0 +1,17 @@ +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Text, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base + + +class 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") diff --git a/app/models/print_job.py b/app/models/print_job.py index a880793..4674eb6 100644 --- a/app/models/print_job.py +++ b/app/models/print_job.py @@ -1,4 +1,5 @@ import enum +import uuid as _uuid from datetime import datetime from decimal import Decimal @@ -20,9 +21,11 @@ class PrintJob(Base): __tablename__ = "print_jobs" 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")) filament_id: Mapped[int | None] = mapped_column(ForeignKey("filaments.id")) name: Mapped[str] = mapped_column(String(200)) + customer: Mapped[str | None] = mapped_column(String(200)) file_name: Mapped[str | None] = mapped_column(String(255)) status: Mapped[JobStatus] = mapped_column(Enum(JobStatus), default=JobStatus.queued) queue_position: Mapped[int | None] = mapped_column(Integer) @@ -39,3 +42,6 @@ class PrintJob(Base): printer: Mapped["Printer"] = 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()" + ) diff --git a/app/routers/ui.py b/app/routers/ui.py index f136ff1..2ef4c17 100644 --- a/app/routers/ui.py +++ b/app/routers/ui.py @@ -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.templating import Jinja2Templates +from sqlalchemy import or_ 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_log import JobLog from app.models.print_job import JobStatus, PrintJob from app.models.printer import Printer, PrinterStatus from app.models.printer_log import PrinterLog from app.models.printer_type import PrinterType from app.models.pricing import PricingConfig from app.models.todo import Todo +from app.stl import get_stl_dir, list_stl_files router = APIRouter() templates = Jinja2Templates(directory="app/templates") +# ── Dashboard ────────────────────────────────────────────────────────────────── + @router.get("/", response_class=HTMLResponse) def dashboard(request: Request, db: Session = Depends(get_db)): 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) def printers_page(request: Request, db: Session = Depends(get_db)): return templates.TemplateResponse("printers.html", { @@ -66,9 +76,9 @@ def create_printer( @router.get("/printers/{printer_id}", response_class=HTMLResponse) 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() if not printer: - from fastapi import HTTPException raise HTTPException(status_code=404, detail="Printer not found") return templates.TemplateResponse("printer_detail.html", { "request": request, @@ -79,12 +89,13 @@ def printer_detail(printer_id: int, request: Request, db: Session = Depends(get_ @router.post("/printers/{printer_id}/log") def add_printer_log(printer_id: int, message: str = Form(...), db: Session = Depends(get_db)): - entry = PrinterLog(printer_id=printer_id, message=message.strip()) - db.add(entry) + db.add(PrinterLog(printer_id=printer_id, message=message.strip())) db.commit() return RedirectResponse(url=f"/printers/{printer_id}", status_code=HTTP_303_SEE_OTHER) +# ── Printer types ────────────────────────────────────────────────────────────── + @router.get("/printer-types", response_class=HTMLResponse) def printer_types_page(request: Request, db: Session = Depends(get_db)): return templates.TemplateResponse("printer_types.html", { @@ -102,21 +113,67 @@ def create_printer_type( bed_z_mm: int = Form(...), 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(pt) + 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.commit() return RedirectResponse(url="/printer-types", status_code=HTTP_303_SEE_OTHER) +# ── Jobs ─────────────────────────────────────────────────────────────────────── + @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", { "request": request, "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) def filaments_page(request: Request, db: Session = Depends(get_db)): 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) def todos_page(request: Request, db: Session = Depends(get_db)): 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) def pricing_page(request: Request, db: Session = Depends(get_db)): return templates.TemplateResponse("pricing.html", { diff --git a/app/stl.py b/app/stl.py new file mode 100644 index 0000000..6202764 --- /dev/null +++ b/app/stl.py @@ -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 [] diff --git a/app/templates/jobs.html b/app/templates/jobs.html index 3237eb6..7af363a 100644 --- a/app/templates/jobs.html +++ b/app/templates/jobs.html @@ -2,28 +2,54 @@ {% block title %}Jobs — POPS{% endblock %} {% block content %} -
Results for {{ q }}
+{% endif %} +| Name | +UUID | +Customer | +File | Printer | -Filament | +Material | Status | -Duration | -Cost | Created | ||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| {{ j.name }} | -{{ j.printer.name if j.printer else '—' }} | -- {% if j.filament %}{{ j.filament.material.value }} {{ j.filament.color or '' }}{% else %}—{% endif %} + | + {{ j.job_uuid[:8] if j.job_uuid else '—' }} + | +{{ j.customer or '—' }} | +{{ j.file_name or '—' }} | +{{ j.printer.name if j.printer else '—' }} | ++ {% if j.filament %}{{ j.filament.material.value }}{% if j.filament.color %} · {{ j.filament.color }}{% endif %}{% else %}—{% endif %} | {% set s = j.status.value %} @@ -34,19 +60,111 @@ {% else %}queued {% endif %} | -- {% if j.duration_minutes %}{{ j.duration_minutes }} min{% else %}—{% endif %} - | -- {% if j.cost_eur %}€ {{ "%.2f"|format(j.cost_eur) }}{% else %}—{% endif %} - | -{{ j.created_at.strftime('%d.%m %H:%M') }} | +{{ j.created_at.strftime('%d.%m.%Y %H:%M') }} |