diff --git a/alembic/versions/0006_job_brackets.py b/alembic/versions/0006_job_brackets.py new file mode 100644 index 0000000..7876015 --- /dev/null +++ b/alembic/versions/0006_job_brackets.py @@ -0,0 +1,37 @@ +"""job_brackets table and bracket_id on print_jobs + +Revision ID: 0006 +Revises: 0005 +Create Date: 2026-06-18 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0006" +down_revision: Union[str, None] = "0005" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "job_brackets", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("name", sa.String(200), nullable=False), + sa.Column("customer", sa.String(200), nullable=True), + sa.Column("notes", sa.Text(), 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("bracket_id", sa.Integer(), nullable=True)) + op.create_foreign_key("fk_job_bracket", "print_jobs", "job_brackets", ["bracket_id"], ["id"]) + + +def downgrade() -> None: + op.drop_constraint("fk_job_bracket", "print_jobs", type_="foreignkey") + op.drop_column("print_jobs", "bracket_id") + op.drop_table("job_brackets") diff --git a/app/models/__init__.py b/app/models/__init__.py index 0375ca3..26fe125 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_bracket import JobBracket from app.models.job_log import JobLog from app.models.pricing import PricingConfig from app.models.print_job import PrintJob @@ -8,4 +9,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", "JobLog", "PrintJob", "Todo", "PricingConfig"] +__all__ = ["Base", "Printer", "PrinterLog", "PrinterType", "Filament", "JobBracket", "JobLog", "PrintJob", "Todo", "PricingConfig"] diff --git a/app/models/job_bracket.py b/app/models/job_bracket.py new file mode 100644 index 0000000..a85c430 --- /dev/null +++ b/app/models/job_bracket.py @@ -0,0 +1,21 @@ +from datetime import datetime + +from sqlalchemy import DateTime, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base + + +class JobBracket(Base): + __tablename__ = "job_brackets" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(200)) + customer: Mapped[str | None] = mapped_column(String(200)) + notes: Mapped[str | None] = mapped_column(Text) + 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="bracket") diff --git a/app/models/print_job.py b/app/models/print_job.py index d96958c..a10fd12 100644 --- a/app/models/print_job.py +++ b/app/models/print_job.py @@ -24,6 +24,7 @@ class PrintJob(Base): 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)) file_name: Mapped[str | None] = mapped_column(String(255)) @@ -42,6 +43,7 @@ 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/ui.py b/app/routers/ui.py index 682bdf4..25326a9 100644 --- a/app/routers/ui.py +++ b/app/routers/ui.py @@ -10,6 +10,7 @@ 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.job_log import JobLog from app.models.print_job import JobStatus, PrintJob from app.models.printer import Printer, PrinterStatus @@ -135,6 +136,7 @@ def jobs_page(request: Request, q: str = "", db: Session = Depends(get_db)): "q": q, "printers": db.query(Printer).order_by(Printer.name).all(), "filaments": db.query(Filament).order_by(Filament.material, Filament.color).all(), + "brackets": db.query(JobBracket).order_by(JobBracket.name).all(), "stl_files": list_stl_files(), }) @@ -144,6 +146,7 @@ async def create_job( printer_id: str = Form(""), customer: str = Form(""), filament_id: str = Form(""), + bracket_id: str = Form(""), notes: str = Form(""), stl_file: UploadFile = File(None), stl_select: str = Form(""), @@ -162,6 +165,7 @@ async def create_job( file_name=filename, printer_id=int(printer_id) if printer_id else None, filament_id=int(filament_id) if filament_id else None, + bracket_id=int(bracket_id) if bracket_id else None, customer=customer.strip() or None, notes=notes.strip() or None, ) @@ -172,6 +176,78 @@ async def create_job( return RedirectResponse(url="/jobs", status_code=HTTP_303_SEE_OTHER) +# ── Brackets ─────────────────────────────────────────────────────────────────── + +@router.get("/brackets", response_class=HTMLResponse) +def brackets_page(request: Request, db: Session = Depends(get_db)): + return templates.TemplateResponse("brackets.html", { + "request": request, + "active": "brackets", + "brackets": db.query(JobBracket).order_by(JobBracket.created_at.desc()).all(), + }) + + +@router.post("/brackets") +def create_bracket( + name: str = Form(...), + customer: str = Form(""), + notes: str = Form(""), + db: Session = Depends(get_db), +): + db.add(JobBracket(name=name.strip(), customer=customer.strip() or None, notes=notes.strip() or None)) + db.commit() + return RedirectResponse(url="/brackets", status_code=HTTP_303_SEE_OTHER) + + +@router.get("/brackets/{bracket_id}", response_class=HTMLResponse) +def bracket_detail(bracket_id: int, request: Request, db: Session = Depends(get_db)): + from fastapi import HTTPException + bracket = db.query(JobBracket).filter(JobBracket.id == bracket_id).first() + if not bracket: + raise HTTPException(status_code=404, detail="Bracket not found") + return templates.TemplateResponse("bracket_detail.html", { + "request": request, + "active": "brackets", + "bracket": bracket, + "filaments": db.query(Filament).order_by(Filament.material, Filament.color).all(), + "stl_files": list_stl_files(), + }) + + +@router.post("/brackets/{bracket_id}/jobs") +async def add_jobs_to_bracket( + bracket_id: int, + quantity: int = Form(1), + 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 + + for _ in range(max(1, min(quantity, 500))): + job = PrintJob( + job_uuid=str(_uuid.uuid4()), + name=Path(filename).stem if filename else "Unnamed", + file_name=filename, + bracket_id=bracket_id, + filament_id=int(filament_id) if filament_id else 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=f"/brackets/{bracket_id}", status_code=HTTP_303_SEE_OTHER) + + # ── Filaments ────────────────────────────────────────────────────────────────── @router.get("/filaments", response_class=HTMLResponse) diff --git a/app/templates/base.html b/app/templates/base.html index a259910..be5db8c 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -49,6 +49,7 @@
  • Dashboard
  • Printers
  • Jobs
  • +
  • Brackets
  • Filaments
  • To-Do
  • Pricing
  • diff --git a/app/templates/bracket_detail.html b/app/templates/bracket_detail.html new file mode 100644 index 0000000..62f7d74 --- /dev/null +++ b/app/templates/bracket_detail.html @@ -0,0 +1,162 @@ +{% extends "base.html" %} +{% block title %}{{ bracket.name }} — POPS{% endblock %} + +{% block content %} +{% set total = bracket.jobs | length %} +{% set done = bracket.jobs | selectattr('status.value', 'equalto', 'done') | list | length %} +{% set printing = bracket.jobs | selectattr('status.value', 'equalto', 'printing') | list | length %} +{% set pct = (done / total * 100) | int if total else 0 %} + +
    + +
    +
    {{ bracket.name }}
    + {% if bracket.customer %}{{ bracket.customer }}{% endif %} +
    + +
    + +{# ── Progress ── #} +{% if total %} +
    +
    + {{ done }} done · {{ printing }} printing · {{ total - done - printing }} queued + {{ pct }}% +
    +
    +
    +
    +
    +{% endif %} + +{# ── Jobs table ── #} +
    + {% if bracket.jobs %} + + + + + + + + + + + + + {% for j in bracket.jobs %} + + + + + + + + + {% endfor %} + +
    UUIDFileMaterialPrinterStatusCreated
    + {{ j.job_uuid[:8] if j.job_uuid else '—' }} + {{ j.file_name or j.name }} + {% if j.filament %}{{ j.filament.material.value }}{% if j.filament.color %} · {{ j.filament.color }}{% endif %}{% else %}—{% endif %} + {{ j.printer.name if j.printer else '—' }} + {% set s = j.status.value %} + {% if s == 'printing' %}printing + {% elif s == 'done' %}done + {% elif s == 'failed' %}failed + {% elif s == 'cancelled' %}cancelled + {% else %}queued + {% endif %} + {{ j.created_at.strftime('%d.%m %H:%M') }}
    + {% else %} +
    No jobs in this bracket yet.
    + {% endif %} +
    + +{# ── Add Jobs Modal ── #} + +{% endblock %} diff --git a/app/templates/brackets.html b/app/templates/brackets.html new file mode 100644 index 0000000..83f81b6 --- /dev/null +++ b/app/templates/brackets.html @@ -0,0 +1,87 @@ +{% extends "base.html" %} +{% block title %}Brackets — POPS{% endblock %} + +{% block content %} +
    +
    Job Brackets
    + +
    + +
    + {% if brackets %} + + + + + + + + + + + {% for b in brackets %} + {% set total = b.jobs | length %} + {% set done = b.jobs | selectattr('status.value', 'equalto', 'done') | list | length %} + {% set pct = (done / total * 100) | int if total else 0 %} + + + + + + + {% endfor %} + +
    NameCustomerProgressCreated
    {{ b.name }}{{ b.customer or '—' }} + {% if total %} +
    +
    +
    +
    + {{ done }}/{{ total }} +
    + {% else %} + no jobs + {% endif %} +
    {{ b.created_at.strftime('%d.%m.%Y') }}
    + {% else %} +
    No brackets yet.
    + {% endif %} +
    + +{# ── Create Bracket Modal ── #} + +{% endblock %} diff --git a/app/templates/jobs.html b/app/templates/jobs.html index 05efdf5..9d13455 100644 --- a/app/templates/jobs.html +++ b/app/templates/jobs.html @@ -152,6 +152,16 @@ +
    + + +
    +