Files
grotti/web/app.py
T

1329 lines
53 KiB
Python

"""Flask hängt die API und die Seiten an den Kern."""
import json
import re
import secrets
import uuid
from datetime import datetime
from pathlib import Path
from urllib.parse import quote
import markdown
import psycopg
from flask import Flask, abort, g, jsonify, render_template, request
from markupsafe import Markup
from psycopg.rows import dict_row
from grotti import config as grotti_config
from grotti import eject, jobs, library, moonraker, notify, store
from grotti.catalog import color_catalog
from grotti.cost import calculate
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.watcher import StateWatcher
ROOT = Path(__file__).resolve().parent
TOOL_ROOT = ROOT.parent
DOCS_DIR = TOOL_ROOT / "docs"
CHANGELOG = TOOL_ROOT / "CHANGELOG.md"
PROJECT_STATUS = {"idee", "bereit", "druckt", "fertig"}
VERDICTS = {"", "gut", "ausschuss"}
IDEA_STATUS = {"offen", "spaeter", "erledigt"}
PRINTER_FAMILIES = set(FAMILY_LABELS) | {""}
STEER_ACTIONS = {
"nozzle", "bed", "speed", "flow", "fan", "emergency_stop", "clear_error",
"exclude_object",
}
def create_app(cfg):
app = Flask(
__name__,
template_folder=str(ROOT / "templates"),
static_folder=str(ROOT / "static"),
)
app.config["GROTTI"] = cfg
app.config["TEMPLATES_AUTO_RELOAD"] = True
app.config["MAX_CONTENT_LENGTH"] = 256 * 1024 * 1024
app.json.ensure_ascii = False
app.extensions["send_queue"] = SendQueue(cfg["database_url"], cfg)
watcher = StateWatcher(cfg["database_url"], cfg)
dispatcher = PrintQueueDispatcher(cfg["database_url"], cfg, watcher=watcher)
app.extensions["print_queue"] = dispatcher
app.extensions["watcher"] = watcher
watcher.start()
dispatcher.start()
@app.before_request
def check_token():
token = cfg["token"]
if not token or not request.path.startswith("/api/"):
return None
presented = ""
header = request.headers.get("Authorization", "")
if header.startswith("Bearer "):
presented = header[7:]
elif request.method == "GET":
presented = request.args.get("token", "")
if not presented or not secrets.compare_digest(presented, token):
return jsonify({"error": "Token fehlt oder stimmt nicht.", "code": "token"}), 401
return None
@app.teardown_appcontext
def close_db(exc):
conn = g.pop("db", None)
if not conn:
return
try:
if exc:
conn.rollback()
else:
conn.commit()
finally:
conn.close()
@app.errorhandler(GrottiError)
def grotti_error(err):
conn = g.get("db")
if conn:
conn.rollback()
return jsonify({"error": err.message, "code": err.code}), err.status
def conn():
if "db" not in g:
g.db = psycopg.connect(cfg["database_url"], row_factory=dict_row)
return g.db
def body():
data = request.get_json(silent=True)
if not isinstance(data, dict):
raise GrottiError("JSON-Objekt erwartet.", "json")
return data
def optional_id(value):
if value in (None, "", 0, "0"):
return None
try:
return int(value)
except (TypeError, ValueError) as exc:
raise GrottiError("Die Nummer stimmt nicht.", "id") from exc
def require_host(host):
if not store.printer_by_host(conn(), host or ""):
raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404)
return host
def optional_host(host):
host = (host or "").strip()
if host:
require_host(host)
return host
@app.context_processor
def inject_version():
return {"version": __version__}
def page(name, title):
return render_template(name, page=name.split(".")[0], title=title, token=cfg["token"])
def doc_names():
if not DOCS_DIR.is_dir():
return []
return sorted(p.stem for p in DOCS_DIR.glob("*.md"))
def render_markdown(path):
text = path.read_text(encoding="utf-8")
html = markdown.markdown(text, extensions=["tables", "fenced_code", "sane_lists"])
return Markup(html)
@app.get("/")
def wand():
return page("wand.html", "Drucker")
@app.get("/kameras")
def kameras():
return page("kameras.html", "Kameras")
@app.get("/dateien")
def dateien():
return page("dateien.html", "Dateien")
@app.get("/projekte")
def projekte():
return page("projekte.html", "Projekte")
@app.get("/einstellungen")
def einstellungen_page():
return page("einstellungen.html", "Einstellungen")
@app.get("/material")
def material():
return page("material.html", "Material")
@app.get("/etikett/<int:spool_id>")
def etikett(spool_id):
row = store.get_spool_row(conn(), spool_id)
if not row:
abort(404)
spool = store.spool_out(row)
if not spool.get("chargen_nr"):
store.ensure_spool_codes(conn())
row = store.get_spool_row(conn(), spool_id)
spool = store.spool_out(row)
base = grotti_config.public_base(cfg, request.url_root)
qr_url = "%s/material?spool=%s" % (base, spool_id)
return render_template(
"etikett.html",
page="etikett",
title="Etikett %s" % spool["chargen_nr"],
token=cfg["token"],
spool=spool,
qr_url=qr_url,
)
@app.get("/protokoll")
def protokoll():
return page("protokoll.html", "Protokoll")
@app.get("/ideen")
def ideen():
return page("ideen.html", "Ideen")
@app.get("/aktivitaet")
def aktivitaet():
return page("aktivitaet.html", "Aktivität")
@app.get("/warteschlange")
def warteschlange_page():
return page("warteschlange.html", "Warteschlange")
@app.get("/doku")
def doku():
return render_template(
"doku.html",
page="doku",
title="Doku",
token=cfg["token"],
docs=doc_names(),
)
@app.get("/doku/<name>")
def doku_page(name):
if name == "changelog":
path, title = CHANGELOG, "Changelog"
elif name in doc_names():
path, title = DOCS_DIR / ("%s.md" % name), name.replace("-", " ")
else:
abort(404)
if not path.is_file():
abort(404)
return render_template(
"doku_page.html",
page="doku",
title=title,
token=cfg["token"],
content=render_markdown(path),
docs=doc_names(),
current=name,
)
@app.get("/api/v1/version")
def version_info():
return jsonify({"version": __version__, "changelog": "/doku/changelog"})
@app.get("/api/v1/einstellungen")
def settings():
return jsonify(grotti_config.settings_public(cfg))
@app.patch("/api/v1/einstellungen")
def settings_patch():
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
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
return jsonify(grotti_config.save_settings(
cfg, energy=energy, telegram=telegram, alerts=alerts, auto_queue=auto_queue, fqdn=fqdn,
))
@app.post("/api/v1/telegram/test")
def telegram_test():
ok = notify.send_telegram(cfg, "Grotti-Test: Telegram ist verbunden.")
if not ok:
raise GrottiError(
"Telegram nicht erreichbar. Token, Chat-ID und „aktiv“ prüfen.",
"telegram",
)
return jsonify({"ok": True})
@app.get("/api/v1/drucker")
@app.get("/api/drucker")
def printers():
rows = store.list_printers(conn())
clears = store.bed_clear_map(conn())
queues = store.queue_by_host(conn(), rows)
def attach(item):
host = item.get("host") or ""
item["needs_clear"] = bool(clears.get(host))
queue = queues.get(host) or {}
item["queue_running"] = queue.get("running")
item["queue_failed"] = queue.get("failed")
item["queue_waiting"] = queue.get("waiting", 0)
item["queue_waiting_next"] = queue.get("waiting_next", "")
by_id = {row["host"]: row for row in rows}
if request.args.get("live", "1") == "0":
for row in rows:
row["mainsail"] = "http://%s/" % row["host"]
attach(row)
return jsonify(rows)
# Eine Live-Quelle: Watcher. Frischer als 60 s, sonst einmal selbst abfragen.
watcher = app.extensions["watcher"]
cached = watcher.by_host()
age = watcher.age_s()
if not cached or age is None or age > 60:
lives = moonraker.snapshot(rows)
else:
lives = []
for row in rows:
live = cached.get(row["host"])
if live:
item = dict(live)
item["name"] = row["name"]
item["family"] = row["family"]
item["family_label"] = row["family_label"]
lives.append(item)
else:
item = dict(row)
item["ok"] = False
item["state"] = "offline"
item["mainsail"] = "http://%s/" % row["host"]
lives.append(item)
for live in lives:
meta = by_id.get(live.get("host") or "") or {}
live["locked"] = bool(meta.get("locked"))
attach(live)
return jsonify(lives)
@app.post("/api/v1/drucker")
def printer_create():
data = body()
name = (data.get("name") or "").strip()
host = (data.get("host") or "").strip()
if not name:
raise GrottiError("Der Drucker braucht einen Namen.", "name")
if not host:
raise GrottiError("Der Drucker braucht eine IP oder einen Host.", "host")
family = (data.get("family") or "").strip() or family_from_name(name)
if family not in PRINTER_FAMILIES:
raise GrottiError("Unbekannte Druckerfamilie.", "family")
if store.printer_by_host(conn(), host):
raise GrottiError("Diesen Host gibt es schon.", "host")
return jsonify(store.create_printer(conn(), uuid.uuid4(), name, host, family)), 201
@app.patch("/api/v1/drucker/id/<printer_id>")
def printer_patch(printer_id):
data = body()
current = store.printer_by_id(conn(), printer_id)
if not current:
raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404)
name = (data.get("name") if "name" in data else current["name"]) or ""
name = str(name).strip()
host = (data.get("host") if "host" in data else current["host"]) or ""
host = str(host).strip()
family = data.get("family") if "family" in data else current["family"]
family = (family or "").strip() or family_from_name(name)
if not name:
raise GrottiError("Der Drucker braucht einen Namen.", "name")
if not host:
raise GrottiError("Der Drucker braucht eine IP oder einen Host.", "host")
if family not in PRINTER_FAMILIES:
raise GrottiError("Unbekannte Druckerfamilie.", "family")
other = store.printer_by_host(conn(), host)
if other and str(other["id"]) != str(printer_id):
raise GrottiError("Diesen Host gibt es schon.", "host")
locked = data.get("locked") if "locked" in data else current.get("locked")
row = store.update_printer(conn(), printer_id, name, host, family, locked=bool(locked))
return jsonify(row)
@app.delete("/api/v1/drucker/id/<printer_id>")
def printer_delete(printer_id):
if not store.delete_printer(conn(), printer_id):
raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404)
return "", 204
@app.post("/api/v1/drucker/<host>/<action>")
def printer_action(host, action):
require_host(host)
if action not in ("pause", "fortsetzen", "abbrechen"):
raise GrottiError("Unbekannter Befehl.", "befehl")
return jsonify(jobs.control(conn(), host, action))
@app.post("/api/v1/drucker/<host>/steuern")
def printer_steer(host):
require_host(host)
data = body()
action = (data.get("action") or "").strip()
if action not in STEER_ACTIONS:
raise GrottiError("Unbekannte Steuerung.", "befehl")
if action in ("emergency_stop", "clear_error", "exclude_object") and not data.get("confirm"):
raise GrottiError("Bestätigung fehlt.", "bestaetigung")
return jsonify(jobs.steuern(conn(), host, action, data))
@app.get("/api/v1/kamera")
def camera():
host = require_host(request.args.get("host", ""))
image = moonraker.camera_image(host)
if not image:
abort(404)
return image[1], 200, {"Content-Type": image[0], "Cache-Control": "no-store"}
@app.get("/api/v1/vorschau")
def live_preview():
host = require_host(request.args.get("host", ""))
embedded_name = request.args.get("embedded", "")
if embedded_name:
png = moonraker.embedded_bytes(host, embedded_name)
if not png:
abort(404)
return png, 200, {"Content-Type": "image/png", "Cache-Control": "private, max-age=300"}
file_path = request.args.get("file", "").replace("\\", "/")
if ".." in file_path or ".thumbs/" not in file_path or not file_path.endswith(".png"):
abort(404)
try:
kind, data = moonraker.proxy_image(
host, "http://%s/server/files/gcodes/%s" % (host, quote(file_path, safe="/"))
)
except Exception:
abort(404)
return data, 200, {"Content-Type": kind, "Cache-Control": "private, max-age=300"}
@app.get("/api/v1/projekte")
def project_list():
return jsonify(store.list_projects(conn()))
@app.post("/api/v1/projekte")
def project_create():
data = body()
name = (data.get("name") or "").strip()
if not name:
raise GrottiError("Das Projekt braucht einen Namen.", "name")
status = data.get("status") or "idee"
if status not in PROJECT_STATUS:
raise GrottiError("Unbekannter Status.", "status")
return jsonify(store.create_project(conn(), name, (data.get("notes") or "").strip(), status)), 201
@app.get("/api/v1/projekte/<int:project_id>")
def project_get(project_id):
row = store.get_project(conn(), project_id)
if not row:
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
return jsonify(row)
@app.patch("/api/v1/projekte/<int:project_id>")
def project_patch(project_id):
data = body()
fields = {}
if "name" in data:
name = (data.get("name") or "").strip()
if not name:
raise GrottiError("Das Projekt braucht einen Namen.", "name")
fields["name"] = name
if "notes" in data:
fields["notes"] = data.get("notes") or ""
if "status" in data:
if data["status"] not in PROJECT_STATUS:
raise GrottiError("Unbekannter Status.", "status")
fields["status"] = data["status"]
row = store.update_project(conn(), project_id, fields)
if not row:
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
return jsonify(row)
@app.delete("/api/v1/projekte/<int:project_id>")
def project_delete(project_id):
if not store.delete_project(conn(), project_id):
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
return "", 204
@app.get("/api/v1/dateien")
def file_list():
library.sync_watch(conn(), cfg, force=False)
return jsonify(store.list_files(conn()))
@app.post("/api/v1/dateien/neu-lesen")
def file_rescan():
result = library.sync_watch(conn(), cfg, force=True)
result["metadaten"] = library.refresh_file_meta(conn(), force=True)
return jsonify(result)
@app.post("/api/v1/dateien")
def file_upload():
upload = request.files.get("file")
if not upload or not upload.filename:
raise GrottiError("Keine Datei angehängt.", "datei")
project_id = optional_id(request.form.get("project_id"))
saved = library.save_upload(conn(), upload.filename, upload.read(), cfg, project_id)
return jsonify(saved), 201
@app.get("/api/v1/dateien/<int:file_id>/vorschau")
def file_preview(file_id):
row = store.get_file_row(conn(), file_id)
if not row:
abort(404)
png = library.preview_png(row)
if not png:
abort(404)
return png, 200, {"Content-Type": "image/png", "Cache-Control": "private, max-age=300"}
@app.get("/api/v1/dateien/<int:file_id>/pfad")
def file_toolpath(file_id):
row = store.get_file_row(conn(), file_id)
if not row:
raise GrottiError("Die Datei fehlt.", "datei", 404)
path = Path(row["stored_path"])
if not path.is_file():
raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404)
cache_dir = Path(cfg["data_dir"]) / "toolpath"
cache_dir.mkdir(parents=True, exist_ok=True)
stamp = "%s-%s-%s-v%s" % (
file_id,
int(row.get("mtime") or 0),
int(row.get("size_bytes") or 0),
TOOLPATH_VERSION,
)
cache_path = cache_dir / (stamp + ".json")
if cache_path.is_file():
try:
data = json.loads(cache_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
data = None
else:
data = None
if not isinstance(data, dict) or data.get("version") != TOOLPATH_VERSION:
data = build_toolpath(path)
try:
cache_path.write_text(json.dumps(data), encoding="utf-8")
except OSError:
pass
data["file_id"] = file_id
data["name"] = row["name"]
return jsonify(data)
@app.patch("/api/v1/dateien/<int:file_id>")
def file_patch(file_id):
data = body()
fields = {}
if "project_id" in data:
fields["project_id"] = optional_id(data.get("project_id"))
if "spool_id" in data:
spool_id = optional_id(data.get("spool_id"))
if spool_id and not store.get_spool_row(conn(), spool_id):
raise GrottiError("Die Spule fehlt.", "spule", 404)
fields["spool_id"] = spool_id
row = store.update_file(conn(), file_id, fields)
if not row:
raise GrottiError("Die Datei fehlt.", "datei", 404)
return jsonify(row)
@app.delete("/api/v1/dateien/<int:file_id>")
def file_delete(file_id):
row = store.get_file_row(conn(), file_id)
if not row:
raise GrottiError("Die Datei fehlt.", "datei", 404)
if row["source"] != "upload":
raise GrottiError("Die Datei kommt aus dem Watch-Ordner und bleibt, solange sie dort liegt.", "watch")
store.delete_file(conn(), file_id)
Path(row["stored_path"]).unlink(missing_ok=True)
return "", 204
@app.post("/api/v1/senden")
def send():
data = body()
try:
file_id = int(data.get("file_id"))
except (TypeError, ValueError) as exc:
raise GrottiError("Die Datei fehlt.", "datei") from exc
hosts = data.get("hosts")
if isinstance(hosts, list):
hosts = [str(item or "").strip() for item in hosts if str(item or "").strip()]
else:
host = (data.get("host") or "").strip()
hosts = [host] if host else []
ace_maps = data.get("ace_maps") if isinstance(data.get("ace_maps"), dict) else {}
if not ace_maps and isinstance(data.get("ace_map"), list) and len(hosts) == 1:
ace_maps = {hosts[0]: data.get("ace_map")}
# when_free: in Druckwarteschlange statt sofort starten
if data.get("when_free"):
if not bool(data.get("confirm")):
raise GrottiError("Start braucht eine Bestätigung.", "bestaetigung")
file_row = store.get_file_row(conn(), file_id)
if not file_row:
raise GrottiError("Die Datei fehlt.", "datei", 404)
family = (data.get("family") or file_row.get("printer_family") or "").strip()
spool_id = optional_id(data.get("spool_id"))
bed = bool(data.get("bed_leveling"))
override = bool(data.get("override_machine"))
try:
priority = int(data.get("priority", 100))
except (TypeError, ValueError):
priority = 100
created = []
targets = hosts or [""]
for host in targets:
if host:
require_host(host)
# Nur die Map dieses Hosts — nie die eines anderen Druckers
# (bei „nächster freier“ host="" bleibt die Map leer bis zum Claim).
mapping = ace_maps.get(host) if host else []
if not isinstance(mapping, list):
mapping = []
if host:
jobs.assert_filament_enough(conn(), file_row, spool_id, mapping, host)
created.append(
store.enqueue_print(
conn(),
file_id,
host,
family if not host else "",
priority,
spool_id,
mapping,
bed,
override,
)
)
return jsonify({"jobs": created, "count": len(created), "mode": "when_free"}), 202
created = app.extensions["send_queue"].enqueue_many(
conn(),
file_id,
hosts,
bool(data.get("confirm")),
bool(data.get("override_machine")),
optional_id(data.get("spool_id")),
ace_maps,
bed_leveling=bool(data.get("bed_leveling")),
)
return jsonify({"jobs": created, "count": len(created), "mode": "now"}), 202
@app.get("/api/v1/sende-auftraege")
def send_list():
return jsonify(app.extensions["send_queue"].list_jobs())
@app.get("/api/v1/senden-zuletzt")
def send_last():
host = optional_host(request.args.get("host") or "")
try:
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 row:
return jsonify(None)
return jsonify(row)
@app.get("/api/v1/senden/<job_id>")
def send_status(job_id):
job = app.extensions["send_queue"].get(job_id)
if not job:
raise GrottiError("Der Auftrag fehlt.", "auftrag", 404)
return jsonify(job)
def enqueue_ablauf_payload(data):
"""Ablauf (Drucken/Abkühlen/Schieben) in die Druckwarteschlange."""
if not bool(data.get("confirm")):
raise GrottiError("Start braucht eine Bestätigung.", "bestaetigung")
hosts = data.get("hosts")
if isinstance(hosts, list):
hosts = [str(item or "").strip() for item in hosts if str(item or "").strip()]
else:
host = (data.get("host") or "").strip()
hosts = [host] if host else []
if not hosts:
raise GrottiError("Ablauf braucht einen Kobra-3-Drucker.", "drucker")
for host in hosts:
require_host(host)
printer = store.printer_by_host(conn(), host)
eject.require_kobra_3(printer)
fallback = {
"file_id": data.get("file_id"),
"spool_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_machine": bool(data.get("override_machine")),
}
ace_maps = data.get("ace_maps") if isinstance(data.get("ace_maps"), dict) else {}
steps = eject.normalize_steps(data.get("steps"), fallback)
for step in steps:
if step["type"] != "print":
continue
if not store.get_file_row(conn(), step["file_id"]):
raise GrottiError("Die Datei fehlt.", "datei", 404)
file_id = eject.first_print_file_id(steps)
try:
priority = int(data.get("priority", 100))
except (TypeError, ValueError):
priority = 100
try:
repeat_total = int(data.get("repeat_total", 1))
except (TypeError, ValueError):
repeat_total = 1
repeat_total = max(1, min(99, repeat_total))
created = []
for host in hosts:
mapping = ace_maps.get(host) if isinstance(ace_maps.get(host), list) else None
host_steps = []
for step in steps:
if step["type"] == "print" and mapping is not None:
copy = dict(step)
copy["ace_map"] = mapping
host_steps.append(copy)
else:
host_steps.append(step)
first = next(s for s in host_steps if s["type"] == "print")
file_row = store.get_file_row(conn(), first["file_id"])
jobs.assert_filament_enough(
conn(),
file_row,
first.get("spool_id"),
first.get("ace_map") or [],
host,
)
created.append(
store.enqueue_print(
conn(),
file_id,
host,
"kobra_3",
priority,
first.get("spool_id"),
first.get("ace_map") or [],
bool(first.get("bed_leveling")),
bool(first.get("override_machine")),
steps=host_steps,
repeat_total=repeat_total,
)
)
return {"jobs": created, "count": len(created), "mode": "ablauf"}
@app.get("/api/v1/druck-queue")
def print_queue_list():
try:
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))
@app.get("/api/v1/laeuft")
def running_now():
"""Was gerade druckt, auch ohne Auftrag in der Queue (Stand des Watchers)."""
items = []
for live in app.extensions["watcher"].latest or []:
state = (live.get("state") or "").lower()
if state not in ("printing", "paused", "heating"):
continue
items.append({
"host": live.get("host") or "",
"name": live.get("name") or "",
"state": state,
"progress": live.get("progress") or 0,
"remain": live.get("remain") or 0,
"filename": live.get("filename") or "",
})
return jsonify(items)
@app.get("/api/v1/aufgaben")
def open_tasks():
"""Platte belegt und kein Ablauf räumt sie: Hand muss ran."""
clears = store.bed_clear_map(conn())
busy = {
row["host"]
for row in conn().execute("SELECT DISTINCT host FROM print_queue WHERE status = 'running'").fetchall()
}
items = []
for live in app.extensions["watcher"].latest or []:
host = live.get("host") or ""
state = (live.get("state") or "").lower()
if not clears.get(host) or host in busy or state in ("printing", "paused", "heating"):
continue
items.append({
"host": host,
"name": live.get("name") or "",
"state": state,
"filename": live.get("filename") or "",
})
return jsonify(items)
@app.post("/api/v1/druck-queue")
def print_queue_create():
data = body()
if isinstance(data.get("steps"), list) and data.get("steps"):
return jsonify(enqueue_ablauf_payload(data)), 202
try:
file_id = int(data.get("file_id"))
except (TypeError, ValueError) as exc:
raise GrottiError("Die Datei fehlt.", "datei") from exc
if not store.get_file_row(conn(), file_id):
raise GrottiError("Die Datei fehlt.", "datei", 404)
host = optional_host(data.get("host") or "")
family = (data.get("family") or "").strip()
ace_map = data.get("ace_map") if isinstance(data.get("ace_map"), list) else []
try:
priority = int(data.get("priority", 100))
except (TypeError, ValueError):
priority = 100
row = store.enqueue_print(
conn(),
file_id,
host,
family,
priority,
optional_id(data.get("spool_id")),
ace_map,
bool(data.get("bed_leveling")),
bool(data.get("override_machine")),
)
return jsonify(row), 201
@app.post("/api/v1/ablauf")
def ablauf_create():
return jsonify(enqueue_ablauf_payload(body())), 202
@app.post("/api/v1/schieben-testen")
def schieben_testen():
"""Nur Testbahn aus dem Ablauf — nicht die Wand-Quittierung."""
data = body()
if not bool(data.get("confirm_empty")):
raise GrottiError("Bestätigung „Bett ist leer“ fehlt.", "bestaetigung")
host = require_host((data.get("host") or "").strip())
printer = store.printer_by_host(conn(), host)
eject.require_kobra_3(printer)
live = moonraker.query(host)
state = (live.get("state") or "").lower()
if live.get("ok") is False:
raise GrottiError("Drucker offline.", "offline")
if state in ("printing", "paused", "heating"):
raise GrottiError("Drucker ist beschäftigt.", "busy")
sweep = eject.normalize_sweep(data.get("sweep") if isinstance(data.get("sweep"), dict) else data)
homed = (live.get("homed_axes") or "").lower()
try:
if not all(axis in homed for axis in "xyz"):
# Bett ist laut Bestätigung leer → richtiges Homing ist sicher
moonraker.gcode_script(host, "G28", timeout=180)
moonraker.gcode_script(host, eject.sweep_script(sweep), timeout=180)
except moonraker.PrinterError as exc:
raise GrottiError(eject.explain_printer_error(exc.message), "drucker") from exc
store.set_bed_needs_clear(conn(), host, False, "Schieben-Test")
return jsonify({"ok": True, "host": host, "sweep": sweep, "message": "Bahn gefahren."})
@app.post("/api/v1/drucker/<host>/abraeumen")
def printer_abraeumen(host):
"""Quittierung: Platte ist frei. Fährt keine Bahn — nur für „wenn frei“."""
data = body()
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."})
@app.get("/api/v1/abraeumen-defaults")
def abraeumen_defaults():
return jsonify({
"cool": eject.DEFAULT_COOL,
"sweep": eject.DEFAULT_SWEEP,
"check": eject.DEFAULT_CHECK,
"limits": eject.LIMITS,
})
@app.patch("/api/v1/druck-queue/<int:item_id>")
def print_queue_patch(item_id):
data = body()
fields = {}
if "priority" in data:
try:
fields["priority"] = int(data.get("priority"))
except (TypeError, ValueError) as exc:
raise GrottiError("Priorität muss eine Zahl sein.", "zahl") from exc
if "status" in data:
status = data.get("status")
if status not in ("waiting", "cancelled", "error", "done", "running", "dismissed"):
raise GrottiError("Unbekannter Status.", "status")
current_row = store.get_print_queue_row(conn(), item_id)
if not current_row:
raise GrottiError("Der Queue-Eintrag fehlt.", "queue", 404)
# Von außen nur Abbrechen, Verwerfen oder Fortsetzen — kein freies done/running.
if status == "done":
raise GrottiError("Status „fertig“ setzt nur Grotti.", "status")
if status == "running" and not data.get("resume"):
raise GrottiError("Nur ein gestoppter Ablauf lässt sich fortsetzen.", "status")
if status == "waiting" and current_row.get("status") not in ("waiting",):
raise GrottiError("Status lässt sich so nicht setzen.", "status")
fields["status"] = status
if status == "running" and data.get("resume"):
row = current_row
if row.get("status") != "error" or not row.get("steps"):
raise GrottiError("Nur ein gestoppter Ablauf lässt sich fortsetzen.", "status")
fields["phase"] = ""
fields["phase_started_at"] = None
fields["message"] = "Wird fortgesetzt…"
store.add_event(
conn(), row.get("host") or "", row.get("printer_name") or "", "ablauf",
"%s Ablauf von Hand fortgesetzt bei Schritt %d (%s)" % (
row.get("printer_name") or "",
int(row.get("step_index") or 0) + 1,
store.print_queue_out(row).get("step_label_current") or "",
),
row.get("filename") or "",
)
if status == "cancelled":
fields["message"] = "Abgebrochen."
fields["phase"] = ""
fields["phase_started_at"] = None
row = store.get_print_queue_row(conn(), item_id)
if row:
was_running = row.get("status") == "running"
what = "Ablauf" if row.get("steps") else "Auftrag"
stopped = ""
# laufenden Druck auf dem Host best-effort stoppen
if was_running and row.get("host"):
try:
moonraker.command(row["host"], "abbrechen")
stopped = ", Druck gestoppt"
except Exception:
stopped = ", Druck nicht gestoppt"
store.add_event(
conn(),
row.get("host") or "",
row.get("printer_name") or "",
"ablauf",
"%s %s von Hand %s%s: %s" % (
row.get("printer_name") or "Warteschlange",
what,
"abgebrochen" if was_running else "entfernt",
stopped,
row.get("filename") or "",
),
row.get("filename") or "",
)
if "message" in data:
fields["message"] = str(data.get("message") or "")
row = store.update_print_queue(conn(), item_id, fields)
if not row:
raise GrottiError("Der Queue-Eintrag fehlt.", "queue", 404)
return jsonify(row)
@app.get("/api/v1/ereignisse")
def events_list():
unread = request.args.get("unread") == "1"
try:
limit = max(1, min(1000, int(request.args.get("limit") or 40)))
except (TypeError, ValueError):
limit = 40
kinds = [k for k in (request.args.get("kind") or "").split(",") if k.strip()]
since = None
if request.args.get("since"):
try:
since = datetime.fromisoformat(request.args["since"].replace("Z", "+00:00"))
except ValueError as exc:
raise GrottiError("since muss ein ISO-Zeitpunkt sein.", "since") from exc
return jsonify({
"items": store.list_events(
conn(),
limit=limit,
unread_only=unread,
host=(request.args.get("host") or "").strip() or None,
kinds=kinds or None,
since=since,
),
"unread": store.unread_event_count(conn()),
})
@app.post("/api/v1/ereignisse/gelesen")
def events_read():
data = body() if request.data else {}
ids = data.get("ids") if isinstance(data.get("ids"), list) else None
if ids is not None:
try:
ids = [int(item) for item in ids]
except (TypeError, ValueError) as exc:
raise GrottiError("IDs müssen Zahlen sein.", "id") from exc
unread = store.mark_events_read(conn(), ids)
return jsonify({"unread": unread})
@app.post("/api/v1/dateien/von-drucker")
def file_from_printer():
data = body()
host = require_host(data.get("host") or "")
filename = (data.get("filename") or "").strip()
saved = library.ensure_library_gcode(conn(), cfg, host, filename)
if not saved:
raise GrottiError("Datei konnte nicht vom Drucker geladen werden.", "datei")
return jsonify(saved), 201
@app.get("/api/v1/materialien")
def material_list():
return jsonify(store.list_materials(conn()))
@app.post("/api/v1/materialien")
def material_create():
data = body()
name = (data.get("name") or "").strip()
if not name:
raise GrottiError("Die Sorte braucht einen Namen.", "name")
machines = data.get("machines") or "beide"
if machines not in ("beide", "kobra_3", "kobra_s1"):
raise GrottiError("Unbekannte Maschinenwahl.", "machines")
try:
row = store.create_material(
conn(),
{
"name": name,
"brand": (data.get("brand") or "").strip(),
"material_type": (data.get("material_type") or "PLA").strip() or "PLA",
"density": float(data.get("density") or 1.24),
"volumetric": float(data.get("volumetric") or 12),
"volumetric_s1": (
float(data["volumetric_s1"])
if data.get("volumetric_s1") not in (None, "")
else None
),
"machines": machines,
"price_per_kg": float(data.get("price_per_kg") or 0),
"note": (data.get("note") or "").strip(),
},
)
except Exception as exc:
if "unique" in str(exc).lower() or "duplicate" in str(exc).lower():
raise GrottiError("Eine Sorte mit diesem Namen gibt es schon.", "name") from exc
raise
return jsonify(row), 201
@app.patch("/api/v1/materialien/<int:material_id>")
def material_patch(material_id):
data = body()
fields = {}
if "name" in data:
name = (data.get("name") or "").strip()
if not name:
raise GrottiError("Die Sorte braucht einen Namen.", "name")
fields["name"] = name
if "brand" in data:
fields["brand"] = (data.get("brand") or "").strip()
if "material_type" in data:
fields["material_type"] = (data.get("material_type") or "PLA").strip() or "PLA"
if "density" in data:
fields["density"] = float(data.get("density") or 1.24)
if "volumetric" in data:
fields["volumetric"] = float(data.get("volumetric") or 12)
if "volumetric_s1" in data:
raw = data.get("volumetric_s1")
fields["volumetric_s1"] = float(raw) if raw not in (None, "") else None
if "machines" in data:
machines = data.get("machines") or "beide"
if machines not in ("beide", "kobra_3", "kobra_s1"):
raise GrottiError("Unbekannte Maschinenwahl.", "machines")
fields["machines"] = machines
if "price_per_kg" in data:
fields["price_per_kg"] = float(data.get("price_per_kg") or 0)
if "note" in data:
fields["note"] = (data.get("note") or "").strip()
try:
row = store.update_material(conn(), material_id, fields)
except Exception as exc:
if "unique" in str(exc).lower() or "duplicate" in str(exc).lower():
raise GrottiError("Eine Sorte mit diesem Namen gibt es schon.", "name") from exc
raise
if not row:
raise GrottiError("Die Sorte fehlt.", "material", 404)
return jsonify(row)
@app.delete("/api/v1/materialien/<int:material_id>")
def material_delete(material_id):
result = store.delete_material(conn(), material_id)
if result == "in_use":
raise GrottiError("Sorte ist noch an Spulen gebunden.", "material")
if not result:
raise GrottiError("Die Sorte fehlt.", "material", 404)
return "", 204
@app.get("/api/v1/farben")
def color_list_api():
return jsonify(color_catalog())
@app.get("/api/v1/ideen")
def idea_list():
return jsonify(store.list_ideas(conn()))
@app.post("/api/v1/ideen")
def idea_create():
data = body()
title = (data.get("title") or "").strip()
if not title:
raise GrottiError("Die Idee braucht einen Titel.", "titel")
status = data.get("status") or "offen"
if status not in IDEA_STATUS:
raise GrottiError("Unbekannter Status.", "status")
return jsonify(store.create_idea(conn(), title, (data.get("body") or "").strip(), status)), 201
@app.patch("/api/v1/ideen/<int:idea_id>")
def idea_patch(idea_id):
data = body()
fields = {}
if "title" in data:
title = (data.get("title") or "").strip()
if not title:
raise GrottiError("Die Idee braucht einen Titel.", "titel")
fields["title"] = title
if "body" in data:
fields["body"] = (data.get("body") or "").strip()
if "status" in data:
if data.get("status") not in IDEA_STATUS:
raise GrottiError("Unbekannter Status.", "status")
fields["status"] = data.get("status")
row = store.update_idea(conn(), idea_id, fields)
if not row:
raise GrottiError("Die Idee fehlt.", "idee", 404)
return jsonify(row)
@app.delete("/api/v1/ideen/<int:idea_id>")
def idea_delete(idea_id):
if not store.delete_idea(conn(), idea_id):
raise GrottiError("Die Idee fehlt.", "idee", 404)
return "", 204
@app.get("/api/v1/spulen")
def spool_list():
return jsonify(store.list_spools(conn()))
@app.get("/api/v1/spulen/by-code/<path:code>")
def spool_by_code(code):
row = store.get_spool_by_code(conn(), code)
if not row:
raise GrottiError("Keine Spule mit diesem Code.", "spule", 404)
return jsonify(row)
@app.post("/api/v1/spulen")
def spool_create():
return jsonify(store.create_spool(conn(), spool_fields(body()))), 201
@app.patch("/api/v1/spulen/<int:spool_id>")
def spool_patch(spool_id):
row = store.update_spool(conn(), spool_id, spool_fields(body(), partial=True, spool_id=spool_id))
if not row:
raise GrottiError("Die Spule fehlt.", "spule", 404)
return jsonify(row)
@app.post("/api/v1/spulen/<int:spool_id>/einlegen")
def spool_load(spool_id):
"""Spule an Drucker/Platz legen; vorherige Rolle dort geht ins Regal."""
data = body()
host = require_host((data.get("host") or "").strip())
slot = ace_slot(data.get("ace_slot"))
spool = store.get_spool_row(conn(), spool_id)
if not spool:
raise GrottiError("Die Spule fehlt.", "spule", 404)
# Alte Rolle am Platz / externe Rolle am Host freimachen
if slot:
old = conn().execute(
"SELECT * FROM spools WHERE printer_host = %s AND ace_slot = %s AND id <> %s",
(host, slot, spool_id),
).fetchall()
else:
old = conn().execute(
"SELECT * FROM spools WHERE printer_host = %s AND ace_slot IS NULL AND id <> %s",
(host, spool_id),
).fetchall()
for row in old:
store.update_spool(conn(), row["id"], {"printer_host": "", "ace_slot": None})
store.add_event(
conn(),
host,
(store.printer_by_host(conn(), host) or {}).get("name") or host,
"spule",
"%s: „%s“ ins Regal%s" % (
(store.printer_by_host(conn(), host) or {}).get("name") or host,
row["name"],
(" (Platz %s)" % slot) if slot else "",
),
"",
)
updated = store.update_spool(conn(), spool_id, {"printer_host": host, "ace_slot": slot})
printer = store.printer_by_host(conn(), host) or {}
store.add_event(
conn(),
host,
printer.get("name") or host,
"spule",
"%s: „%s“ eingelegt%s" % (
printer.get("name") or host,
updated["name"],
(" auf Platz %s" % slot) if slot else "",
),
"",
)
return jsonify(updated)
@app.delete("/api/v1/spulen/<int:spool_id>")
def spool_delete(spool_id):
if not store.delete_spool(conn(), spool_id):
raise GrottiError("Die Spule fehlt.", "spule", 404)
return "", 204
def spool_fields(data, partial=False, spool_id=None):
fields = {}
if not partial or "name" in data:
name = (data.get("name") or "").strip()
if not name:
raise GrottiError("Die Spule braucht einen Namen.", "name")
fields["name"] = name
text_keys = ("brand", "material_type", "hersteller_charge")
for key in text_keys:
if not partial or key in data:
fields[key] = (data.get(key) or "").strip()
if not partial or "colors" in data or "color" in data:
fields["colors"] = color_list(data.get("colors"))
label = (data.get("color") or "").strip()
if not label:
label = " / ".join(item["name"] for item in fields["colors"])
fields["color"] = label
for key, default in (("price_per_kg", 0), ("density", 1.24), ("volumetric", 12), ("remaining_g", 1000)):
if not partial or key in data:
try:
fields[key] = float(data.get(key, default) or 0)
except (TypeError, ValueError) as exc:
raise GrottiError("%s muss eine Zahl sein." % key, "zahl") from exc
if not partial or "printer_host" in data:
fields["printer_host"] = optional_host(data.get("printer_host") or "")
if not partial or "ace_slot" in data:
fields["ace_slot"] = ace_slot(data.get("ace_slot"))
if not partial or "material_id" in data:
fields["material_id"] = optional_id(data.get("material_id"))
# Doppelte Plätze verhindern
host = fields.get("printer_host")
slot = fields.get("ace_slot")
if host is None or slot is None:
current = store.get_spool_row(conn(), spool_id) if spool_id else None
if host is None and current:
host = current.get("printer_host") or ""
if slot is None and current:
slot = current.get("ace_slot")
if host and slot:
taken = conn().execute(
"""
SELECT id, name FROM spools
WHERE printer_host = %s AND ace_slot = %s
AND (%s::bigint IS NULL OR id <> %s)
LIMIT 1
""",
(host, slot, spool_id, spool_id),
).fetchone()
if taken:
raise GrottiError(
"Platz %s auf diesem Drucker ist schon von „%s“ belegt."
% (slot, taken["name"]),
"ace",
409,
)
return fields
def color_list(raw):
if raw in (None, ""):
return []
if not isinstance(raw, list):
raise GrottiError("Farben sind eine Liste.", "farbe")
if len(raw) > 8:
raise GrottiError("Eine Spule hat höchstens acht Farben.", "farbe")
colors = []
for item in raw:
if isinstance(item, dict):
name = (item.get("name") or "").strip()
hex_value = (item.get("hex") or "").strip()
else:
raise GrottiError("Eine Farbe stimmt nicht.", "farbe")
if not re.fullmatch(r"#[0-9A-Fa-f]{6}", hex_value):
raise GrottiError("Eine Farbe braucht einen Hex-Wert.", "farbe")
colors.append({"name": name or hex_value, "hex": hex_value.lower()})
return colors
def ace_slot(raw):
if raw in (None, "", 0, "0"):
return None
try:
slot = int(raw)
except (TypeError, ValueError) as exc:
raise GrottiError("Der ACE-Platz ist eine Zahl.", "ace") from exc
if slot < 1 or slot > 8:
raise GrottiError("Ein ACE hat vier Plätze, ein zweites ACE die Plätze 5 bis 8.", "ace")
return slot
@app.post("/api/v1/kalkulation")
def quote():
data = body()
spool = None
spool_id = optional_id(data.get("spool_id"))
if spool_id:
spool = store.get_spool_row(conn(), spool_id)
if not spool:
raise GrottiError("Die Spule fehlt.", "spule", 404)
material = {
"price_per_kg": spool["price_per_kg"] if spool else float(data.get("price_per_kg") or 0),
"density": spool["density"] if spool else float(data.get("density") or 1.24),
"volumetric": spool["volumetric"] if spool else float(data.get("volumetric") or 12),
}
job = {
"weight_g": float(data.get("weight_g") or 0),
"qty": float(data.get("qty") or 1),
"waste_percent": float(data.get("waste_percent", cfg["energy"]["waste_percent"]) or 0),
"utilization": float(data.get("utilization", cfg["energy"]["utilization"]) or 1),
"watts": float(data.get("watts", cfg["energy"]["watts"]) or 0),
"euro_per_kwh": float(data.get("euro_per_kwh", cfg["energy"]["euro_per_kwh"]) or 0),
"dryer_watts": float(data.get("dryer_watts", cfg["energy"]["dryer_watts"]) or 0),
"dry_hours": float(data.get("dry_hours", cfg["energy"]["dry_hours"]) or 0),
"price_mode": data.get("price_mode") or "perPart",
"price": float(data.get("price") or 0),
}
result = calculate(job, material)
payload = {"inputs": job, "material": material, "result": result}
project_id = optional_id(data.get("project_id"))
if project_id:
saved = store.update_project(conn(), project_id, {"quote": payload})
if not saved:
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
return jsonify(payload)
@app.get("/api/v1/laeufe")
def run_list():
return jsonify(store.list_runs(conn()))
@app.post("/api/v1/laeufe/abgleich")
def run_sync():
library.sync_watch(conn(), cfg, force=False)
return jsonify(jobs.sync_history(conn(), cfg))
@app.patch("/api/v1/laeufe/<int:run_id>")
def run_patch(run_id):
data = body()
row = None
if "spool_id" in data and data.get("spool_id"):
row = jobs.assign_and_deduct(conn(), cfg, run_id, int(data["spool_id"]))
if "verdict" in data:
if data["verdict"] not in VERDICTS:
raise GrottiError("Urteil ist gut oder Ausschuss.", "urteil")
row = store.set_verdict(conn(), run_id, data["verdict"])
if row is None:
current = store.get_run_row(conn(), run_id)
if not current:
raise GrottiError("Der Lauf fehlt.", "lauf", 404)
row = store.run_out(current)
return jsonify(row)
return app