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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
81
app/vikunja.py
Normal file
81
app/vikunja.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user