diff --git a/.env.install b/.env.install index 91329f3..b90be90 100644 --- a/.env.install +++ b/.env.install @@ -5,3 +5,4 @@ MYSQL_USER= MYSQL_PASSWORD= STL_UPLOAD_DIR=/data/stl TZ=Europe/Berlin +FILAMENTDB_URL=https://web.filamentdb.orb.local diff --git a/alembic/versions/0009_remove_filaments.py b/alembic/versions/0009_remove_filaments.py new file mode 100644 index 0000000..d0e4996 --- /dev/null +++ b/alembic/versions/0009_remove_filaments.py @@ -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"]) diff --git a/app/filamentdb.py b/app/filamentdb.py new file mode 100644 index 0000000..ca83fd7 --- /dev/null +++ b/app/filamentdb.py @@ -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) diff --git a/app/main.py b/app/main.py index 9436af0..30e3c32 100644 --- a/app/main.py +++ b/app/main.py @@ -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") diff --git a/app/models/__init__.py b/app/models/__init__.py index 6fdcf4c..ddaedfe 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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"] diff --git a/app/models/filament.py b/app/models/filament.py deleted file mode 100644 index 9621c5e..0000000 --- a/app/models/filament.py +++ /dev/null @@ -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") diff --git a/app/models/print_job.py b/app/models/print_job.py index d8d1093..c62641b 100644 --- a/app/models/print_job.py +++ b/app/models/print_job.py @@ -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()" diff --git a/app/routers/filaments.py b/app/routers/filaments.py deleted file mode 100644 index 8eee61c..0000000 --- a/app/routers/filaments.py +++ /dev/null @@ -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() diff --git a/app/routers/ui.py b/app/routers/ui.py index 00bf1e1..44bfee7 100644 --- a/app/routers/ui.py +++ b/app/routers/ui.py @@ -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) diff --git a/app/templates/base.html b/app/templates/base.html index be5db8c..bfa513a 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -50,8 +50,7 @@
  • Printers
  • Jobs
  • Brackets
  • -
  • Filaments
  • -
  • To-Do
  • +
  • To-Do
  • Pricing
  • diff --git a/app/templates/filaments.html b/app/templates/filaments.html deleted file mode 100644 index 7fb8f32..0000000 --- a/app/templates/filaments.html +++ /dev/null @@ -1,52 +0,0 @@ -{% extends "base.html" %} -{% block title %}Filaments — POPS{% endblock %} - -{% block content %} -
    Filament Inventory
    -
    - {% if filaments %} - - - - - - - - - - - - {% for f in filaments %} - {% set pct = (f.weight_remaining_g / f.weight_total_g * 100) | int if f.weight_total_g else 0 %} - - - - - - - - {% endfor %} - -
    BrandMaterialColorRemainingPrice / kg
    {{ f.brand or '—' }}{{ f.material.value }} -
    - {% if f.color_hex %} - - {% endif %} - {{ f.color or '—' }} -
    -
    -
    -
    -
    -
    - {{ f.weight_remaining_g | int }}g / {{ f.weight_total_g | int }}g -
    -
    - {% if f.price_per_kg_eur %}€ {{ "%.2f"|format(f.price_per_kg_eur) }}{% else %}—{% endif %} -
    - {% else %} -
    No filaments in inventory yet.
    - {% endif %} -
    -{% endblock %} diff --git a/app/templates/job_detail.html b/app/templates/job_detail.html new file mode 100644 index 0000000..595c063 --- /dev/null +++ b/app/templates/job_detail.html @@ -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 %} +
    + + + +
    +
    +
    {{ job.file_name or job.name }}
    + {% set s = job.status.value %} + {% if s == 'printing' %}printing + {% elif s == 'done' %}done + {% elif s == 'failed' %}failed + {% elif s == 'cancelled' %}cancelled + {% else %}queued + {% endif %} +
    +
    {{ job.job_uuid or '—' }}
    +
    +
    + +
    + + {# ── Job info ── #} +
    +
    +
    Job
    +
    + + + + + + + + + + + + + {% if job.started_at %} + + + {% endif %} + {% if job.finished_at %} + + + {% endif %} + {% if job.duration_minutes %} + + + {% endif %} + {% if job.cost_eur %} + + + {% endif %} + {% if job.notes %} + + + {% endif %} + +
    Customer{{ job.customer or '—' }}
    Material{{ job.job_material or '—' }}{% if job.job_color %} · {{ job.job_color }}{% endif %}
    Printer{{ job.printer.name if job.printer else '—' }}
    Bracket{% if job.bracket %}{{ job.bracket.name }}{% else %}—{% endif %}
    Created{{ job.created_at.strftime('%d.%m.%Y %H:%M') }}
    Started{{ job.started_at.strftime('%d.%m.%Y %H:%M') }}
    Finished{{ job.finished_at.strftime('%d.%m.%Y %H:%M') }}
    Duration{{ job.duration_minutes }} min
    Cost€ {{ "%.2f"|format(job.cost_eur) }}
    Notes{{ job.notes }}
    +
    +
    +
    + + {# ── STL geometry ── #} +
    +
    +
    + File & Geometry +
    +
    + {% if job.file_name %} +
    {{ job.file_name }}
    + {% endif %} + {% if stl %} + + + + + + + +
    Bounding box + {{ stl.cube_x|round(1) }} × {{ stl.cube_y|round(1) }} × {{ stl.cube_z|round(1) }} mm +
    Volume{{ stl.volume_ccm }} cm³
    + {% elif job.file_name and job.file_name.endswith('.gcode') %} +

    Geometry not available for gcode files.

    + {% elif job.file_name %} +

    Not yet analysed — will be computed on next upload.

    + {% else %} +

    No file attached.

    + {% endif %} +
    +
    +
    + +
    + +{# ── Log ── #} +
    +
    Log
    + +
    + +
    + {% if job.logs %} + + + {% for entry in job.logs %} + + + + + {% endfor %} + +
    + {{ entry.created_at.strftime('%d.%m.%Y %H:%M') }} + {{ entry.message }}
    + {% else %} +
    No log entries.
    + {% endif %} +
    + +{# ── Add log entry modal ── #} + +{% endblock %} diff --git a/app/templates/jobs.html b/app/templates/jobs.html index 5d30023..ceb0bdc 100644 --- a/app/templates/jobs.html +++ b/app/templates/jobs.html @@ -46,7 +46,7 @@ {{ j.job_uuid[:8] if j.job_uuid else '—' }} {{ j.customer or '—' }} - {{ j.file_name or '—' }} + {{ j.file_name or j.name or '—' }} {{ j.printer.name if j.printer else '—' }} {% if j.job_material %}{{ j.job_material }}{% if j.job_color %} · {{ j.job_color }}{% endif %}{% else %}—{% endif %} diff --git a/requirements.txt b/requirements.txt index adb30c0..9b56141 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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