Compare commits
8 Commits
2fb08acd5b
...
726a4d3c02
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
726a4d3c02 | ||
|
|
5dfa700916 | ||
|
|
a2e8c9e194 | ||
|
|
223a1b7f1e | ||
|
|
8ecace0493 | ||
|
|
0569cd4f02 | ||
|
|
c94eeb071f | ||
|
|
cb5a2bc394 |
@@ -6,6 +6,9 @@ MYSQL_PASSWORD=
|
|||||||
STL_UPLOAD_DIR=/data/stl
|
STL_UPLOAD_DIR=/data/stl
|
||||||
TZ=Europe/Berlin
|
TZ=Europe/Berlin
|
||||||
FILAMENTDB_URL=https://web.filamentdb.orb.local
|
FILAMENTDB_URL=https://web.filamentdb.orb.local
|
||||||
|
VIKUNJA_URL=https://task.example.com
|
||||||
|
VIKUNJA_API_TOKEN=
|
||||||
|
VIKUNJA_PROJECT_ID=
|
||||||
VAT_RATE=19
|
VAT_RATE=19
|
||||||
FILAMENT_PRICE_PER_KG=15
|
FILAMENT_PRICE_PER_KG=15
|
||||||
MATERIAL_FACTOR=0.50
|
MATERIAL_FACTOR=0.50
|
||||||
|
|||||||
@@ -46,11 +46,12 @@ def estimate(volume_ccm: float, material: str | None) -> dict:
|
|||||||
PRINT_SPEED_G_PER_HOUR (env, default 32):
|
PRINT_SPEED_G_PER_HOUR (env, default 32):
|
||||||
~32 g/h matches Bambu Lab at normal quality; use ~20 for slower printers.
|
~32 g/h matches Bambu Lab at normal quality; use ~20 for slower printers.
|
||||||
"""
|
"""
|
||||||
vat_rate = float(os.environ.get("VAT_RATE", 19))
|
vat_rate = float(os.environ.get("VAT_RATE", 19))
|
||||||
price_per_kg = float(os.environ.get("FILAMENT_PRICE_PER_KG", 15))
|
price_per_kg = float(os.environ.get("FILAMENT_PRICE_PER_KG", 15))
|
||||||
base_factor = float(os.environ.get("MATERIAL_FACTOR", 0.50))
|
base_factor = float(os.environ.get("MATERIAL_FACTOR", 0.50))
|
||||||
print_speed = float(os.environ.get("PRINT_SPEED_G_PER_HOUR", 32))
|
print_speed = float(os.environ.get("PRINT_SPEED_G_PER_HOUR", 32))
|
||||||
density = _DENSITY.get(material or "", _DEFAULT_DENSITY)
|
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)
|
eff_factor = _effective_factor(volume_ccm, base_factor)
|
||||||
material_ccm = volume_ccm * eff_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
|
filament_cost = weight_g / 1000 * price_per_kg
|
||||||
after_misprint = filament_cost * 1.20
|
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)
|
gross_price = net_price * (1 + vat_rate / 100)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"material_ccm": round(material_ccm, 2),
|
"material_ccm": round(material_ccm, 2),
|
||||||
"weight_g": round(weight_g, 1),
|
"weight_g": round(weight_g, 1),
|
||||||
"print_minutes": print_minutes,
|
"print_minutes": print_minutes,
|
||||||
"filament_cost": round(filament_cost, 2),
|
"filament_cost": round(filament_cost, 2),
|
||||||
"net_price": round(net_price, 2),
|
"printer_cost": round(printer_cost, 2),
|
||||||
"gross_price": round(gross_price, 2),
|
"net_price": round(net_price, 2),
|
||||||
"vat_rate": vat_rate,
|
"gross_price": round(gross_price, 2),
|
||||||
"price_per_kg": price_per_kg,
|
"vat_rate": vat_rate,
|
||||||
"material_factor": eff_factor,
|
"price_per_kg": price_per_kg,
|
||||||
"print_speed": print_speed,
|
"material_factor": eff_factor,
|
||||||
|
"print_speed": print_speed,
|
||||||
|
"printer_cost_per_hour": printer_cost_per_hour,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter
|
||||||
from sqlalchemy.orm import Session
|
from app.vikunja import create_task, delete_task, get_tasks, update_task
|
||||||
|
|
||||||
from app.db import get_db
|
|
||||||
from app.models.todo import Todo
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/todos", tags=["todos"])
|
router = APIRouter(prefix="/todos", tags=["todos"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("/")
|
||||||
def list_todos(db: Session = Depends(get_db)):
|
def list_todos():
|
||||||
return [t.to_dict() for t in db.query(Todo).all()]
|
return get_tasks()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{todo_id}")
|
@router.post("/")
|
||||||
def get_todo(todo_id: int, db: Session = Depends(get_db)):
|
def create_todo(body: dict):
|
||||||
todo = db.query(Todo).filter(Todo.id == todo_id).first()
|
return create_task(body.get("title", ""))
|
||||||
if not todo:
|
|
||||||
from fastapi import HTTPException
|
|
||||||
raise HTTPException(status_code=404, detail="Todo not found")
|
@router.post("/{task_id}")
|
||||||
return todo.to_dict()
|
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_log import PrinterLog
|
||||||
from app.models.printer_type import PrinterType
|
from app.models.printer_type import PrinterType
|
||||||
from app.models.pricing import PricingConfig
|
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.constants import COLORS, MATERIALS
|
||||||
from app.estimation import estimate # also used in start_job
|
from app.estimation import estimate # also used in start_job
|
||||||
from app.filamentdb import (available_colors, available_materials, find_matching_ams_roll,
|
from app.filamentdb import (available_colors, available_materials, find_matching_ams_roll,
|
||||||
@@ -163,6 +163,21 @@ def jobs_page(request: Request, q: str = "", bracket_id: str = "", show_all: str
|
|||||||
)
|
)
|
||||||
jobs = query.order_by(status_order, PrintJob.priority.desc(), PrintJob.created_at.asc()).all()
|
jobs = query.order_by(status_order, PrintJob.priority.desc(), 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", {
|
return templates.TemplateResponse("jobs.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"active": "jobs",
|
"active": "jobs",
|
||||||
@@ -172,10 +187,11 @@ def jobs_page(request: Request, q: str = "", bracket_id: str = "", show_all: str
|
|||||||
"show_all": bool(show_all),
|
"show_all": bool(show_all),
|
||||||
"hidden_count": hidden_count,
|
"hidden_count": hidden_count,
|
||||||
"active_bracket": active_bracket,
|
"active_bracket": active_bracket,
|
||||||
|
"bracket_gross_total": bracket_gross_total,
|
||||||
"printers": db.query(Printer).order_by(Printer.name).all(),
|
"printers": db.query(Printer).order_by(Printer.name).all(),
|
||||||
"brackets": db.query(JobBracket).order_by(JobBracket.name).all(),
|
"brackets": db.query(JobBracket).order_by(JobBracket.name).all(),
|
||||||
"stl_files": (stl_files := list_stl_files()),
|
"stl_files": stl_files,
|
||||||
"stl_cache": get_cache_map(stl_files, db),
|
"stl_cache": stl_cache,
|
||||||
"materials": available_materials() or MATERIALS,
|
"materials": available_materials() or MATERIALS,
|
||||||
"colors": available_colors() or COLORS,
|
"colors": available_colors() or COLORS,
|
||||||
"spool_map": spool_counts(),
|
"spool_map": spool_counts(),
|
||||||
@@ -329,6 +345,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}"))
|
db.add(JobLog(job_id=job_id, message=f"Job started on {printer.name}"))
|
||||||
|
|
||||||
# Log filament usage against matching AMS roll in FilamentDB
|
# Log filament usage against matching AMS roll in FilamentDB
|
||||||
|
filament_low = False
|
||||||
if job.job_material and job.job_color:
|
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
|
stl = db.query(StlCache).filter_by(filename=job.file_name).first() if job.file_name else None
|
||||||
if stl and stl.volume_ccm:
|
if stl and stl.volume_ccm:
|
||||||
@@ -343,6 +360,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"(AMS slot {roll['slot']} — {roll['material']} {roll['color']}, "
|
||||||
f"{roll['weight_remaining_g']} g remaining, least-full roll selected)"
|
f"{roll['weight_remaining_g']} g remaining, least-full roll selected)"
|
||||||
)))
|
)))
|
||||||
|
if roll["weight_remaining_g"] - weight_g < 100:
|
||||||
|
filament_low = True
|
||||||
else:
|
else:
|
||||||
db.add(JobLog(job_id=job_id, message=f"FilamentDB: print log failed for roll #{roll['roll_id']}"))
|
db.add(JobLog(job_id=job_id, message=f"FilamentDB: print log failed for roll #{roll['roll_id']}"))
|
||||||
else:
|
else:
|
||||||
@@ -351,6 +370,8 @@ def start_job(job_id: int, printer_id: int = Form(...), db: Session = Depends(ge
|
|||||||
)))
|
)))
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
if filament_low:
|
||||||
|
vikunja_create("Check filament status")
|
||||||
return RedirectResponse(url=f"/jobs/{job_id}", status_code=HTTP_303_SEE_OTHER)
|
return RedirectResponse(url=f"/jobs/{job_id}", status_code=HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
@@ -406,7 +427,20 @@ def finish_job(job_id: int, db: Session = Depends(get_db)):
|
|||||||
job.printer.status = PrinterStatus.idle
|
job.printer.status = PrinterStatus.idle
|
||||||
|
|
||||||
db.add(JobLog(job_id=job_id, message=f"Job finished on {printer_name}"))
|
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()
|
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)
|
return RedirectResponse(url=f"/jobs/{job_id}", status_code=HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
@@ -457,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)
|
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)
|
@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", {
|
return templates.TemplateResponse("todos.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"active": "todos",
|
"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 ────────────────────────────────────────────────────────────────────
|
# ── Pricing ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/pricing", response_class=HTMLResponse)
|
@router.get("/pricing", response_class=HTMLResponse)
|
||||||
|
|||||||
@@ -37,7 +37,17 @@
|
|||||||
<nav id="sidebar" class="p-3 d-flex flex-column gap-3">
|
<nav id="sidebar" class="p-3 d-flex flex-column gap-3">
|
||||||
<a href="/" class="brand text-decoration-none pb-3">
|
<a href="/" class="brand text-decoration-none pb-3">
|
||||||
<div class="d-flex align-items-center gap-2">
|
<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>
|
||||||
<div class="text-white fw-semibold lh-sm">POPS</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>
|
<div class="text-secondary lh-sm" style="font-size:.68rem;letter-spacing:.03em">PRINT FARM MANAGER</div>
|
||||||
|
|||||||
@@ -86,11 +86,11 @@
|
|||||||
<td class="text-secondary small">{{ j.printer.name if j.printer else '—' }}</td>
|
<td class="text-secondary small">{{ j.printer.name if j.printer else '—' }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% set s = j.status.value %}
|
{% 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 == 'done' %}<span class="badge bg-success">done</span>
|
||||||
{% elif s == 'failed' %}<span class="badge bg-danger">failed</span>
|
{% elif s == 'failed' %}<span class="badge bg-danger">failed</span>
|
||||||
{% elif s == 'cancelled' %}<span class="badge bg-warning text-dark">cancelled</span>
|
{% elif s == 'cancelled' %}<span class="badge bg-secondary">cancelled</span>
|
||||||
{% else %}<span class="badge bg-secondary">queued</span>
|
{% else %}<span class="badge border border-warning text-warning" style="background:#000">queued</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-secondary small text-end">{{ j.created_at.strftime('%d.%m %H:%M') }}</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>
|
<h5 class="text-white mb-0">{{ job.file_name or job.name }}</h5>
|
||||||
{% set s = job.status.value %}
|
{% 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 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 == 'done' %}<span class="badge bg-success">done</span>
|
||||||
{% elif s == 'failed' %}<span class="badge bg-danger">failed</span>
|
{% elif s == 'failed' %}<span class="badge bg-danger">failed</span>
|
||||||
{% elif s == 'cancelled' %}<span class="badge bg-warning text-dark">cancelled</span>
|
{% elif s == 'cancelled' %}<span class="badge bg-secondary">cancelled</span>
|
||||||
{% else %}<span class="badge bg-secondary">queued</span>
|
{% else %}<span class="badge border border-warning text-warning" style="background:#000">queued</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-secondary small font-monospace mt-1">{{ job.job_uuid or '—' }}</div>
|
<div class="text-secondary small font-monospace mt-1">{{ job.job_uuid or '—' }}</div>
|
||||||
@@ -155,6 +155,7 @@
|
|||||||
{{ (estimation.material_factor * 100) | round | int }}% material factor
|
{{ (estimation.material_factor * 100) | round | int }}% material factor
|
||||||
· {{ estimation.print_speed | int }} g/h
|
· {{ estimation.print_speed | int }} g/h
|
||||||
· {{ estimation.price_per_kg }} €/kg
|
· {{ estimation.price_per_kg }} €/kg
|
||||||
|
· {{ estimation.printer_cost_per_hour }} €/h printer
|
||||||
· {{ estimation.vat_rate }}% VAT
|
· {{ estimation.vat_rate }}% VAT
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,6 +189,8 @@
|
|||||||
<td class="border-0">{{ "%.2f"|format(estimation.filament_cost) }} €</td></tr>
|
<td class="border-0">{{ "%.2f"|format(estimation.filament_cost) }} €</td></tr>
|
||||||
<tr><td class="text-secondary">+ 20 % misprint</td>
|
<tr><td class="text-secondary">+ 20 % misprint</td>
|
||||||
<td>{{ "%.2f"|format(estimation.filament_cost * 1.2) }} €</td></tr>
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,6 +26,11 @@
|
|||||||
<span class="badge bg-secondary fs-6 fw-normal px-3 py-2">
|
<span class="badge bg-secondary fs-6 fw-normal px-3 py-2">
|
||||||
<i class="bi bi-collection me-1"></i>{{ active_bracket.name }}
|
<i class="bi bi-collection me-1"></i>{{ active_bracket.name }}
|
||||||
</span>
|
</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>
|
<a href="/jobs{% if q %}?q={{ q }}{% endif %}" class="text-secondary text-decoration-none small">× clear filter</a>
|
||||||
</div>
|
</div>
|
||||||
{% elif q %}
|
{% elif q %}
|
||||||
@@ -74,11 +79,11 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% set s = j.status.value %}
|
{% 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 == 'done' %}<span class="badge bg-success">done</span>
|
||||||
{% elif s == 'failed' %}<span class="badge bg-danger">failed</span>
|
{% elif s == 'failed' %}<span class="badge bg-danger">failed</span>
|
||||||
{% elif s == 'cancelled' %}<span class="badge bg-warning text-dark">cancelled</span>
|
{% elif s == 'cancelled' %}<span class="badge bg-secondary">cancelled</span>
|
||||||
{% else %}<span class="badge bg-secondary">queued</span>
|
{% else %}<span class="badge border border-warning text-warning" style="background:#000">queued</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-secondary text-end">{{ j.created_at.strftime('%d.%m.%Y %H:%M') }}</td>
|
<td class="text-secondary text-end">{{ j.created_at.strftime('%d.%m.%Y %H:%M') }}</td>
|
||||||
|
|||||||
@@ -2,39 +2,63 @@
|
|||||||
{% block title %}To-Do — POPS{% endblock %}
|
{% block title %}To-Do — POPS{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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">
|
<div class="card">
|
||||||
{% if todos %}
|
{% 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>
|
<thead>
|
||||||
<tr class="text-secondary small">
|
<tr class="text-secondary small">
|
||||||
|
<th class="fw-normal" style="width:32px"></th>
|
||||||
<th class="fw-normal">Task</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 text-end">Created</th>
|
||||||
|
<th class="fw-normal" style="width:40px"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for t in todos %}
|
{% for t in todos %}
|
||||||
<tr class="{% if t.done %}opacity-50{% endif %}">
|
{% set done = t.done %}
|
||||||
<td class="{% if t.done %}text-decoration-line-through text-secondary{% else %}text-white{% endif %}">
|
<tr class="{% if done %}opacity-50{% endif %}">
|
||||||
{{ t.title }}
|
<td>
|
||||||
</td>
|
{% if done %}
|
||||||
<td class="text-secondary small">{{ t.printer.name if t.printer else '—' }}</td>
|
|
||||||
<td class="text-center">
|
|
||||||
{% if t.done %}
|
|
||||||
<i class="bi bi-check-circle-fill text-success"></i>
|
<i class="bi bi-check-circle-fill text-success"></i>
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="card-body text-secondary">No to-do items yet.</div>
|
<div class="card-body text-secondary">No tasks. Nice.</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% 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
|
||||||
Reference in New Issue
Block a user