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 @@
| Brand | -Material | -Color | -Remaining | -Price / 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 %} - | -
| 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 }} |
| Bounding box | ++ {{ stl.cube_x|round(1) }} × {{ stl.cube_y|round(1) }} × {{ stl.cube_z|round(1) }} mm + |
| Volume | +{{ stl.volume_ccm }} cm³ |
Geometry not available for gcode files.
+ {% elif job.file_name %} +Not yet analysed — will be computed on next upload.
+ {% else %} +No file attached.
+ {% endif %} +| + {{ entry.created_at.strftime('%d.%m.%Y %H:%M') }} + | +{{ entry.message }} | +