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:
1 parent
0236278929
commit
8e88efc094
45 files changed
+3121
-122
No files matched your search
+348
-6
@@ -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
|
||||
Reference in new issue
Block a user