Grotti 3.0.0: Aufträge, Wartung, SSE, Ausschuss-Dialog, UI-Umbau.

Migrationen 003–009, Kundaufträge mit Lieferschein, Abräumen mit Urteil,
ntfy/Backup/Zeitfenster, Live-Stream und Mobile-Mehr-Menü.
This commit is contained in:
Dominic Gundel committed 2026-10-18 00:54:51 +02:00
1 parent 0236278929
commit 8e88efc094
45 files changed
+3121 -122

No files matched your search

+348 -6
View File
@@ -11,7 +11,7 @@ from urllib.parse import quote
import markdown
import psycopg
from flask import Flask, abort, g, jsonify, redirect, render_template, request, session, url_for
from flask import Flask, abort, g, jsonify, redirect, render_template, request, send_file, session, url_for
from markupsafe import Markup
from psycopg.rows import dict_row
from werkzeug.middleware.proxy_fix import ProxyFix
@@ -21,13 +21,15 @@ from grotti import config as grotti_config
from grotti import db as grotti_db
from grotti import eject, jobs, library, moonraker, notify, store
from grotti.catalog import color_catalog
from grotti.cost import calculate
from grotti.cost import calculate, offer_from_file, order_actuals
from grotti import orders as grotti_orders
from grotti.errors import GrottiError
from grotti.printers import FAMILY_LABELS, family_from_name
from grotti.print_queue import PrintQueueDispatcher
from grotti.send_queue import SendQueue
from grotti.toolpath import TOOLPATH_VERSION, build_toolpath
from grotti.version import __version__
from grotti import verdict as grotti_verdict
from grotti.watcher import StateWatcher
ROOT = Path(__file__).resolve().parent
@@ -35,6 +37,7 @@ TOOL_ROOT = ROOT.parent
DOCS_DIR = TOOL_ROOT / "docs"
PROJECT_STATUS = {"idee", "bereit", "druckt", "fertig"}
VERDICTS = {"", "gut", "ausschuss"}
VERDICT_REASONS = sorted(grotti_verdict.VERDICT_REASONS)
IDEA_STATUS = {"offen", "spaeter", "erledigt"}
PRINTER_FAMILIES = set(FAMILY_LABELS) | {""}
UPLOAD_SUFFIXES = (".gcode", ".3mf", ".gcode.3mf")
@@ -96,6 +99,7 @@ def create_app(cfg):
ADMIN_API_PREFIXES = (
"/api/v1/users",
"/api/v1/einstellungen",
"/api/v1/backup",
)
ADMIN_API_EXACT = {
"/api/v1/telegram/test",
@@ -566,7 +570,9 @@ def create_app(cfg):
"Anleitung-Orca",
"Anleitung-ACE",
"Anleitung-Warteschlange",
"Anleitung-Auftraege",
"Anleitung-Material",
"Anleitung-Wartung",
"Anleitung-Einstellungen",
"Anleitung-Stoerungen",
)
@@ -587,7 +593,9 @@ def create_app(cfg):
"Anleitung-Orca": "Orca an Grotti",
"Anleitung-ACE": "ACE und Spulen",
"Anleitung-Warteschlange": "Warteschlange",
"Anleitung-Auftraege": "Aufträge",
"Anleitung-Material": "Material und mehr",
"Anleitung-Wartung": "Wartung",
"Anleitung-Einstellungen": "Einstellungen",
"Anleitung-Stoerungen": "Störungen",
}
@@ -620,7 +628,23 @@ def create_app(cfg):
@app.get("/projekte")
def projekte():
return page("projekte.html", "Projekte")
return redirect("/auftraege", code=301)
@app.get("/auftraege")
def auftraege_page():
return page("auftraege.html", "Aufträge")
@app.get("/auftraege/<int:order_id>/lieferschein")
def auftrag_lieferschein(order_id):
row = grotti_orders.get_order(conn(), order_id)
if not row:
abort(404)
return render_template(
"lieferschein.html",
page="auftraege",
title="Lieferschein #%s" % order_id,
order=row,
)
@app.get("/einstellungen")
def einstellungen_page():
@@ -666,6 +690,10 @@ def create_app(cfg):
def statistik_page():
return page("statistik.html", "Statistik")
@app.get("/wartung")
def wartung_page():
return page("wartung.html", "Wartung")
@app.get("/etikett/lauf/<int:run_id>")
def etikett_lauf(run_id):
row = store.get_run_row(conn(), run_id)
@@ -709,7 +737,7 @@ def create_app(cfg):
@app.get("/ideen")
def ideen():
return page("ideen.html", "Ideen")
return redirect("/auftraege", code=301)
@app.get("/aktivitaet")
def aktivitaet():
@@ -767,6 +795,7 @@ def create_app(cfg):
data = body()
energy = data.get("energy") if isinstance(data.get("energy"), dict) else None
telegram = data.get("telegram") if isinstance(data.get("telegram"), dict) else None
ntfy = data.get("ntfy") if isinstance(data.get("ntfy"), dict) else None
alerts = data.get("alerts") if isinstance(data.get("alerts"), dict) else None
auto_queue = data.get("auto_queue") if "auto_queue" in data else None
fqdn = data.get("fqdn") if "fqdn" in data else None
@@ -775,6 +804,7 @@ def create_app(cfg):
cfg,
energy=energy,
telegram=telegram,
ntfy=ntfy,
alerts=alerts,
auto_queue=auto_queue,
fqdn=fqdn,
@@ -1399,8 +1429,31 @@ def create_app(cfg):
if not bool(data.get("confirm")):
raise GrottiError("Bestätigung fehlt.", "bestaetigung")
require_host(host)
store.set_bed_needs_clear(conn(), host, False, "quittiert")
return jsonify({"ok": True, "host": host, "needs_clear": False, "message": "Platte quittiert."})
c = conn()
store.set_bed_needs_clear(c, host, False, "quittiert")
run_json = None
if any(k in data for k in ("verdict", "verdict_reason", "pieces_ok", "pieces_bad")):
run_row = store.run_for_abraeumen_verdict(c, host)
if not run_row:
raise GrottiError("Kein Lauf zum Bewerten — Protokoll prüfen.", "lauf", 404)
try:
verdict, reason, ok_n, bad_n = grotti_verdict.normalize_abraeumen_verdict(
data.get("verdict"),
data.get("verdict_reason"),
data.get("pieces_ok"),
data.get("pieces_bad"),
)
except ValueError as exc:
raise GrottiError(str(exc), "urteil") from exc
run_json = store.set_verdict(c, run_row["id"], verdict, reason, ok_n, bad_n)
return jsonify({
"ok": True,
"host": host,
"needs_clear": False,
"message": "Platte quittiert.",
"run": run_json,
"verdict_reasons": VERDICT_REASONS,
})
@app.get("/api/v1/abraeumen-defaults")
def abraeumen_defaults():
@@ -1667,6 +1720,191 @@ def create_app(cfg):
raise GrottiError("Die Idee fehlt.", "idee", 404)
return "", 204
@app.get("/api/v1/kunden")
def kunden_list():
return jsonify(grotti_orders.list_customers(conn()))
@app.post("/api/v1/kunden")
def kunden_create():
data = body()
name = (data.get("name") or "").strip()
if not name:
raise GrottiError("Der Kunde braucht einen Namen.", "name")
return jsonify(
grotti_orders.create_customer(
conn(),
name,
(data.get("email") or "").strip(),
(data.get("notes") or "").strip(),
)
), 201
@app.patch("/api/v1/kunden/<int:customer_id>")
def kunden_patch(customer_id):
data = body()
fields = {}
if "name" in data:
name = (data.get("name") or "").strip()
if not name:
raise GrottiError("Der Kunde braucht einen Namen.", "name")
fields["name"] = name
if "email" in data:
fields["email"] = (data.get("email") or "").strip()
if "notes" in data:
fields["notes"] = (data.get("notes") or "").strip()
row = grotti_orders.update_customer(conn(), customer_id, fields)
if not row:
raise GrottiError("Der Kunde fehlt.", "kunde", 404)
return jsonify(row)
@app.delete("/api/v1/kunden/<int:customer_id>")
def kunden_delete(customer_id):
if not grotti_orders.delete_customer(conn(), customer_id):
raise GrottiError("Der Kunde fehlt.", "kunde", 404)
return "", 204
@app.get("/api/v1/auftraege")
def auftraege_list():
status = (request.args.get("status") or "").strip().lower()
if status and status not in grotti_orders.ORDER_STATUSES:
raise GrottiError("Unbekannter Status.", "status")
return jsonify(grotti_orders.list_orders(conn(), status=status or None))
@app.post("/api/v1/auftraege")
def auftraege_create():
data = body()
title = (data.get("title") or "").strip()
if not title:
raise GrottiError("Der Auftrag braucht einen Titel.", "titel")
try:
status = grotti_orders.normalize_status(data.get("status") or "idee")
except ValueError as exc:
raise GrottiError(str(exc), "status") from exc
cid = optional_id(data.get("customer_id"))
return jsonify(
grotti_orders.create_order(
conn(),
title,
status,
customer_id=cid,
due_at=data.get("due_at"),
notes=(data.get("notes") or "").strip(),
quote=data.get("quote") if isinstance(data.get("quote"), dict) else {},
)
), 201
@app.get("/api/v1/auftraege/<int:order_id>")
def auftrag_get(order_id):
row = grotti_orders.get_order(conn(), order_id)
if not row:
raise GrottiError("Der Auftrag fehlt.", "auftrag", 404)
row["actuals"] = order_actuals(conn(), order_id)
return jsonify(row)
@app.patch("/api/v1/auftraege/<int:order_id>")
def auftrag_patch(order_id):
data = body()
fields = {}
if "title" in data:
title = (data.get("title") or "").strip()
if not title:
raise GrottiError("Der Auftrag braucht einen Titel.", "titel")
fields["title"] = title
if "status" in data:
try:
fields["status"] = grotti_orders.normalize_status(data.get("status"))
except ValueError as exc:
raise GrottiError(str(exc), "status") from exc
if "customer_id" in data:
fields["customer_id"] = optional_id(data.get("customer_id"))
if "due_at" in data:
fields["due_at"] = data.get("due_at")
if "notes" in data:
fields["notes"] = data.get("notes") or ""
if "quote" in data and isinstance(data.get("quote"), dict):
fields["quote"] = data["quote"]
row = grotti_orders.update_order(conn(), order_id, fields)
if not row:
raise GrottiError("Der Auftrag fehlt.", "auftrag", 404)
row["actuals"] = order_actuals(conn(), order_id)
return jsonify(row)
@app.delete("/api/v1/auftraege/<int:order_id>")
def auftrag_delete(order_id):
if not grotti_orders.delete_order(conn(), order_id):
raise GrottiError("Der Auftrag fehlt.", "auftrag", 404)
return "", 204
@app.post("/api/v1/auftraege/<int:order_id>/positionen")
def auftrag_position_create(order_id):
data = body()
file_id = optional_id(data.get("file_id"))
row = grotti_orders.create_order_item(
conn(),
order_id,
file_id=file_id,
qty=data.get("qty", 1),
unit_price=data.get("unit_price", 0),
material_type=(data.get("material_type") or "").strip(),
color=(data.get("color") or "").strip(),
note=(data.get("note") or "").strip(),
)
if not row:
raise GrottiError("Der Auftrag fehlt.", "auftrag", 404)
return jsonify(row), 201
@app.patch("/api/v1/auftraege/positionen/<int:item_id>")
def auftrag_position_patch(item_id):
data = body()
fields = {}
for key in ("file_id", "qty", "unit_price", "material_type", "color", "note"):
if key in data:
fields[key] = data[key] if key != "file_id" else optional_id(data.get("file_id"))
row = grotti_orders.update_order_item(conn(), item_id, fields)
if not row:
raise GrottiError("Die Position fehlt.", "position", 404)
return jsonify(row)
@app.delete("/api/v1/auftraege/positionen/<int:item_id>")
def auftrag_position_delete(item_id):
if not grotti_orders.delete_order_item(conn(), item_id):
raise GrottiError("Die Position fehlt.", "position", 404)
return "", 204
@app.post("/api/v1/auftraege/<int:order_id>/einreihen")
def auftrag_einreihen(order_id):
data = body()
if not bool(data.get("confirm")):
raise GrottiError("Bestätigung fehlt.", "bestaetigung")
try:
item_id = int(data.get("item_id"))
except (TypeError, ValueError) as exc:
raise GrottiError("Position fehlt.", "position") from exc
host = optional_host(data.get("host") or "")
family = (data.get("family") or "").strip()
if not host and not family:
file_row = None
item = grotti_orders.get_order_item(conn(), item_id)
if item and item.get("file_id"):
file_row = store.get_file_row(conn(), item["file_id"])
family = (file_row or {}).get("printer_family") or ""
try:
row = grotti_orders.enqueue_order_item(
conn(),
order_id,
item_id,
host=host,
family=family,
priority=int(data.get("priority", 100)),
spool_id=optional_id(data.get("spool_id")),
ace_map=data.get("ace_map") if isinstance(data.get("ace_map"), list) else [],
bed_leveling=bool(data.get("bed_leveling")),
override=bool(data.get("override_machine")),
)
except ValueError as exc:
raise GrottiError(str(exc), "auftrag") from exc
return jsonify(row), 201
@app.get("/api/v1/spulen")
def spool_list():
return jsonify(store.list_spools(conn()))
@@ -1989,6 +2227,75 @@ def create_app(cfg):
row = store.run_out(current)
return jsonify(row)
@app.get("/api/v1/bedarf")
def bedarf_api():
from grotti import bedarf as bedarf_mod
return jsonify(bedarf_mod.compute(conn()))
@app.get("/api/v1/wartung")
def wartung_api():
from grotti import maintenance
return jsonify(maintenance.list_for_api(conn(), store.list_printers(conn())))
@app.post("/api/v1/wartung/teile")
def wartung_teil_create():
from grotti import maintenance
data = body()
host = validate_printer_host(data.get("printer_host") or data.get("host") or "")
require_host(host)
try:
part = maintenance.create_part(
conn(),
host,
data.get("part_key") or "",
label=data.get("label") or "",
life_hours=data.get("life_hours") or 0,
life_meters=data.get("life_meters") or 0,
note=data.get("note") or "",
)
except ValueError as exc:
raise GrottiError(str(exc), "wartung") from exc
conn().commit()
return jsonify(part)
@app.post("/api/v1/wartung/teile/<int:part_id>/reset")
def wartung_teil_reset(part_id):
from grotti import maintenance
part = maintenance.reset_part(conn(), part_id)
if not part:
raise GrottiError("Teil fehlt.", "wartung", 404)
conn().commit()
return jsonify(part)
@app.post("/api/v1/backup")
def backup_create():
from grotti import backup as backup_mod
try:
meta = backup_mod.create_backup(cfg)
except RuntimeError as exc:
raise GrottiError(str(exc), "backup") from exc
return jsonify(meta)
@app.get("/api/v1/backup")
def backup_list():
from grotti import backup as backup_mod
return jsonify({"items": backup_mod.list_backups(cfg)})
@app.get("/api/v1/backup/<name>")
def backup_download(name):
from grotti import backup as backup_mod
path = backup_mod.backup_path(cfg, name)
if not path:
raise GrottiError("Backup fehlt.", "backup", 404)
return send_file(path, as_attachment=True, download_name=path.name)
@app.get("/api/v1/statistik")
def statistik_api():
since = request.args.get("since") or None
@@ -2056,4 +2363,39 @@ def create_app(cfg):
headers={"Content-Disposition": "attachment; filename=grotti-laeufe.csv"},
)
@app.get("/api/v1/stream")
def api_event_stream():
from flask import Response, stream_with_context
from grotti import events_bus
def generate():
q = events_bus.subscribe()
try:
while True:
item = events_bus.wait_event(q, 15.0)
if item is None:
yield "event: ping\ndata: {}\n\n"
continue
kind = item.get("kind") or "message"
payload = item.get("payload") or {}
yield "event: %s\ndata: %s\n\n" % (
kind,
json.dumps(payload, ensure_ascii=False),
)
except GeneratorExit:
pass
finally:
events_bus.unsubscribe(q)
return Response(
stream_with_context(generate()),
mimetype="text/event-stream; charset=utf-8",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
return app