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:
1 parent
d1eb87ba25
commit
2b1e36634a
53 files changed
+4254
-636
No files matched your search
+236
-19
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user