Grotti 3.6.0: Lauf-Identität nach Filament-Pause, robustere Queue, Projekte/Aufträge list-first und API-Polish.

This commit is contained in:
Dominic Gundel committed 2026-10-25 08:24:24 +01:00
1 parent d1eb87ba25
commit 2b1e36634a
53 files changed
+4254 -636

No files matched your search

+236 -19
View File
@@ -48,6 +48,8 @@ TOKEN_QUERY_PATHS = (
"/api/v1/kamera",
"/api/v1/kamera/stream",
"/api/v1/vorschau",
"/api/v1/dateien/",
"/api/v1/projekte/",
)
STEER_ACTIONS = {
"nozzle", "bed", "speed", "flow", "fan", "emergency_stop", "clear_error",
@@ -99,6 +101,7 @@ def create_app(cfg):
"/setup",
"/api/v1/auth/status",
"/api/v1/auth/login",
"/api/v1/auth/token",
"/api/v1/auth/setup",
"/api/v1/version",
}
@@ -454,6 +457,39 @@ def create_app(cfg):
store.add_event(conn(), "", "", "auth", "Login %s" % row["username"])
return jsonify({"user": grotti_auth.public_user(row), "csrf": session["csrf"]})
@app.post("/api/v1/auth/token")
def auth_token():
"""Passwort → API-Token (für Android/App). Keine Session, Rate-Limit wie Login."""
if store.user_count(conn()) == 0:
raise GrottiError("Zuerst Admin anlegen.", "setup", 503)
key = request.remote_addr or "unknown"
if not grotti_auth.login_rate_ok(key):
raise GrottiError("Zu viele Versuche. Bitte warten.", "rate", 429)
data = body()
username = (data.get("username") or "").strip()
password = data.get("password") or ""
device = (data.get("device_name") or data.get("name") or "App").strip()[:80] or "App"
row = store.get_user_by_name(conn(), username)
if not row or not row.get("active") or not grotti_auth.verify_password(row["password_hash"], password):
grotti_auth.login_rate_hit(key)
raise GrottiError("Benutzer oder Passwort falsch.", "auth", 401)
store.touch_user_login(conn(), row["id"])
raw = grotti_auth.new_api_token()
role = grotti_auth.clamp_token_role(row["role"], data.get("role"))
token_row = store.create_api_token(
conn(),
row["id"],
device,
grotti_auth.hash_token(raw),
grotti_auth.token_prefix(raw),
role,
)
store.add_event(conn(), "", "", "auth", "Token „%s“ für %s" % (device, row["username"]))
item = grotti_auth.public_token(token_row)
item["token"] = raw
item["user"] = grotti_auth.public_user(row)
return jsonify(item), 201
@app.post("/api/v1/auth/logout")
def auth_logout():
session.clear()
@@ -811,7 +847,22 @@ def create_app(cfg):
@app.get("/api/v1/version")
def version_info():
return jsonify({"version": __version__})
return jsonify({
"name": "Grotti",
"version": __version__,
"api": 1,
"features": [
"auth_token",
"sse",
"queue",
"projects",
"orders",
"spools",
"camera",
"confirm",
"ntfy",
],
})
@app.get("/api/v1/einstellungen")
def settings():
@@ -969,6 +1020,8 @@ def create_app(cfg):
@app.post("/api/v1/drucker/<host>/<action>")
def printer_action(host, action):
require_host(host)
aliases = {"resume": "fortsetzen", "cancel": "abbrechen"}
action = aliases.get(action, action)
if action not in ("pause", "fortsetzen", "abbrechen"):
raise GrottiError("Unbekannter Befehl.", "befehl")
return jsonify(jobs.control(conn(), host, action))
@@ -989,7 +1042,7 @@ def create_app(cfg):
host = require_host(request.args.get("host", ""))
image = moonraker.camera_image(host)
if not image:
abort(404)
raise GrottiError("Kein Kamerabild.", "kamera", 404)
return image[1], 200, {"Content-Type": image[0], "Cache-Control": "no-store"}
@app.get("/api/v1/kamera/stream")
@@ -1048,7 +1101,8 @@ def create_app(cfg):
@app.get("/api/v1/projekte")
def project_list():
return jsonify(store.list_projects(conn()))
status = (request.args.get("status") or "").strip() or None
return jsonify(store.list_projects(conn(), status=status))
@app.post("/api/v1/projekte")
def project_create():
@@ -1083,11 +1137,83 @@ def create_app(cfg):
@app.get("/api/v1/projekte/<int:project_id>")
def project_get(project_id):
row = store.get_project(conn(), project_id)
row = store.get_project(conn(), project_id, watch_dir=cfg.get("watch_dir"))
if not row:
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
# Status „druckt“, wenn ein Lauf aktiv ist (nur Anzeige — kein Auto-Write ohne Wunsch)
return jsonify(row)
@app.get("/api/v1/projekte/<int:project_id>/laeufe")
def project_runs(project_id):
if not store.get_project(conn(), project_id):
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
try:
limit = max(1, min(200, int(request.args.get("limit") or 40)))
except (TypeError, ValueError):
limit = 40
try:
offset = max(0, int(request.args.get("offset") or 0))
except (TypeError, ValueError):
offset = 0
data = store.list_project_runs(conn(), project_id, limit=limit, offset=offset)
resp = jsonify(data["items"])
resp.headers["X-Total-Count"] = str(data["total"])
return resp
@app.post("/api/v1/projekte/<int:project_id>/einreihen")
def project_einreihen(project_id):
data = body()
if not bool(data.get("confirm")):
raise GrottiError("Bestätigung fehlt.", "bestaetigung")
project = store.get_project(conn(), project_id, watch_dir=cfg.get("watch_dir"))
if not project:
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
wanted = data.get("file_ids")
if isinstance(wanted, list) and wanted:
try:
wanted_ids = {int(x) for x in wanted}
except (TypeError, ValueError) as exc:
raise GrottiError("Ungültige Datei-IDs.", "datei") from exc
files = [f for f in (project.get("files") or []) if f["id"] in wanted_ids]
else:
files = list(project.get("files") or [])
if not files:
raise GrottiError("Keine Druckdateien zum Einreihen.", "datei")
try:
menge = max(1, min(50, int(data.get("menge") or 1)))
except (TypeError, ValueError):
menge = 1
try:
priority = int(data.get("priority", 100))
except (TypeError, ValueError):
priority = 100
host = optional_host(data.get("host") or "")
if host:
require_host(host)
bed = bool(data.get("bed_leveling"))
override = bool(data.get("override_machine"))
created = []
for file_row in files:
family = (data.get("family") or file_row.get("printer_family") or "").strip()
for _ in range(menge):
created.append(
store.enqueue_print(
conn(),
file_row["id"],
host,
family if not host else "",
priority,
optional_id(data.get("spool_id")),
data.get("ace_map") if isinstance(data.get("ace_map"), list) else [],
bed,
override,
)
)
# Status auf „druckt“ heben, wenn noch Idee/bereit
if project.get("status") in ("idee", "bereit"):
store.update_project(conn(), project_id, {"status": "druckt"})
return jsonify({"jobs": created, "count": len(created)}), 201
@app.patch("/api/v1/projekte/<int:project_id>")
def project_patch(project_id):
data = body()
@@ -1182,7 +1308,39 @@ def create_app(cfg):
@app.get("/api/v1/dateien")
def file_list():
library.sync_watch(conn(), cfg, force=False)
return jsonify(store.list_files(conn(), watch_dir=cfg.get("watch_dir")))
family = (request.args.get("family") or "").strip()
material = (request.args.get("material") or "").strip()
q = (request.args.get("q") or "").strip()
host = optional_host(request.args.get("host") or "")
project_id = optional_id(request.args.get("project_id") or request.args.get("project"))
limit_raw = request.args.get("limit")
offset_raw = request.args.get("offset")
limit = None
offset = 0
if limit_raw is not None and str(limit_raw).strip() != "":
try:
limit = max(1, min(500, int(limit_raw)))
except (TypeError, ValueError):
limit = 40
if offset_raw is not None:
try:
offset = max(0, int(offset_raw))
except (TypeError, ValueError):
offset = 0
data = store.list_files(
conn(),
watch_dir=cfg.get("watch_dir"),
family=family,
material=material,
project_id=project_id,
q=q,
host=host,
limit=limit,
offset=offset,
)
resp = jsonify(data["items"])
resp.headers["X-Total-Count"] = str(data["total"])
return resp
@app.post("/api/v1/dateien/<int:file_id>/meta")
def file_meta_refresh(file_id):
@@ -1267,10 +1425,10 @@ def create_app(cfg):
def file_preview(file_id):
row = store.get_file_row(conn(), file_id)
if not row:
abort(404)
raise GrottiError("Vorschau fehlt.", "vorschau", 404)
png = library.preview_png(row, conn=conn(), data_dir=cfg["data_dir"])
if not png:
abort(404)
raise GrottiError("Vorschau fehlt.", "vorschau", 404)
return png, 200, {"Content-Type": "image/png", "Cache-Control": "private, max-age=300"}
@app.get("/api/v1/dateien/<int:file_id>/pfad")
@@ -1411,9 +1569,12 @@ def create_app(cfg):
file_id = int(request.args.get("file_id") or 0)
except (TypeError, ValueError):
file_id = 0
if not host or not file_id:
raise GrottiError("Host und Datei nötig.", "senden")
row = store.latest_send_for_file(conn(), host, file_id)
if not host:
raise GrottiError("Host nötig.", "senden")
if file_id:
row = store.latest_send_for_file(conn(), host, file_id)
else:
row = store.latest_send_for_host(conn(), host)
if not row:
return jsonify(None)
return jsonify(row)
@@ -1508,7 +1669,18 @@ def create_app(cfg):
limit = max(1, min(200, int(request.args.get("limit") or 40)))
except (TypeError, ValueError):
limit = 40
return jsonify(store.list_print_queue(conn(), limit=limit))
try:
offset = max(0, int(request.args.get("offset") or 0))
except (TypeError, ValueError):
offset = 0
host = optional_host(request.args.get("host") or "")
status = (request.args.get("status") or "").strip()
data = store.list_print_queue(
conn(), limit=limit, offset=offset, host=host, status=status
)
resp = jsonify(data["items"])
resp.headers["X-Total-Count"] = str(data["total"])
return resp
@app.get("/api/v1/laeuft")
def running_now():
@@ -1590,7 +1762,10 @@ def create_app(cfg):
for item in running:
rem = float(item.get("remain") or 0)
item["free_at"] = jetzt_mod._iso_remain(rem) if rem > 0 else None
waiting = [j for j in store.list_print_queue(conn(), limit=80) if j.get("status") == "waiting"]
waiting = [
j for j in store.list_print_queue(conn(), limit=80)["items"]
if j.get("status") == "waiting"
]
return jsonify({
"running": running,
"waiting_count": len(waiting),
@@ -1694,15 +1869,14 @@ def create_app(cfg):
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)
# Live-Lauf schließen: Moonraker-History bleibt oft ohne Endzeit hängen,
# sonst bleibt der Druck im Protokoll ewig „in_progress“ trotz Urteil.
# Live-Lauf schließen ohne Abzug — History bucht; Fallback nach Frist.
if run_row and (run_row.get("state") or "") in ("in_progress", "printing", "paused"):
closed = store.complete_open_run(c, run_row["id"], state="completed")
if closed:
try:
jobs.apply_deduction(c, closed, cfg["energy"])
jobs.sync_history(c, cfg, host=host)
except Exception as exc:
log.warning("Abräumen Abzug %s: %s", host, exc)
log.warning("Abräumen Abgleich %s: %s", host, exc)
row = store.get_run_row(c, closed["id"]) or closed
run_json = store.run_out(row)
reprint = None
@@ -2196,7 +2370,28 @@ def create_app(cfg):
@app.get("/api/v1/spulen")
def spool_list():
return jsonify(store.list_spools(conn()))
aktiv_raw = (request.args.get("aktiv") or "").strip().lower()
aktiv = None
if aktiv_raw in ("1", "true", "yes"):
aktiv = True
elif aktiv_raw in ("0", "false", "no"):
aktiv = False
limit_raw = request.args.get("limit")
limit = None
offset = 0
if limit_raw is not None and str(limit_raw).strip() != "":
try:
limit = max(1, min(500, int(limit_raw)))
except (TypeError, ValueError):
limit = 100
try:
offset = max(0, int(request.args.get("offset") or 0))
except (TypeError, ValueError):
offset = 0
data = store.list_spools(conn(), aktiv=aktiv, limit=limit, offset=offset)
resp = jsonify(data["items"])
resp.headers["X-Total-Count"] = str(data["total"])
return resp
@app.get("/api/v1/spulen/by-code/<path:code>")
def spool_by_code(code):
@@ -2495,8 +2690,18 @@ def create_app(cfg):
run = store.run_out(row)
if run.get("chargen_nr") and run.get("objects"):
store.ensure_run_labels(conn(), run_id, run.get("objects") or [], qty=1)
if run.get("order_item_id") and not run.get("order_id"):
oi = conn().execute(
"SELECT order_id FROM order_items WHERE id = %s",
(run["order_item_id"],),
).fetchone()
if oi:
run["order_id"] = oi["order_id"]
file_row = store.get_file_row(conn(), run["file_id"]) if run.get("file_id") else None
project = store.get_project(conn(), run["project_id"]) if run.get("project_id") else None
project = (
store.get_project(conn(), run["project_id"], watch_dir=cfg.get("watch_dir"))
if run.get("project_id") else None
)
samples = store.list_run_samples(conn(), run_id)
from grotti.cost import energy_estimate
@@ -2512,7 +2717,7 @@ def create_app(cfg):
"samples": samples,
"labels": store.list_run_labels(conn(), run_id),
"events": store.list_run_events(conn(), run_id),
"file": store.file_out(file_row) if file_row else None,
"file": store.file_out(file_row, watch_dir=cfg.get("watch_dir")) if file_row else None,
"project": project,
"energy": energy,
})
@@ -2522,6 +2727,18 @@ def create_app(cfg):
library.sync_watch(conn(), cfg, force=False)
return jsonify(jobs.sync_history(conn(), cfg))
@app.post("/api/v1/laeufe/<int:run_id>/verbinden")
def run_merge(run_id):
"""Zwei Protokoll-Zeilen desselben physischen Drucks verbinden (admin)."""
require_role("admin")
data = body()
other = int(data.get("other_id") or data.get("mit") or 0)
if not other:
raise GrottiError("other_id fehlt.", "lauf")
return jsonify(jobs.manual_merge_runs(
conn(), run_id, other, confirm=bool(data.get("confirm"))
))
@app.patch("/api/v1/laeufe/<int:run_id>")
def run_patch(run_id):
data = body()