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 @@
| UUID | +File | +Material | +Printer | +Status | +Created | +
|---|---|---|---|---|---|
| + {{ 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') }} | +
| Name | +Customer | +Progress | +Created | +
|---|---|---|---|
| {{ b.name }} | +{{ b.customer or '—' }} | +
+ {% if total %}
+
+
+ {% else %}
+ no jobs
+ {% endif %}
+
+
+
+ {{ done }}/{{ total }}
+ |
+ {{ b.created_at.strftime('%d.%m.%Y') }} | +