Compare commits
19 Commits
2fb08acd5b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff2b618414 | ||
|
|
38e6d38a2f | ||
|
|
f713fbe2fb | ||
|
|
7dec15d777 | ||
|
|
5270d96b92 | ||
|
|
5a61a35839 | ||
|
|
dc8918d323 | ||
|
|
6a1f146d8e | ||
|
|
fc13d905db | ||
|
|
ab52721c7f | ||
|
|
1e74b3a149 | ||
|
|
726a4d3c02 | ||
|
|
5dfa700916 | ||
|
|
a2e8c9e194 | ||
|
|
223a1b7f1e | ||
|
|
8ecace0493 | ||
|
|
0569cd4f02 | ||
|
|
c94eeb071f | ||
|
|
cb5a2bc394 |
@@ -6,6 +6,9 @@ MYSQL_PASSWORD=
|
||||
STL_UPLOAD_DIR=/data/stl
|
||||
TZ=Europe/Berlin
|
||||
FILAMENTDB_URL=https://web.filamentdb.orb.local
|
||||
VIKUNJA_URL=https://task.example.com
|
||||
VIKUNJA_API_TOKEN=
|
||||
VIKUNJA_PROJECT_ID=
|
||||
VAT_RATE=19
|
||||
FILAMENT_PRICE_PER_KG=15
|
||||
MATERIAL_FACTOR=0.50
|
||||
|
||||
21
alembic/versions/0011_stl_cache_manually_set.py
Normal file
21
alembic/versions/0011_stl_cache_manually_set.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""stl_cache manually_set flag
|
||||
|
||||
Revision ID: 0011
|
||||
Revises: 0010
|
||||
Create Date: 2026-06-19
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0011"
|
||||
down_revision = "0010"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column("stl_cache", sa.Column("manually_set", sa.Boolean(), server_default="0", nullable=False))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("stl_cache", "manually_set")
|
||||
@@ -46,11 +46,12 @@ def estimate(volume_ccm: float, material: str | None) -> dict:
|
||||
PRINT_SPEED_G_PER_HOUR (env, default 32):
|
||||
~32 g/h matches Bambu Lab at normal quality; use ~20 for slower printers.
|
||||
"""
|
||||
vat_rate = float(os.environ.get("VAT_RATE", 19))
|
||||
price_per_kg = float(os.environ.get("FILAMENT_PRICE_PER_KG", 15))
|
||||
base_factor = float(os.environ.get("MATERIAL_FACTOR", 0.50))
|
||||
print_speed = float(os.environ.get("PRINT_SPEED_G_PER_HOUR", 32))
|
||||
density = _DENSITY.get(material or "", _DEFAULT_DENSITY)
|
||||
vat_rate = float(os.environ.get("VAT_RATE", 19))
|
||||
price_per_kg = float(os.environ.get("FILAMENT_PRICE_PER_KG", 15))
|
||||
base_factor = float(os.environ.get("MATERIAL_FACTOR", 0.50))
|
||||
print_speed = float(os.environ.get("PRINT_SPEED_G_PER_HOUR", 32))
|
||||
printer_cost_per_hour = float(os.environ.get("PRINTER_COST_PER_HOUR", 0.35))
|
||||
density = _DENSITY.get(material or "", _DEFAULT_DENSITY)
|
||||
|
||||
eff_factor = _effective_factor(volume_ccm, base_factor)
|
||||
material_ccm = volume_ccm * eff_factor
|
||||
@@ -59,18 +60,21 @@ def estimate(volume_ccm: float, material: str | None) -> dict:
|
||||
|
||||
filament_cost = weight_g / 1000 * price_per_kg
|
||||
after_misprint = filament_cost * 1.20
|
||||
net_price = after_misprint * 1.30
|
||||
printer_cost = (print_minutes / 60) * printer_cost_per_hour
|
||||
net_price = (after_misprint + printer_cost) * 1.30
|
||||
gross_price = net_price * (1 + vat_rate / 100)
|
||||
|
||||
return {
|
||||
"material_ccm": round(material_ccm, 2),
|
||||
"weight_g": round(weight_g, 1),
|
||||
"print_minutes": print_minutes,
|
||||
"filament_cost": round(filament_cost, 2),
|
||||
"net_price": round(net_price, 2),
|
||||
"gross_price": round(gross_price, 2),
|
||||
"vat_rate": vat_rate,
|
||||
"price_per_kg": price_per_kg,
|
||||
"material_factor": eff_factor,
|
||||
"print_speed": print_speed,
|
||||
"material_ccm": round(material_ccm, 2),
|
||||
"weight_g": round(weight_g, 1),
|
||||
"print_minutes": print_minutes,
|
||||
"filament_cost": round(filament_cost, 2),
|
||||
"printer_cost": round(printer_cost, 2),
|
||||
"net_price": round(net_price, 2),
|
||||
"gross_price": round(gross_price, 2),
|
||||
"vat_rate": vat_rate,
|
||||
"price_per_kg": price_per_kg,
|
||||
"material_factor": eff_factor,
|
||||
"print_speed": print_speed,
|
||||
"printer_cost_per_hour": printer_cost_per_hour,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Float, String, func
|
||||
from sqlalchemy import Boolean, DateTime, Float, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
@@ -15,4 +15,5 @@ class StlCache(Base):
|
||||
cube_y: Mapped[float | None] = mapped_column(Float) # mm
|
||||
cube_z: Mapped[float | None] = mapped_column(Float) # mm
|
||||
volume_ccm: Mapped[float | None] = mapped_column(Float) # cm³
|
||||
manually_set: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import get_db
|
||||
from app.models.todo import Todo
|
||||
from fastapi import APIRouter
|
||||
from app.vikunja import create_task, delete_task, get_tasks, update_task
|
||||
|
||||
router = APIRouter(prefix="/todos", tags=["todos"])
|
||||
|
||||
|
||||
@router.get("/count")
|
||||
def count_todos():
|
||||
tasks = get_tasks()
|
||||
return {"open": sum(1 for t in tasks if not t.get("done"))}
|
||||
|
||||
|
||||
@router.get("/")
|
||||
def list_todos(db: Session = Depends(get_db)):
|
||||
return [t.to_dict() for t in db.query(Todo).all()]
|
||||
def list_todos():
|
||||
return get_tasks()
|
||||
|
||||
|
||||
@router.get("/{todo_id}")
|
||||
def get_todo(todo_id: int, db: Session = Depends(get_db)):
|
||||
todo = db.query(Todo).filter(Todo.id == todo_id).first()
|
||||
if not todo:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail="Todo not found")
|
||||
return todo.to_dict()
|
||||
@router.post("/")
|
||||
def create_todo(body: dict):
|
||||
return create_task(body.get("title", ""))
|
||||
|
||||
|
||||
@router.post("/{task_id}")
|
||||
def update_todo(task_id: int, body: dict):
|
||||
update_task(task_id, **body)
|
||||
|
||||
|
||||
@router.delete("/{task_id}")
|
||||
def delete_todo(task_id: int):
|
||||
delete_task(task_id)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import uuid as _uuid
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, Request, UploadFile
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, Request, UploadFile
|
||||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import case, or_
|
||||
@@ -20,13 +21,13 @@ 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.vikunja import create_task as vikunja_create, delete_task as vikunja_delete, get_tasks as vikunja_tasks, update_task as vikunja_update
|
||||
from app.constants import COLORS, MATERIALS
|
||||
from app.estimation import estimate # also used in start_job
|
||||
from app.filamentdb import (available_colors, available_materials, find_matching_ams_roll,
|
||||
get_stock, log_print_on_roll, spool_counts)
|
||||
from app.stl import get_stl_dir, list_stl_files
|
||||
from app.stl_analysis import analyse, get_cache_map # noqa: F401 used in job_detail route
|
||||
from app.stl_analysis import analyse_in_background, get_cache_map, is_computing, is_failed # noqa: F401
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
@@ -90,10 +91,17 @@ def printer_detail(printer_id: int, request: Request, db: Session = Depends(get_
|
||||
printer = db.query(Printer).filter(Printer.id == printer_id).first()
|
||||
if not printer:
|
||||
raise HTTPException(status_code=404, detail="Printer not found")
|
||||
current_job = (
|
||||
db.query(PrintJob)
|
||||
.filter(PrintJob.printer_id == printer_id, PrintJob.status == JobStatus.printing)
|
||||
.order_by(PrintJob.started_at.desc())
|
||||
.first()
|
||||
)
|
||||
return templates.TemplateResponse("printer_detail.html", {
|
||||
"request": request,
|
||||
"active": "printers",
|
||||
"printer": printer,
|
||||
"current_job": current_job,
|
||||
})
|
||||
|
||||
|
||||
@@ -155,13 +163,29 @@ def jobs_page(request: Request, q: str = "", bracket_id: str = "", show_all: str
|
||||
or_(~PrintJob.status.in_(finished), PrintJob.created_at >= cutoff)
|
||||
)
|
||||
|
||||
# Sort: printing → queued → rest, then priority, then oldest first
|
||||
# Sort: printing → queued → rest, then priority flag, then internal last, then oldest first
|
||||
status_order = case(
|
||||
(PrintJob.status == JobStatus.printing, 0),
|
||||
(PrintJob.status == JobStatus.queued, 1),
|
||||
else_=2,
|
||||
)
|
||||
jobs = query.order_by(status_order, PrintJob.priority.desc(), PrintJob.created_at.asc()).all()
|
||||
internal_last = case((PrintJob.customer == "internal", 1), else_=0)
|
||||
jobs = query.order_by(status_order, PrintJob.priority.desc(), internal_last, PrintJob.created_at.asc()).all()
|
||||
|
||||
stl_files = list_stl_files()
|
||||
stl_cache = get_cache_map(stl_files, db)
|
||||
|
||||
bracket_gross_total = None
|
||||
if active_bracket:
|
||||
total = 0.0
|
||||
has_any = False
|
||||
for j in active_bracket.jobs:
|
||||
cached = stl_cache.get(j.file_name)
|
||||
if cached and cached.volume_ccm:
|
||||
total += estimate(cached.volume_ccm, j.job_material)["gross_price"]
|
||||
has_any = True
|
||||
if has_any:
|
||||
bracket_gross_total = round(total, 2)
|
||||
|
||||
return templates.TemplateResponse("jobs.html", {
|
||||
"request": request,
|
||||
@@ -172,10 +196,11 @@ def jobs_page(request: Request, q: str = "", bracket_id: str = "", show_all: str
|
||||
"show_all": bool(show_all),
|
||||
"hidden_count": hidden_count,
|
||||
"active_bracket": active_bracket,
|
||||
"bracket_gross_total": bracket_gross_total,
|
||||
"printers": db.query(Printer).order_by(Printer.name).all(),
|
||||
"brackets": db.query(JobBracket).order_by(JobBracket.name).all(),
|
||||
"stl_files": (stl_files := list_stl_files()),
|
||||
"stl_cache": get_cache_map(stl_files, db),
|
||||
"stl_files": stl_files,
|
||||
"stl_cache": stl_cache,
|
||||
"materials": available_materials() or MATERIALS,
|
||||
"colors": available_colors() or COLORS,
|
||||
"spool_map": spool_counts(),
|
||||
@@ -205,7 +230,7 @@ async def create_job(
|
||||
filename = stl_select
|
||||
|
||||
amount = max(1, amount)
|
||||
customer_str = customer.strip() or None
|
||||
customer_str = customer.strip() or "internal"
|
||||
job_name = Path(filename).stem if filename else (customer_str or "Unnamed")
|
||||
|
||||
# Resolve bracket — auto-create one when printing multiple copies
|
||||
@@ -244,18 +269,37 @@ async def create_job(
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}", response_class=HTMLResponse)
|
||||
def job_detail(job_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
def job_detail(job_id: int, request: Request, background_tasks: BackgroundTasks, 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
|
||||
|
||||
file_missing = False
|
||||
computing = False
|
||||
stl = None
|
||||
if job.file_name:
|
||||
file_path = get_stl_dir() / job.file_name
|
||||
if not file_path.exists():
|
||||
file_missing = True
|
||||
else:
|
||||
stl = db.query(StlCache).filter_by(filename=job.file_name).first()
|
||||
if stl is None and not job.file_name.endswith(".gcode"):
|
||||
if is_failed(job.file_name):
|
||||
file_missing = False # file exists, analysis just failed
|
||||
elif not is_computing(job.file_name):
|
||||
background_tasks.add_task(analyse_in_background, job.file_name)
|
||||
computing = not is_failed(job.file_name)
|
||||
|
||||
estimation = estimate(stl.volume_ccm, job.job_material) if stl and stl.volume_ccm else None
|
||||
return templates.TemplateResponse("job_detail.html", {
|
||||
"request": request,
|
||||
"active": "jobs",
|
||||
"job": job,
|
||||
"stl": stl,
|
||||
"file_missing": file_missing,
|
||||
"computing": computing,
|
||||
"analysis_failed": job.file_name and is_failed(job.file_name),
|
||||
"estimation": estimation,
|
||||
"printers": db.query(Printer).order_by(Printer.name).all(),
|
||||
"brackets": db.query(JobBracket).order_by(JobBracket.name).all(),
|
||||
@@ -285,7 +329,7 @@ def edit_job(
|
||||
job = db.query(PrintJob).filter(PrintJob.id == job_id).first()
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
job.customer = customer.strip() or None
|
||||
job.customer = customer.strip() or "internal"
|
||||
job.job_material = job_material or None
|
||||
job.job_color = job_color or None
|
||||
job.priority = bool(priority)
|
||||
@@ -299,6 +343,45 @@ def edit_job(
|
||||
return RedirectResponse(url=f"/jobs/{job_id}", status_code=HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post("/jobs/{job_id}/duplicate")
|
||||
def duplicate_job(job_id: int, db: Session = Depends(get_db)):
|
||||
from fastapi import HTTPException
|
||||
job = db.query(PrintJob).filter(PrintJob.id == job_id).first()
|
||||
if not job:
|
||||
raise HTTPException(404, "Job not found")
|
||||
|
||||
copy = PrintJob(
|
||||
job_uuid=str(_uuid.uuid4()),
|
||||
name=job.name,
|
||||
file_name=job.file_name,
|
||||
printer_id=None,
|
||||
bracket_id=job.bracket_id,
|
||||
customer=job.customer,
|
||||
job_material=job.job_material,
|
||||
job_color=job.job_color,
|
||||
priority=job.priority,
|
||||
status=JobStatus.queued,
|
||||
notes=job.notes,
|
||||
)
|
||||
db.add(copy)
|
||||
|
||||
# If the original was in a bracket, extend the "×N" count in its name to match.
|
||||
if job.bracket and job.bracket.name:
|
||||
job.bracket.name = re.sub(
|
||||
r"×(\d+)\s*$",
|
||||
lambda m: f"×{int(m.group(1)) + 1}",
|
||||
job.bracket.name,
|
||||
)
|
||||
|
||||
db.flush()
|
||||
db.add(JobLog(job_id=copy.id, message=f"Job created (duplicate of #{job.id})"))
|
||||
db.commit()
|
||||
|
||||
if copy.bracket_id:
|
||||
return RedirectResponse(url=f"/jobs?bracket_id={copy.bracket_id}", status_code=HTTP_303_SEE_OTHER)
|
||||
return RedirectResponse(url="/jobs", status_code=HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.get("/files/{filename}")
|
||||
def download_file(filename: str):
|
||||
from fastapi import HTTPException
|
||||
@@ -329,6 +412,7 @@ def start_job(job_id: int, printer_id: int = Form(...), db: Session = Depends(ge
|
||||
db.add(JobLog(job_id=job_id, message=f"Job started on {printer.name}"))
|
||||
|
||||
# Log filament usage against matching AMS roll in FilamentDB
|
||||
filament_low = False
|
||||
if job.job_material and job.job_color:
|
||||
stl = db.query(StlCache).filter_by(filename=job.file_name).first() if job.file_name else None
|
||||
if stl and stl.volume_ccm:
|
||||
@@ -343,6 +427,8 @@ def start_job(job_id: int, printer_id: int = Form(...), db: Session = Depends(ge
|
||||
f"(AMS slot {roll['slot']} — {roll['material']} {roll['color']}, "
|
||||
f"{roll['weight_remaining_g']} g remaining, least-full roll selected)"
|
||||
)))
|
||||
if roll["weight_remaining_g"] - weight_g < 100:
|
||||
filament_low = True
|
||||
else:
|
||||
db.add(JobLog(job_id=job_id, message=f"FilamentDB: print log failed for roll #{roll['roll_id']}"))
|
||||
else:
|
||||
@@ -351,6 +437,8 @@ def start_job(job_id: int, printer_id: int = Form(...), db: Session = Depends(ge
|
||||
)))
|
||||
|
||||
db.commit()
|
||||
if filament_low:
|
||||
vikunja_create("Check filament status")
|
||||
return RedirectResponse(url=f"/jobs/{job_id}", status_code=HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@@ -406,19 +494,65 @@ def finish_job(job_id: int, db: Session = Depends(get_db)):
|
||||
job.printer.status = PrinterStatus.idle
|
||||
|
||||
db.add(JobLog(job_id=job_id, message=f"Job finished on {printer_name}"))
|
||||
|
||||
# Every 3rd completed print on this printer → cleaning reminder
|
||||
cleaning_todo = False
|
||||
if job.printer_id:
|
||||
done_count = db.query(PrintJob).filter(
|
||||
PrintJob.printer_id == job.printer_id,
|
||||
PrintJob.status == JobStatus.done,
|
||||
).count()
|
||||
if (done_count + 1) % 3 == 0:
|
||||
cleaning_todo = True
|
||||
|
||||
db.commit()
|
||||
if cleaning_todo:
|
||||
vikunja_create(f"Clean build plate on {printer_name}")
|
||||
return RedirectResponse(url=f"/jobs/{job_id}", status_code=HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post("/jobs/{job_id}/analyse")
|
||||
def analyse_job_file(job_id: int, db: Session = Depends(get_db)):
|
||||
def analyse_job_file(job_id: int, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
|
||||
from fastapi import HTTPException
|
||||
job = db.query(PrintJob).filter(PrintJob.id == job_id).first()
|
||||
if not job or not job.file_name:
|
||||
raise HTTPException(status_code=404, detail="Job or file not found")
|
||||
db.query(StlCache).filter_by(filename=job.file_name).delete()
|
||||
db.commit()
|
||||
analyse(job.file_name, db)
|
||||
background_tasks.add_task(analyse_in_background, job.file_name)
|
||||
return RedirectResponse(url=f"/jobs/{job_id}", status_code=HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post("/jobs/{job_id}/geometry")
|
||||
def set_geometry(
|
||||
job_id: int,
|
||||
volume_ccm: float = Form(...),
|
||||
cube_x: float = Form(...),
|
||||
cube_y: float = Form(...),
|
||||
cube_z: float = Form(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
from fastapi import HTTPException
|
||||
job = db.query(PrintJob).filter(PrintJob.id == job_id).first()
|
||||
if not job or not job.file_name:
|
||||
raise HTTPException(status_code=404, detail="Job or file not found")
|
||||
stl = db.query(StlCache).filter_by(filename=job.file_name).first()
|
||||
if stl:
|
||||
stl.volume_ccm = volume_ccm
|
||||
stl.cube_x = cube_x
|
||||
stl.cube_y = cube_y
|
||||
stl.cube_z = cube_z
|
||||
stl.manually_set = True
|
||||
else:
|
||||
db.add(StlCache(
|
||||
filename=job.file_name,
|
||||
volume_ccm=volume_ccm,
|
||||
cube_x=cube_x,
|
||||
cube_y=cube_y,
|
||||
cube_z=cube_z,
|
||||
manually_set=True,
|
||||
))
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/jobs/{job_id}", status_code=HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@@ -457,17 +591,37 @@ def bracket_detail_redirect(bracket_id: int):
|
||||
return RedirectResponse(url=f"/jobs?bracket_id={bracket_id}", status_code=HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
# ── To-Do ──────────────────────────────────────────────────────────────────────
|
||||
# ── To-Do (Vikunja) ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/todos", response_class=HTMLResponse)
|
||||
def todos_page(request: Request, db: Session = Depends(get_db)):
|
||||
def todos_page(request: Request):
|
||||
tasks = vikunja_tasks()
|
||||
tasks.sort(key=lambda t: (t.get("done", False), t.get("created", "")), reverse=False)
|
||||
return templates.TemplateResponse("todos.html", {
|
||||
"request": request,
|
||||
"active": "todos",
|
||||
"todos": db.query(Todo).order_by(Todo.done, Todo.created_at.desc()).all(),
|
||||
"todos": tasks,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/todos")
|
||||
def create_todo(title: str = Form(...)):
|
||||
vikunja_create(title.strip())
|
||||
return RedirectResponse(url="/todos", status_code=HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post("/todos/{task_id}/done")
|
||||
def done_todo(task_id: int):
|
||||
vikunja_update(task_id, done=True)
|
||||
return RedirectResponse(url="/todos", status_code=HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post("/todos/{task_id}/delete")
|
||||
def delete_todo(task_id: int):
|
||||
vikunja_delete(task_id)
|
||||
return RedirectResponse(url="/todos", status_code=HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
# ── Pricing ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/pricing", response_class=HTMLResponse)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"""STL/3MF geometry analysis with background process isolation."""
|
||||
import logging
|
||||
import multiprocessing as _mp
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.stl_cache import StlCache
|
||||
@@ -8,6 +11,27 @@ from app.models.stl_cache import StlCache
|
||||
logger = logging.getLogger("pops")
|
||||
|
||||
_ANALYSABLE = {".stl", ".3mf"}
|
||||
_TIMEOUT = 120 # seconds before we give up
|
||||
|
||||
_in_progress: set[str] = set()
|
||||
_failed: set[str] = set()
|
||||
|
||||
|
||||
def _load_geometry(filepath: str):
|
||||
"""Return (extents_array, volume_mm3). Handles Scene without concatenating."""
|
||||
import trimesh
|
||||
loaded = trimesh.load(filepath)
|
||||
if isinstance(loaded, trimesh.Scene):
|
||||
meshes = list(loaded.geometry.values())
|
||||
if not meshes:
|
||||
raise ValueError("3MF scene contains no meshes")
|
||||
bounds = np.array([m.bounds for m in meshes]) # (N, 2, 3)
|
||||
extents = bounds[:, 1, :].max(axis=0) - bounds[:, 0, :].min(axis=0)
|
||||
volume_mm3 = sum(abs(float(m.volume)) for m in meshes)
|
||||
else:
|
||||
extents = np.array(loaded.extents)
|
||||
volume_mm3 = abs(float(loaded.volume))
|
||||
return extents, volume_mm3
|
||||
|
||||
|
||||
def analyse(filename: str, db: Session) -> StlCache | None:
|
||||
@@ -22,10 +46,7 @@ def analyse(filename: str, db: Session) -> StlCache | None:
|
||||
return None
|
||||
|
||||
try:
|
||||
import trimesh
|
||||
mesh = trimesh.load(str(filepath), force="mesh")
|
||||
extents = mesh.extents # [size_x, size_y, size_z] in mm
|
||||
volume_mm3 = abs(mesh.volume) # mm³ (negative for inverted normals)
|
||||
extents, volume_mm3 = _load_geometry(str(filepath))
|
||||
entry = StlCache(
|
||||
filename=filename,
|
||||
cube_x=round(float(extents[0]), 2),
|
||||
@@ -41,6 +62,50 @@ def analyse(filename: str, db: Session) -> StlCache | None:
|
||||
return None
|
||||
|
||||
|
||||
def _worker(filename: str) -> None:
|
||||
"""Child process entry point — creates its own DB session."""
|
||||
from app.db import SessionLocal
|
||||
db = SessionLocal()
|
||||
try:
|
||||
analyse(filename, db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def analyse_in_background(filename: str) -> None:
|
||||
"""Spawn an isolated child process for analysis; kill it after _TIMEOUT."""
|
||||
if filename in _in_progress:
|
||||
return
|
||||
_in_progress.add(filename)
|
||||
_failed.discard(filename)
|
||||
try:
|
||||
ctx = _mp.get_context("spawn")
|
||||
p = ctx.Process(target=_worker, args=(filename,), daemon=True)
|
||||
p.start()
|
||||
p.join(timeout=_TIMEOUT)
|
||||
if p.is_alive():
|
||||
logger.warning("STL analysis timed out for %s — killing process", filename)
|
||||
p.kill()
|
||||
p.join()
|
||||
_failed.add(filename)
|
||||
elif p.exitcode != 0:
|
||||
logger.warning("STL analysis process exited with code %s for %s", p.exitcode, filename)
|
||||
_failed.add(filename)
|
||||
except Exception:
|
||||
logger.exception("STL background task error for %s", filename)
|
||||
_failed.add(filename)
|
||||
finally:
|
||||
_in_progress.discard(filename)
|
||||
|
||||
|
||||
def is_computing(filename: str) -> bool:
|
||||
return filename in _in_progress
|
||||
|
||||
|
||||
def is_failed(filename: str) -> bool:
|
||||
return filename in _failed
|
||||
|
||||
|
||||
def get_cache_map(filenames: list[str], db: Session) -> dict[str, StlCache]:
|
||||
"""Bulk-fetch cached entries for a list of filenames."""
|
||||
rows = db.query(StlCache).filter(StlCache.filename.in_(filenames)).all()
|
||||
|
||||
@@ -37,7 +37,17 @@
|
||||
<nav id="sidebar" class="p-3 d-flex flex-column gap-3">
|
||||
<a href="/" class="brand text-decoration-none pb-3">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="bi bi-printer-fill text-primary fs-5"></i>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="#58a6ff">
|
||||
<!-- extruder head -->
|
||||
<rect x="7" y="1" width="10" height="5" rx="1.5"/>
|
||||
<!-- nozzle taper -->
|
||||
<path d="M8 6h8l-2.5 5.5h-3z"/>
|
||||
<!-- filament bead -->
|
||||
<circle cx="12" cy="13.5" r="1.25"/>
|
||||
<!-- printed layers -->
|
||||
<rect x="1.5" y="16.5" width="21" height="2.5" rx="1.25"/>
|
||||
<rect x="1.5" y="21" width="21" height="2" rx="1" fill-opacity="0.55"/>
|
||||
</svg>
|
||||
<div>
|
||||
<div class="text-white fw-semibold lh-sm">POPS</div>
|
||||
<div class="text-secondary lh-sm" style="font-size:.68rem;letter-spacing:.03em">PRINT FARM MANAGER</div>
|
||||
@@ -50,7 +60,7 @@
|
||||
<li><a href="/printers" class="nav-link {% if active == 'printers' %}active{% endif %}"><i class="bi bi-printer me-2"></i>Printers</a></li>
|
||||
<li><a href="/jobs" class="nav-link {% if active == 'jobs' %}active{% endif %}"><i class="bi bi-list-task me-2"></i>Jobs</a></li>
|
||||
<li><a href="/brackets" class="nav-link {% if active == 'brackets' %}active{% endif %}"><i class="bi bi-collection me-2"></i>Brackets</a></li>
|
||||
<li><a href="/todos" class="nav-link {% if active == 'todos' %}active{% endif %}"><i class="bi bi-check2-square me-2"></i>To-Do</a></li>
|
||||
<li><a href="/todos" class="nav-link {% if active == 'todos' %}active{% endif %} d-flex align-items-center justify-content-between"><span><i class="bi bi-check2-square me-2"></i>To-Do</span><span id="todo-count-badge" class="badge rounded-pill bg-primary ms-1" style="display:none!important"></span></a></li>
|
||||
<li><a href="/pricing" class="nav-link {% if active == 'pricing' %}active{% endif %}"><i class="bi bi-currency-euro me-2"></i>Pricing</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
@@ -61,5 +71,14 @@
|
||||
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
fetch('/api/todos/count').then(r => r.json()).then(d => {
|
||||
if (d.open > 0) {
|
||||
const b = document.getElementById('todo-count-badge');
|
||||
b.textContent = d.open;
|
||||
b.style.removeProperty('display');
|
||||
}
|
||||
}).catch(() => {});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<tbody>
|
||||
{% for b in brackets %}
|
||||
{% set total = b.jobs | length %}
|
||||
{% set done = b.jobs | selectattr('status.value', 'equalto', 'done') | list | length %}
|
||||
{% set done = b.jobs | selectattr('status.value', 'in', ['done', 'cancelled']) | list | length %}
|
||||
{% set pct = (done / total * 100) | int if total else 0 %}
|
||||
<tr>
|
||||
<td><a href="/jobs?bracket_id={{ b.id }}" class="text-white text-decoration-none fw-medium">{{ b.name }}</a></td>
|
||||
|
||||
@@ -86,11 +86,11 @@
|
||||
<td class="text-secondary small">{{ j.printer.name if j.printer else '—' }}</td>
|
||||
<td>
|
||||
{% set s = j.status.value %}
|
||||
{% if s == 'printing' %}<span class="badge bg-primary">printing</span>
|
||||
{% if s == 'printing' %}<span class="badge bg-warning text-dark">printing</span>
|
||||
{% elif s == 'done' %}<span class="badge bg-success">done</span>
|
||||
{% elif s == 'failed' %}<span class="badge bg-danger">failed</span>
|
||||
{% elif s == 'cancelled' %}<span class="badge bg-warning text-dark">cancelled</span>
|
||||
{% else %}<span class="badge bg-secondary">queued</span>
|
||||
{% elif s == 'cancelled' %}<span class="badge bg-secondary">cancelled</span>
|
||||
{% else %}<span class="badge border border-warning text-warning" style="background:#000">queued</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-secondary small text-end">{{ j.created_at.strftime('%d.%m %H:%M') }}</td>
|
||||
|
||||
@@ -11,11 +11,11 @@
|
||||
<h5 class="text-white mb-0">{{ job.file_name or job.name }}</h5>
|
||||
{% set s = job.status.value %}
|
||||
{% if job.priority %}<span class="badge bg-warning text-dark"><i class="bi bi-exclamation-circle me-1"></i>Priority</span>{% endif %}
|
||||
{% if s == 'printing' %}<span class="badge bg-primary">printing</span>
|
||||
{% if s == 'printing' %}<span class="badge bg-warning text-dark">printing</span>
|
||||
{% elif s == 'done' %}<span class="badge bg-success">done</span>
|
||||
{% elif s == 'failed' %}<span class="badge bg-danger">failed</span>
|
||||
{% elif s == 'cancelled' %}<span class="badge bg-warning text-dark">cancelled</span>
|
||||
{% else %}<span class="badge bg-secondary">queued</span>
|
||||
{% elif s == 'cancelled' %}<span class="badge bg-secondary">cancelled</span>
|
||||
{% else %}<span class="badge border border-warning text-warning" style="background:#000">queued</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-secondary small font-monospace mt-1">{{ job.job_uuid or '—' }}</div>
|
||||
@@ -103,7 +103,7 @@
|
||||
<div class="card h-100">
|
||||
<div class="card-header border-secondary d-flex align-items-center justify-content-between" style="border-color:#21262d">
|
||||
<span class="text-secondary small text-uppercase fw-normal">File & Geometry</span>
|
||||
{% if job.file_name %}
|
||||
{% if job.file_name and not file_missing %}
|
||||
<div class="d-flex gap-2">
|
||||
<a href="/files/{{ job.file_name }}" class="btn btn-outline-primary btn-sm py-0 px-2" style="font-size:.75rem" download>
|
||||
<i class="bi bi-download me-1"></i>Download
|
||||
@@ -122,21 +122,91 @@
|
||||
{% if job.file_name %}
|
||||
<div class="text-white mb-3 font-monospace" style="font-size:.85rem">{{ job.file_name }}</div>
|
||||
{% endif %}
|
||||
{% if stl %}
|
||||
<table class="table table-dark table-sm mb-0" style="font-size:.875rem">
|
||||
{% if file_missing %}
|
||||
<div class="alert alert-danger py-2 px-3 mb-0" style="font-size:.875rem">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||
File <code>{{ job.file_name }}</code> not found on disk.
|
||||
</div>
|
||||
{% elif analysis_failed %}
|
||||
<div class="alert alert-warning py-2 px-3 mb-2" style="font-size:.875rem">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
Analysis failed or timed out — enter values from your slicer below.
|
||||
</div>
|
||||
<form method="POST" action="/jobs/{{ job.id }}/geometry" class="d-flex flex-column gap-2" style="font-size:.8rem">
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<label class="text-secondary" style="width:70px">X mm</label>
|
||||
<input type="number" step="0.1" name="cube_x" value="" required
|
||||
class="form-control form-control-sm bg-dark border-secondary text-white" style="width:100px">
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<label class="text-secondary" style="width:70px">Y mm</label>
|
||||
<input type="number" step="0.1" name="cube_y" value="" required
|
||||
class="form-control form-control-sm bg-dark border-secondary text-white" style="width:100px">
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<label class="text-secondary" style="width:70px">Z mm</label>
|
||||
<input type="number" step="0.1" name="cube_z" value="" required
|
||||
class="form-control form-control-sm bg-dark border-secondary text-white" style="width:100px">
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<label class="text-secondary" style="width:70px">Vol cm³</label>
|
||||
<input type="number" step="0.001" name="volume_ccm" value="" required
|
||||
class="form-control form-control-sm bg-dark border-secondary text-white" style="width:100px">
|
||||
</div>
|
||||
<div><button type="submit" class="btn btn-sm btn-outline-secondary py-0 px-2">Save</button></div>
|
||||
</form>
|
||||
{% elif computing %}
|
||||
<div class="text-secondary small d-flex align-items-center gap-2" id="computing-state">
|
||||
<div class="spinner-border spinner-border-sm text-secondary" role="status"></div>
|
||||
Computing geometry…
|
||||
</div>
|
||||
<script>setTimeout(() => location.reload(), 3000);</script>
|
||||
{% elif stl %}
|
||||
<table class="table table-dark table-sm mb-2" style="font-size:.875rem">
|
||||
<tbody>
|
||||
<tr><td class="text-secondary border-0">Bounding box</td>
|
||||
<td class="border-0">
|
||||
{{ stl.cube_x|round(1) }} × {{ stl.cube_y|round(1) }} × {{ stl.cube_z|round(1) }} mm
|
||||
</td></tr>
|
||||
<tr><td class="text-secondary">Volume</td>
|
||||
<td>{{ stl.volume_ccm }} cm³</td></tr>
|
||||
<td>
|
||||
{{ stl.volume_ccm }} cm³
|
||||
{% if stl.manually_set %}<span class="badge bg-secondary ms-1" style="font-size:.65rem">manual</span>{% endif %}
|
||||
</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<details class="mt-1" style="font-size:.8rem">
|
||||
<summary class="text-secondary" style="cursor:pointer">Override values</summary>
|
||||
<form method="POST" action="/jobs/{{ job.id }}/geometry" class="mt-2 d-flex flex-column gap-2">
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<label class="text-secondary" style="width:70px">X mm</label>
|
||||
<input type="number" step="0.1" name="cube_x" value="{{ stl.cube_x|round(2) }}"
|
||||
class="form-control form-control-sm bg-dark border-secondary text-white" style="width:100px">
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<label class="text-secondary" style="width:70px">Y mm</label>
|
||||
<input type="number" step="0.1" name="cube_y" value="{{ stl.cube_y|round(2) }}"
|
||||
class="form-control form-control-sm bg-dark border-secondary text-white" style="width:100px">
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<label class="text-secondary" style="width:70px">Z mm</label>
|
||||
<input type="number" step="0.1" name="cube_z" value="{{ stl.cube_z|round(2) }}"
|
||||
class="form-control form-control-sm bg-dark border-secondary text-white" style="width:100px">
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<label class="text-secondary" style="width:70px">Vol cm³</label>
|
||||
<input type="number" step="0.001" name="volume_ccm" value="{{ stl.volume_ccm }}"
|
||||
class="form-control form-control-sm bg-dark border-secondary text-white" style="width:100px">
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary py-0 px-2">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
{% elif job.file_name and job.file_name.endswith('.gcode') %}
|
||||
<p class="text-secondary small mb-0">Geometry not available for gcode files.</p>
|
||||
{% elif job.file_name %}
|
||||
<p class="text-secondary small mb-0">Not yet analysed.</p>
|
||||
<p class="text-secondary small mb-0">Analysis failed — check file format.</p>
|
||||
{% else %}
|
||||
<p class="text-secondary small mb-0">No file attached.</p>
|
||||
{% endif %}
|
||||
@@ -155,6 +225,7 @@
|
||||
{{ (estimation.material_factor * 100) | round | int }}% material factor
|
||||
· {{ estimation.print_speed | int }} g/h
|
||||
· {{ estimation.price_per_kg }} €/kg
|
||||
· {{ estimation.printer_cost_per_hour }} €/h printer
|
||||
· {{ estimation.vat_rate }}% VAT
|
||||
</span>
|
||||
</div>
|
||||
@@ -188,6 +259,8 @@
|
||||
<td class="border-0">{{ "%.2f"|format(estimation.filament_cost) }} €</td></tr>
|
||||
<tr><td class="text-secondary">+ 20 % misprint</td>
|
||||
<td>{{ "%.2f"|format(estimation.filament_cost * 1.2) }} €</td></tr>
|
||||
<tr><td class="text-secondary">Printer ({{ estimation.printer_cost_per_hour }} €/h)</td>
|
||||
<td>{{ "%.2f"|format(estimation.printer_cost) }} €</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,11 @@
|
||||
<span class="badge bg-secondary fs-6 fw-normal px-3 py-2">
|
||||
<i class="bi bi-collection me-1"></i>{{ active_bracket.name }}
|
||||
</span>
|
||||
{% if bracket_gross_total is not none %}
|
||||
<span class="badge bg-dark border border-secondary fs-6 fw-normal px-3 py-2 text-white">
|
||||
<i class="bi bi-currency-euro me-1 text-secondary"></i>{{ "%.2f"|format(bracket_gross_total) }} <span class="text-secondary fw-normal" style="font-size:.75em">gross total</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
<a href="/jobs{% if q %}?q={{ q }}{% endif %}" class="text-secondary text-decoration-none small">× clear filter</a>
|
||||
</div>
|
||||
{% elif q %}
|
||||
@@ -45,6 +50,7 @@
|
||||
<th class="fw-normal">Material</th>
|
||||
<th class="fw-normal">Status</th>
|
||||
<th class="fw-normal text-end">Created</th>
|
||||
<th class="fw-normal"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -74,14 +80,21 @@
|
||||
</td>
|
||||
<td>
|
||||
{% set s = j.status.value %}
|
||||
{% if s == 'printing' %}<span class="badge bg-primary">printing</span>
|
||||
{% if s == 'printing' %}<span class="badge bg-warning text-dark">printing</span>
|
||||
{% elif s == 'done' %}<span class="badge bg-success">done</span>
|
||||
{% elif s == 'failed' %}<span class="badge bg-danger">failed</span>
|
||||
{% elif s == 'cancelled' %}<span class="badge bg-warning text-dark">cancelled</span>
|
||||
{% else %}<span class="badge bg-secondary">queued</span>
|
||||
{% elif s == 'cancelled' %}<span class="badge bg-secondary">cancelled</span>
|
||||
{% else %}<span class="badge border border-warning text-warning" style="background:#000">queued</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-secondary text-end">{{ j.created_at.strftime('%d.%m.%Y %H:%M') }}</td>
|
||||
<td class="text-end" style="width:32px">
|
||||
<form method="POST" action="/jobs/{{ j.id }}/duplicate" class="d-inline">
|
||||
<button type="submit" class="btn btn-sm btn-link text-secondary p-0" title="Duplicate job">
|
||||
<i class="bi bi-files"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
@@ -16,6 +16,23 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── Currently printing ── #}
|
||||
{% if current_job %}
|
||||
<a href="/jobs/{{ current_job.id }}" class="text-decoration-none">
|
||||
<div class="card mb-4 border-primary">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<i class="bi bi-printer text-primary fs-4"></i>
|
||||
<div>
|
||||
<div class="text-secondary small text-uppercase">Currently printing</div>
|
||||
<div class="text-white fw-semibold">{{ current_job.file_name or current_job.name or '—' }}</div>
|
||||
{% if current_job.customer %}<div class="text-secondary small">{{ current_job.customer }}</div>{% endif %}
|
||||
</div>
|
||||
<i class="bi bi-chevron-right text-secondary ms-auto"></i>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
|
||||
{# ── Info card ── #}
|
||||
|
||||
@@ -2,39 +2,63 @@
|
||||
{% block title %}To-Do — POPS{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h5 class="text-white mb-4">To-Do</h5>
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<h5 class="text-white mb-0">To-Do</h5>
|
||||
</div>
|
||||
|
||||
{# ── Add task ── #}
|
||||
<form method="POST" action="/todos" class="mb-3 d-flex gap-2" style="max-width:480px">
|
||||
<input type="text" name="title" required
|
||||
class="form-control form-control-sm bg-dark border-secondary text-white"
|
||||
placeholder="New task…">
|
||||
<button type="submit" class="btn btn-sm btn-primary px-3">Add</button>
|
||||
</form>
|
||||
|
||||
<div class="card">
|
||||
{% if todos %}
|
||||
<table class="table table-dark table-hover mb-0 align-middle">
|
||||
<table class="table table-dark table-hover mb-0 align-middle" style="font-size:.875rem">
|
||||
<thead>
|
||||
<tr class="text-secondary small">
|
||||
<th class="fw-normal" style="width:32px"></th>
|
||||
<th class="fw-normal">Task</th>
|
||||
<th class="fw-normal">Printer</th>
|
||||
<th class="fw-normal text-center">Done</th>
|
||||
<th class="fw-normal text-end">Created</th>
|
||||
<th class="fw-normal" style="width:40px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for t in todos %}
|
||||
<tr class="{% if t.done %}opacity-50{% endif %}">
|
||||
<td class="{% if t.done %}text-decoration-line-through text-secondary{% else %}text-white{% endif %}">
|
||||
{{ t.title }}
|
||||
</td>
|
||||
<td class="text-secondary small">{{ t.printer.name if t.printer else '—' }}</td>
|
||||
<td class="text-center">
|
||||
{% if t.done %}
|
||||
{% set done = t.done %}
|
||||
<tr class="{% if done %}opacity-50{% endif %}">
|
||||
<td>
|
||||
{% if done %}
|
||||
<i class="bi bi-check-circle-fill text-success"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-circle text-secondary"></i>
|
||||
<form method="POST" action="/todos/{{ t.id }}/done" class="m-0">
|
||||
<button type="submit" class="btn btn-link p-0 text-secondary" title="Mark done">
|
||||
<i class="bi bi-circle"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-secondary small text-end">{{ t.created_at.strftime('%d.%m.%Y') }}</td>
|
||||
<td class="{% if done %}text-decoration-line-through text-secondary{% else %}text-white{% endif %}">
|
||||
{{ t.title }}
|
||||
</td>
|
||||
<td class="text-secondary small text-end text-nowrap">
|
||||
{{ t.created[:10] if t.created else '—' }}
|
||||
</td>
|
||||
<td>
|
||||
<form method="POST" action="/todos/{{ t.id }}/delete" class="m-0 text-end">
|
||||
<button type="submit" class="btn btn-link p-0 text-secondary" title="Delete">
|
||||
<i class="bi bi-trash3"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="card-body text-secondary">No to-do items yet.</div>
|
||||
<div class="card-body text-secondary">No tasks. Nice.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
114
app/vikunja.py
Normal file
114
app/vikunja.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Vikunja task API client."""
|
||||
import logging
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger("pops")
|
||||
|
||||
_pops_label_id: int | None = None
|
||||
|
||||
|
||||
def _base() -> str:
|
||||
return os.environ.get("VIKUNJA_URL", "").rstrip("/")
|
||||
|
||||
|
||||
def _headers() -> dict:
|
||||
return {
|
||||
"Authorization": f"Bearer {os.environ.get('VIKUNJA_API_TOKEN', '')}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def _project_id() -> int:
|
||||
return int(os.environ.get("VIKUNJA_PROJECT_ID", 0))
|
||||
|
||||
|
||||
def _get_or_create_pops_label() -> int | None:
|
||||
"""Return the ID of the 'POPS' label, creating it if it doesn't exist."""
|
||||
global _pops_label_id
|
||||
if _pops_label_id is not None:
|
||||
return _pops_label_id
|
||||
try:
|
||||
r = httpx.get(f"{_base()}/api/v1/labels", headers=_headers(), timeout=5)
|
||||
r.raise_for_status()
|
||||
for label in r.json() or []:
|
||||
if label.get("title") == "POPS":
|
||||
_pops_label_id = label["id"]
|
||||
return _pops_label_id
|
||||
# Not found — create it
|
||||
r = httpx.put(
|
||||
f"{_base()}/api/v1/labels",
|
||||
headers=_headers(),
|
||||
json={"title": "POPS"},
|
||||
timeout=5,
|
||||
)
|
||||
r.raise_for_status()
|
||||
_pops_label_id = r.json()["id"]
|
||||
return _pops_label_id
|
||||
except Exception:
|
||||
logger.warning("Vikunja: failed to get or create POPS label")
|
||||
return None
|
||||
|
||||
|
||||
def get_tasks() -> list[dict]:
|
||||
try:
|
||||
r = httpx.get(
|
||||
f"{_base()}/api/v1/projects/{_project_id()}/tasks",
|
||||
headers=_headers(),
|
||||
params={"sort_by": "created", "order_by": "desc"},
|
||||
timeout=5,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json() or []
|
||||
except Exception:
|
||||
logger.warning("Vikunja: failed to fetch tasks")
|
||||
return []
|
||||
|
||||
|
||||
def create_task(title: str) -> dict | None:
|
||||
label_id = _get_or_create_pops_label()
|
||||
payload: dict = {"title": title}
|
||||
if label_id:
|
||||
payload["labels"] = [{"id": label_id}]
|
||||
try:
|
||||
r = httpx.put(
|
||||
f"{_base()}/api/v1/projects/{_project_id()}/tasks",
|
||||
headers=_headers(),
|
||||
json=payload,
|
||||
timeout=5,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception:
|
||||
logger.warning("Vikunja: failed to create task %r", title)
|
||||
return None
|
||||
|
||||
|
||||
def update_task(task_id: int, **fields) -> bool:
|
||||
try:
|
||||
r = httpx.post(
|
||||
f"{_base()}/api/v1/tasks/{task_id}",
|
||||
headers=_headers(),
|
||||
json=fields,
|
||||
timeout=5,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return True
|
||||
except Exception:
|
||||
logger.warning("Vikunja: failed to update task %s", task_id)
|
||||
return False
|
||||
|
||||
|
||||
def delete_task(task_id: int) -> bool:
|
||||
try:
|
||||
r = httpx.delete(
|
||||
f"{_base()}/api/v1/tasks/{task_id}",
|
||||
headers=_headers(),
|
||||
timeout=5,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return True
|
||||
except Exception:
|
||||
logger.warning("Vikunja: failed to delete task %s", task_id)
|
||||
return False
|
||||
@@ -7,4 +7,6 @@ cryptography==44.0.3
|
||||
jinja2==3.1.4
|
||||
python-multipart==0.0.20
|
||||
trimesh==4.5.3
|
||||
networkx
|
||||
lxml
|
||||
httpx==0.28.1
|
||||
|
||||
Reference in New Issue
Block a user