From a2e8c9e19495f9b67f6a1f170be846bd81a67fbb Mon Sep 17 00:00:00 2001 From: Martin Hohenberg Date: Fri, 19 Jun 2026 07:40:54 +0200 Subject: [PATCH] Replace internal todos with Vikunja API wrapper - New app/vikunja.py: get_tasks, create_task, update_task, delete_task - /todos page now reads from and writes to Vikunja (create, mark done, delete) - Auto-todos (clean build plate, filament low) post to Vikunja after DB commit - /api/todos router proxies to Vikunja - .env.install: VIKUNJA_URL, VIKUNJA_API_TOKEN, VIKUNJA_PROJECT_ID Co-Authored-By: Claude Sonnet 4.6 --- .env.install | 3 ++ app/routers/todos.py | 31 ++++++++------- app/routers/ui.py | 38 ++++++++++++++++--- app/templates/todos.html | 52 +++++++++++++++++++------- app/vikunja.py | 81 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 171 insertions(+), 34 deletions(-) create mode 100644 app/vikunja.py diff --git a/.env.install b/.env.install index 0e81669..0d90f13 100644 --- a/.env.install +++ b/.env.install @@ -6,6 +6,9 @@ MYSQL_PASSWORD= STL_UPLOAD_DIR=/data/stl TZ=Europe/Berlin FILAMENTDB_URL=https://web.filamentdb.orb.local +VIKUNJA_URL=https://vikunja.example.com +VIKUNJA_API_TOKEN= +VIKUNJA_PROJECT_ID= VAT_RATE=19 FILAMENT_PRICE_PER_KG=15 MATERIAL_FACTOR=0.50 diff --git a/app/routers/todos.py b/app/routers/todos.py index dcff07c..90c6062 100644 --- a/app/routers/todos.py +++ b/app/routers/todos.py @@ -1,21 +1,24 @@ -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("/") -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) diff --git a/app/routers/ui.py b/app/routers/ui.py index 0923b80..384355d 100644 --- a/app/routers/ui.py +++ b/app/routers/ui.py @@ -20,7 +20,7 @@ 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, @@ -353,6 +353,7 @@ def start_job(job_id: int, printer_id: int = Form(...), db: Session = Depends(ge if roll: note = (f"POPS {job.job_uuid[:8] if job.job_uuid else job.id}: " f"{job.file_name or job.name} on {printer.name}") + filament_low = False if log_print_on_roll(roll["roll_id"], weight_g, note): db.add(JobLog(job_id=job_id, message=( f"FilamentDB: logged {weight_g} g against roll #{roll['roll_id']} " @@ -360,7 +361,7 @@ def start_job(job_id: int, printer_id: int = Form(...), db: Session = Depends(ge f"{roll['weight_remaining_g']} g remaining, least-full roll selected)" ))) if roll["weight_remaining_g"] - weight_g < 100: - db.add(Todo(title="Check filament status")) + filament_low = True else: db.add(JobLog(job_id=job_id, message=f"FilamentDB: print log failed for roll #{roll['roll_id']}")) else: @@ -369,6 +370,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) @@ -426,15 +429,18 @@ def finish_job(job_id: int, db: Session = Depends(get_db)): 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: - db.add(Todo(title=f"Clean build plate on {printer_name}", printer_id=job.printer_id)) + 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) @@ -485,17 +491,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) diff --git a/app/templates/todos.html b/app/templates/todos.html index 3b1d62e..fc9279a 100644 --- a/app/templates/todos.html +++ b/app/templates/todos.html @@ -2,39 +2,63 @@ {% block title %}To-Do — POPS{% endblock %} {% block content %} -
To-Do
+
+
To-Do
+
+ +{# ── Add task ── #} +
+ + +
+
{% if todos %} - +
+ - - + {% for t in todos %} - - - - + - + + + {% endfor %}
TaskPrinterDone Created
- {{ t.title }} - {{ t.printer.name if t.printer else '—' }} - {% if t.done %} + {% set done = t.done %} +
+ {% if done %} {% else %} - +
+ +
{% endif %}
{{ t.created_at.strftime('%d.%m.%Y') }} + {{ t.title }} + + {{ t.created[:10] if t.created else '—' }} + +
+ +
+
{% else %} -
No to-do items yet.
+
No tasks. Nice.
{% endif %}
{% endblock %} diff --git a/app/vikunja.py b/app/vikunja.py new file mode 100644 index 0000000..b4be014 --- /dev/null +++ b/app/vikunja.py @@ -0,0 +1,81 @@ +"""Vikunja task API client.""" +import logging +import os + +import httpx + +logger = logging.getLogger("pops") + + +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_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: + try: + r = httpx.put( + f"{_base()}/api/v1/projects/{_project_id()}/tasks", + headers=_headers(), + json={"title": title}, + 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