Jetzt/Topbar und Material-Routing, Pause bei Filament-Ende, Druckbericht mit Stromanteilen, Design- und Kamera-Stream-Polish; Bedarf-Alert-Spam und Platte-belegt-Bugs behoben.
2561 lines
100 KiB
Python
2561 lines
100 KiB
Python
"""Flask hängt die API und die Seiten an den Kern."""
|
||
|
||
import json
|
||
import re
|
||
import secrets
|
||
import time
|
||
import uuid
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from urllib.parse import quote
|
||
|
||
import markdown
|
||
import psycopg
|
||
from flask import Flask, Response, abort, g, jsonify, redirect, render_template, request, send_file, session, stream_with_context, url_for
|
||
from markupsafe import Markup
|
||
from psycopg.rows import dict_row
|
||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||
|
||
from grotti import auth as grotti_auth
|
||
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, 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
|
||
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")
|
||
HOST_RE = re.compile(r"^[A-Za-z0-9.\-]+(?::\d{1,5})?$")
|
||
TOKEN_QUERY_PATHS = (
|
||
"/api/v1/kamera",
|
||
"/api/v1/kamera/stream",
|
||
"/api/v1/vorschau",
|
||
)
|
||
STEER_ACTIONS = {
|
||
"nozzle", "bed", "speed", "flow", "fan", "emergency_stop", "clear_error",
|
||
"exclude_object", "dryer_start", "dryer_stop", "slot_setzen",
|
||
}
|
||
|
||
|
||
def validate_printer_host(host):
|
||
host = (host or "").strip()
|
||
if not host or not HOST_RE.fullmatch(host):
|
||
raise GrottiError(
|
||
"Host nur als Hostname oder IP, optional mit Port (z. B. 10.10.1.60).",
|
||
"host",
|
||
)
|
||
if ".." in host:
|
||
raise GrottiError("Ungültiger Host.", "host")
|
||
return host
|
||
|
||
|
||
def create_app(cfg):
|
||
app = Flask(
|
||
__name__,
|
||
template_folder=str(ROOT / "templates"),
|
||
static_folder=str(ROOT / "static"),
|
||
)
|
||
app.config["GROTTI"] = cfg
|
||
app.config["SECRET_KEY"] = cfg.get("secret_key") or secrets.token_hex(32)
|
||
app.config["TEMPLATES_AUTO_RELOAD"] = True
|
||
app.config["MAX_CONTENT_LENGTH"] = 256 * 1024 * 1024
|
||
app.config["SESSION_COOKIE_HTTPONLY"] = True
|
||
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
||
if cfg.get("fqdn", "").startswith("https://"):
|
||
app.config["SESSION_COOKIE_SECURE"] = True
|
||
app.json.ensure_ascii = False
|
||
# Hinter Reverse Proxy: X-Forwarded-For für Rate-Limits.
|
||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
|
||
# Threads starten in server.main() — Tests können create_app ohne Netz/DB-Worker nutzen.
|
||
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
|
||
|
||
PUBLIC_PATHS = {
|
||
"/login",
|
||
"/setup",
|
||
"/api/v1/auth/status",
|
||
"/api/v1/auth/login",
|
||
"/api/v1/auth/setup",
|
||
"/api/v1/version",
|
||
}
|
||
ADMIN_API_PREFIXES = (
|
||
"/api/v1/users",
|
||
"/api/v1/einstellungen",
|
||
"/api/v1/backup",
|
||
)
|
||
ADMIN_API_EXACT = {
|
||
"/api/v1/telegram/test",
|
||
"/api/v1/ntfy/test",
|
||
}
|
||
ADMIN_METHODS_ON = {
|
||
# Drucker anlegen/ändern/löschen nur Admin
|
||
("POST", "/api/v1/drucker"),
|
||
}
|
||
|
||
def conn():
|
||
if "db" not in g:
|
||
try:
|
||
pool = grotti_db.get_pool()
|
||
g.db = pool.getconn()
|
||
g.db_from_pool = True
|
||
except RuntimeError:
|
||
g.db = psycopg.connect(cfg["database_url"], row_factory=dict_row)
|
||
g.db_from_pool = False
|
||
return g.db
|
||
|
||
def ensure_csrf():
|
||
if not session.get("csrf"):
|
||
session["csrf"] = grotti_auth.new_csrf()
|
||
return session["csrf"]
|
||
|
||
def auth_user():
|
||
return getattr(g, "user", None)
|
||
|
||
def require_role(need):
|
||
user = auth_user()
|
||
if not user or not grotti_auth.role_at_least(user.get("role"), need):
|
||
raise GrottiError("Keine Berechtigung.", "forbidden", 403)
|
||
return user
|
||
|
||
def resolve_bearer(presented):
|
||
if not presented.startswith(grotti_auth.TOKEN_PREFIX):
|
||
return None
|
||
row = store.get_api_token_by_hash(conn(), grotti_auth.hash_token(presented))
|
||
if not row or row.get("revoked_at") or not row.get("user_active"):
|
||
return None
|
||
if row.get("expires_at") and row["expires_at"].timestamp() < time.time():
|
||
return None
|
||
store.touch_api_token(conn(), row["id"])
|
||
return {
|
||
"id": row["user_id"],
|
||
"username": row["username"],
|
||
"role": row["role"],
|
||
"auth": "token",
|
||
"token_id": row["id"],
|
||
}
|
||
|
||
def resolve_session_user():
|
||
uid = session.get("user_id")
|
||
if not uid:
|
||
return None
|
||
row = store.get_user(conn(), uid)
|
||
if not row or not row.get("active"):
|
||
session.clear()
|
||
return None
|
||
return {
|
||
"id": row["id"],
|
||
"username": row["username"],
|
||
"role": row["role"],
|
||
"auth": "session",
|
||
}
|
||
|
||
def wants_json():
|
||
if request.path.startswith("/api/"):
|
||
return True
|
||
accept = request.accept_mimetypes
|
||
return accept.best == "application/json"
|
||
|
||
@app.before_request
|
||
def check_auth():
|
||
g.user = None
|
||
path = request.path
|
||
if path.startswith("/static/"):
|
||
return None
|
||
if path in PUBLIC_PATHS:
|
||
return None
|
||
|
||
users_exist = store.user_count(conn()) > 0
|
||
if not users_exist:
|
||
if path.startswith("/api/") and path != "/api/v1/auth/setup":
|
||
if path == "/api/v1/auth/status":
|
||
return None
|
||
return jsonify({
|
||
"error": "Zuerst Admin anlegen.",
|
||
"code": "setup",
|
||
"setup": True,
|
||
}), 503
|
||
if path != "/setup":
|
||
return redirect(url_for("setup_page"))
|
||
return None
|
||
|
||
presented = ""
|
||
header = request.headers.get("Authorization", "")
|
||
if header.startswith("Bearer "):
|
||
presented = header[7:].strip()
|
||
elif (
|
||
request.method == "GET"
|
||
and any(request.path.startswith(p) for p in TOKEN_QUERY_PATHS)
|
||
):
|
||
# Token in der URL nur für Bild-Endpunkte (img-src ohne Header).
|
||
presented = (request.args.get("token") or "").strip()
|
||
|
||
user = None
|
||
if presented:
|
||
user = resolve_bearer(presented)
|
||
if not user:
|
||
return jsonify({"error": "Token fehlt oder stimmt nicht.", "code": "token"}), 401
|
||
else:
|
||
user = resolve_session_user()
|
||
|
||
if not user:
|
||
if wants_json() or path.startswith("/api/"):
|
||
return jsonify({"error": "Anmeldung nötig.", "code": "auth"}), 401
|
||
# Query behalten (z. B. /dateien?file=12 nach Orca-Upload)
|
||
next_path = path
|
||
if request.query_string:
|
||
next_path = path + "?" + request.query_string.decode("utf-8", "replace")
|
||
return redirect(url_for("login_page", next=next_path))
|
||
|
||
g.user = user
|
||
ensure_csrf()
|
||
|
||
# CSRF für Cookie-Sessions bei mutierenden API-Calls
|
||
if (
|
||
user.get("auth") == "session"
|
||
and path.startswith("/api/")
|
||
and request.method in ("POST", "PATCH", "PUT", "DELETE")
|
||
):
|
||
sent = request.headers.get("X-CSRF-Token") or ""
|
||
expected = session.get("csrf") or ""
|
||
if not expected or not secrets.compare_digest(sent, expected):
|
||
return jsonify({"error": "CSRF-Token fehlt oder ungültig.", "code": "csrf"}), 403
|
||
|
||
need_admin = False
|
||
if path.startswith(ADMIN_API_PREFIXES) or path in ADMIN_API_EXACT:
|
||
if path == "/api/v1/einstellungen" and request.method == "GET":
|
||
need_admin = False
|
||
elif path == "/api/v1/einstellungen" and request.method == "PATCH":
|
||
# Operator darf nur Automatik-Schalter setzen
|
||
payload = request.get_json(silent=True) or {}
|
||
keys = {k for k, v in payload.items() if v is not None}
|
||
need_admin = keys - {"auto_queue", "auto_retry"} != set()
|
||
elif path.startswith("/api/v1/users"):
|
||
need_admin = True
|
||
elif request.method != "GET":
|
||
need_admin = True
|
||
if (request.method, path) in ADMIN_METHODS_ON:
|
||
need_admin = True
|
||
if path.startswith("/api/v1/drucker/id/") and request.method in ("PATCH", "DELETE"):
|
||
need_admin = True
|
||
if need_admin and not grotti_auth.role_at_least(user.get("role"), "admin"):
|
||
return jsonify({"error": "Nur für Admins.", "code": "forbidden"}), 403
|
||
return None
|
||
|
||
@app.teardown_appcontext
|
||
def close_db(exc):
|
||
db_conn = g.pop("db", None)
|
||
from_pool = g.pop("db_from_pool", False)
|
||
if not db_conn:
|
||
return
|
||
try:
|
||
if exc:
|
||
db_conn.rollback()
|
||
else:
|
||
db_conn.commit()
|
||
except Exception:
|
||
try:
|
||
db_conn.rollback()
|
||
except Exception:
|
||
pass
|
||
finally:
|
||
if from_pool:
|
||
try:
|
||
grotti_db.get_pool().putconn(db_conn)
|
||
except Exception:
|
||
try:
|
||
db_conn.close()
|
||
except Exception:
|
||
pass
|
||
else:
|
||
db_conn.close()
|
||
|
||
@app.errorhandler(GrottiError)
|
||
def grotti_error(err):
|
||
db_conn = g.get("db")
|
||
if db_conn:
|
||
try:
|
||
db_conn.rollback()
|
||
except Exception:
|
||
pass
|
||
return jsonify({"error": err.message, "code": err.code}), err.status
|
||
|
||
@app.errorhandler(psycopg.Error)
|
||
def psycopg_error(err):
|
||
db_conn = g.get("db")
|
||
if db_conn:
|
||
try:
|
||
db_conn.rollback()
|
||
except Exception:
|
||
pass
|
||
message = "Datenbankfehler."
|
||
if isinstance(err, psycopg.errors.ForeignKeyViolation):
|
||
message = "Der Eintrag hängt noch an anderen Daten."
|
||
elif isinstance(err, psycopg.errors.UniqueViolation):
|
||
message = "Diesen Eintrag gibt es schon."
|
||
elif isinstance(err, psycopg.errors.InvalidTextRepresentation):
|
||
message = "Ungültiger Wert in der Anfrage."
|
||
print("DB: %s" % err, flush=True)
|
||
return jsonify({"error": message, "code": "db"}), 500
|
||
|
||
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
|
||
|
||
_icons_cache = {"html": "", "mtime": None}
|
||
|
||
def icons_sprite_html():
|
||
path = ROOT / "static" / "icons.svg"
|
||
try:
|
||
mtime = path.stat().st_mtime
|
||
except OSError:
|
||
return ""
|
||
if _icons_cache["html"] and _icons_cache["mtime"] == mtime:
|
||
return _icons_cache["html"]
|
||
text = path.read_text(encoding="utf-8")
|
||
text = re.sub(r"<\?xml[^?]*\?>\s*", "", text)
|
||
text = re.sub(r"<!--.*?-->\s*", "", text, count=1, flags=re.DOTALL)
|
||
text = text.replace("<svg ", '<svg width="0" height="0" style="position:absolute" aria-hidden="true" ', 1)
|
||
_icons_cache["html"] = text
|
||
_icons_cache["mtime"] = mtime
|
||
return text
|
||
|
||
@app.context_processor
|
||
def inject_globals():
|
||
user = auth_user()
|
||
return {
|
||
"version": __version__,
|
||
"csrf": session.get("csrf") or "",
|
||
"current_user": user,
|
||
"icons_sprite": Markup(icons_sprite_html()),
|
||
}
|
||
|
||
def page(name, title):
|
||
return render_template(
|
||
name,
|
||
page=name.split(".")[0],
|
||
title=title,
|
||
token="",
|
||
)
|
||
|
||
def audit(kind, message, host=""):
|
||
user = auth_user()
|
||
who = (user or {}).get("username") or "?"
|
||
store.add_event(conn(), host, "", kind, "%s · %s" % (who, message))
|
||
|
||
@app.get("/login")
|
||
def login_page():
|
||
if store.user_count(conn()) == 0:
|
||
return redirect(url_for("setup_page"))
|
||
if resolve_session_user():
|
||
return redirect("/")
|
||
ensure_csrf()
|
||
return render_template("login.html", title="Anmelden", csrf=session.get("csrf") or "")
|
||
|
||
@app.get("/setup")
|
||
def setup_page():
|
||
if store.user_count(conn()) > 0:
|
||
return redirect(url_for("login_page"))
|
||
ensure_csrf()
|
||
return render_template("setup.html", title="Ersten Admin anlegen", csrf=session.get("csrf") or "")
|
||
|
||
@app.get("/api/v1/auth/status")
|
||
def auth_status():
|
||
count = store.user_count(conn())
|
||
user = resolve_session_user()
|
||
return jsonify({
|
||
"setup_required": count == 0,
|
||
"authenticated": bool(user or auth_user()),
|
||
"user": grotti_auth.public_user(user) if user else None,
|
||
"oauth_enabled": bool((cfg.get("oauth") or {}).get("enabled")),
|
||
"legacy_token_set": bool(cfg.get("token")),
|
||
})
|
||
|
||
@app.post("/api/v1/auth/setup")
|
||
def auth_setup():
|
||
if store.user_count(conn()) > 0:
|
||
raise GrottiError("Setup ist schon erledigt.", "setup", 409)
|
||
data = body()
|
||
username = grotti_auth.validate_username(data.get("username") or "")
|
||
password = grotti_auth.validate_password(data.get("password") or "")
|
||
row = store.create_user(conn(), username, grotti_auth.hash_password(password), "admin")
|
||
grotti_db.invalidate_user_count()
|
||
store.touch_user_login(conn(), row["id"])
|
||
session.clear()
|
||
session["user_id"] = row["id"]
|
||
session["csrf"] = grotti_auth.new_csrf()
|
||
store.add_event(conn(), "", "", "auth", "Admin %s angelegt" % username)
|
||
return jsonify({"user": grotti_auth.public_user(row), "csrf": session["csrf"]}), 201
|
||
|
||
@app.post("/api/v1/auth/login")
|
||
def auth_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 ""
|
||
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"])
|
||
session.clear()
|
||
session["user_id"] = row["id"]
|
||
session["csrf"] = grotti_auth.new_csrf()
|
||
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/logout")
|
||
def auth_logout():
|
||
session.clear()
|
||
return jsonify({"ok": True})
|
||
|
||
@app.get("/api/v1/me")
|
||
def me():
|
||
user = require_role("operator")
|
||
return jsonify({"user": {
|
||
"id": user["id"],
|
||
"username": user["username"],
|
||
"role": user["role"],
|
||
"auth": user.get("auth"),
|
||
}, "csrf": ensure_csrf()})
|
||
|
||
@app.get("/api/v1/users")
|
||
def users_list():
|
||
require_role("admin")
|
||
return jsonify([grotti_auth.public_user(r) for r in store.list_users(conn())])
|
||
|
||
@app.post("/api/v1/users")
|
||
def users_create():
|
||
require_role("admin")
|
||
data = body()
|
||
username = grotti_auth.validate_username(data.get("username") or "")
|
||
password = grotti_auth.validate_password(data.get("password") or "")
|
||
role = grotti_auth.normalize_role(data.get("role") or "operator")
|
||
if store.get_user_by_name(conn(), username):
|
||
raise GrottiError("Benutzer gibt es schon.", "user", 409)
|
||
row = store.create_user(conn(), username, grotti_auth.hash_password(password), role)
|
||
grotti_db.invalidate_user_count()
|
||
audit("auth", "Benutzer %s (%s) angelegt" % (username, role))
|
||
return jsonify(grotti_auth.public_user(row)), 201
|
||
|
||
@app.patch("/api/v1/users/<int:user_id>")
|
||
def users_patch(user_id):
|
||
actor = require_role("admin")
|
||
data = body()
|
||
row = store.get_user(conn(), user_id)
|
||
if not row:
|
||
raise GrottiError("Benutzer fehlt.", "user", 404)
|
||
password_hash = None
|
||
if data.get("password"):
|
||
password_hash = grotti_auth.hash_password(grotti_auth.validate_password(data["password"]))
|
||
role = grotti_auth.normalize_role(data["role"]) if "role" in data else None
|
||
active = data.get("active") if "active" in data else None
|
||
if active is False and row["id"] == actor.get("id"):
|
||
raise GrottiError("Eigenes Konto nicht deaktivieren.", "user")
|
||
if role == "operator" and row["role"] == "admin" and row["id"] == actor.get("id"):
|
||
admins = [u for u in store.list_users(conn()) if u["role"] == "admin" and u["active"]]
|
||
if len(admins) <= 1:
|
||
raise GrottiError("Letzter Admin bleibt Admin.", "user")
|
||
updated = store.update_user(conn(), user_id, password_hash=password_hash, role=role, active=active)
|
||
audit("auth", "Benutzer %s geändert" % row["username"])
|
||
return jsonify(grotti_auth.public_user(updated))
|
||
|
||
@app.delete("/api/v1/users/<int:user_id>")
|
||
def users_delete(user_id):
|
||
actor = require_role("admin")
|
||
row = store.get_user(conn(), user_id)
|
||
if not row:
|
||
raise GrottiError("Benutzer fehlt.", "user", 404)
|
||
if row["id"] == actor.get("id"):
|
||
raise GrottiError("Eigenes Konto nicht löschen.", "user")
|
||
if row["role"] == "admin":
|
||
admins = [u for u in store.list_users(conn()) if u["role"] == "admin" and u["active"]]
|
||
if len(admins) <= 1:
|
||
raise GrottiError("Letzter Admin bleibt.", "user")
|
||
store.delete_user(conn(), user_id)
|
||
audit("auth", "Benutzer %s gelöscht" % row["username"])
|
||
return "", 204
|
||
|
||
@app.get("/api/v1/tokens")
|
||
def tokens_list():
|
||
user = require_role("operator")
|
||
if user.get("role") == "admin" and request.args.get("all") == "1":
|
||
rows = store.list_api_tokens(conn(), user_id=None)
|
||
else:
|
||
if not user.get("id"):
|
||
return jsonify([])
|
||
rows = store.list_api_tokens(conn(), user_id=user["id"])
|
||
out = []
|
||
for row in rows:
|
||
item = grotti_auth.public_token(row)
|
||
item["username"] = row.get("username")
|
||
out.append(item)
|
||
return jsonify(out)
|
||
|
||
@app.post("/api/v1/tokens")
|
||
def tokens_create():
|
||
user = require_role("operator")
|
||
if not user.get("id"):
|
||
raise GrottiError("Legacy-Token: bitte Benutzer-Token anlegen.", "token")
|
||
data = body()
|
||
name = (data.get("name") or "Token").strip()[:80]
|
||
role = grotti_auth.clamp_token_role(user["role"], data.get("role"))
|
||
raw = grotti_auth.new_api_token()
|
||
row = store.create_api_token(
|
||
conn(),
|
||
user["id"],
|
||
name,
|
||
grotti_auth.hash_token(raw),
|
||
grotti_auth.token_prefix(raw),
|
||
role,
|
||
)
|
||
audit("auth", "API-Token „%s“ erzeugt" % name)
|
||
item = grotti_auth.public_token(row)
|
||
item["token"] = raw
|
||
return jsonify(item), 201
|
||
|
||
@app.delete("/api/v1/tokens/<int:token_id>")
|
||
def tokens_revoke(token_id):
|
||
user = require_role("operator")
|
||
if user.get("role") == "admin":
|
||
row = store.revoke_api_token(conn(), token_id, user_id=None)
|
||
else:
|
||
row = store.revoke_api_token(conn(), token_id, user_id=user.get("id"))
|
||
if not row:
|
||
raise GrottiError("Token fehlt.", "token", 404)
|
||
audit("auth", "API-Token #%s widerrufen" % token_id)
|
||
return jsonify(grotti_auth.public_token(row))
|
||
|
||
def doc_names():
|
||
"""Nur Anleitung-*.md — Technik liegt unter docs/internal/ (nicht in der App)."""
|
||
if not DOCS_DIR.is_dir():
|
||
return []
|
||
return sorted(p.stem for p in DOCS_DIR.glob("Anleitung-*.md"))
|
||
|
||
ANLEITUNG_ORDER = (
|
||
"Anleitung-Einrichtung",
|
||
"Anleitung-Oberflaeche",
|
||
"Anleitung-Wand",
|
||
"Anleitung-Dateien",
|
||
"Anleitung-Orca",
|
||
"Anleitung-ACE",
|
||
"Anleitung-Warteschlange",
|
||
"Anleitung-Auftraege",
|
||
"Anleitung-Material",
|
||
"Anleitung-Wartung",
|
||
"Anleitung-Einstellungen",
|
||
"Anleitung-Stoerungen",
|
||
)
|
||
|
||
def anleitung_list(names=None):
|
||
names = names if names is not None else doc_names()
|
||
anleitung_set = set(names)
|
||
ordered = [n for n in ANLEITUNG_ORDER if n in anleitung_set]
|
||
ordered += sorted(anleitung_set - set(ordered))
|
||
return ordered
|
||
|
||
def anleitung_title(name):
|
||
labels = {
|
||
"Anleitung-Einrichtung": "Einrichtung",
|
||
"Anleitung-Oberflaeche": "Oberfläche",
|
||
"Anleitung-Wand": "Druckerwand",
|
||
"Anleitung-Dateien": "Dateien senden",
|
||
"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",
|
||
}
|
||
return labels.get(name, name.replace("Anleitung-", "").replace("-", " "))
|
||
|
||
def render_markdown(path):
|
||
"""Markdown für die App: Git-relative Bild-/Kapitelpfade → App-URLs."""
|
||
text = path.read_text(encoding="utf-8")
|
||
# docs/… → web/static/… im Repo; unter /doku als /static/…
|
||
text = text.replace("../web/static/", "/static/")
|
||
text = re.sub(
|
||
r"\]\((Anleitung-[A-Za-z0-9_-]+)(?:\.md)?\)",
|
||
r"](/doku/\1)",
|
||
text,
|
||
)
|
||
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 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():
|
||
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="",
|
||
spool=spool,
|
||
qr_url=qr_url,
|
||
)
|
||
|
||
@app.get("/protokoll")
|
||
def protokoll():
|
||
return page("protokoll.html", "Protokoll")
|
||
|
||
@app.get("/protokoll/<int:run_id>")
|
||
def protokoll_detail(run_id):
|
||
row = store.get_run_row(conn(), run_id)
|
||
if not row:
|
||
abort(404)
|
||
return page("bericht.html", "Druckbericht")
|
||
|
||
@app.get("/statistik")
|
||
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)
|
||
if not row:
|
||
abort(404)
|
||
run = store.run_out(row)
|
||
if not run.get("chargen_nr"):
|
||
store.ensure_run_chargen(conn(), run_id, run.get("weight_used_g"))
|
||
row = store.get_run_row(conn(), run_id)
|
||
run = store.run_out(row)
|
||
if not run.get("chargen_nr"):
|
||
abort(404)
|
||
labels = store.list_run_labels(conn(), run_id)
|
||
mode = (request.args.get("mode") or "druck").strip()
|
||
qty = max(1, min(50, int(request.args.get("qty") or 1)))
|
||
if mode == "objekt":
|
||
labels = store.ensure_run_labels(conn(), run_id, run.get("objects") or [], qty=qty)
|
||
sheets = [lab for lab in labels if not lab.get("excluded")]
|
||
else:
|
||
sheets = [{"object_name": "", "chargen_nr": run["chargen_nr"], "excluded": False}]
|
||
if not sheets:
|
||
abort(404)
|
||
spools = store.etikett_spools_for_run(conn(), run)
|
||
base = grotti_config.public_base(cfg, request.url_root)
|
||
target_g = float(run.get("weight_planned_g") or run.get("weight_g") or 0)
|
||
for sheet in sheets:
|
||
sheet["qr_url"] = "%s/protokoll/%s" % (base, run_id)
|
||
sheet["target_g"] = target_g
|
||
if sheet.get("object_name"):
|
||
sheet["qr_url"] += "#objekt-%s" % quote(sheet["object_name"])
|
||
return render_template(
|
||
"etikett_lauf.html",
|
||
page="etikett",
|
||
title="Etikett %s" % run["chargen_nr"],
|
||
token="",
|
||
run=run,
|
||
sheets=sheets,
|
||
spools=spools,
|
||
target_g=target_g,
|
||
)
|
||
|
||
@app.get("/ideen")
|
||
def ideen():
|
||
return redirect("/auftraege", code=301)
|
||
|
||
@app.get("/jetzt")
|
||
def jetzt_page():
|
||
return page("jetzt.html", "Jetzt")
|
||
|
||
@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():
|
||
names = doc_names()
|
||
anleitung = anleitung_list(names)
|
||
return render_template(
|
||
"doku.html",
|
||
page="doku",
|
||
title="Doku",
|
||
token="",
|
||
docs=names,
|
||
anleitung=anleitung,
|
||
anleitung_title=anleitung_title,
|
||
)
|
||
|
||
@app.get("/doku/<name>")
|
||
def doku_page(name):
|
||
names = doc_names()
|
||
if name not in names:
|
||
abort(404)
|
||
path = DOCS_DIR / ("%s.md" % name)
|
||
if not path.is_file():
|
||
abort(404)
|
||
anleitung = anleitung_list(names)
|
||
return render_template(
|
||
"doku_page.html",
|
||
page="doku",
|
||
title=anleitung_title(name),
|
||
token="",
|
||
content=render_markdown(path),
|
||
docs=names,
|
||
anleitung=anleitung,
|
||
anleitung_title=anleitung_title,
|
||
current=name,
|
||
)
|
||
|
||
@app.get("/api/v1/version")
|
||
def version_info():
|
||
return jsonify({"version": __version__})
|
||
|
||
@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
|
||
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
|
||
auto_retry = data.get("auto_retry") if "auto_retry" in data else None
|
||
fqdn = data.get("fqdn") if "fqdn" in data else None
|
||
watch_dir = data.get("watch_dir") if "watch_dir" in data else None
|
||
return jsonify(grotti_config.save_settings(
|
||
cfg,
|
||
energy=energy,
|
||
telegram=telegram,
|
||
ntfy=ntfy,
|
||
alerts=alerts,
|
||
auto_queue=auto_queue,
|
||
auto_retry=auto_retry,
|
||
fqdn=fqdn,
|
||
watch_dir=watch_dir,
|
||
))
|
||
|
||
@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.post("/api/v1/ntfy/test")
|
||
def ntfy_test():
|
||
ok = notify.send_ntfy(cfg, "Grotti-Test: ntfy ist verbunden.")
|
||
if not ok:
|
||
raise GrottiError(
|
||
"ntfy nicht erreichbar. Server, Topic und „aktiv“ prüfen.",
|
||
"ntfy",
|
||
)
|
||
return jsonify({"ok": True})
|
||
|
||
@app.get("/api/v1/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", "")
|
||
live_run = store.find_live_run(conn(), host) if host else None
|
||
item["live_run_id"] = (live_run or {}).get("id")
|
||
item["live_chargen"] = (live_run or {}).get("chargen_nr") or ""
|
||
|
||
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 = validate_printer_host(data.get("host") or "")
|
||
if not name:
|
||
raise GrottiError("Der Drucker braucht einen Namen.", "name")
|
||
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_raw = (data.get("host") if "host" in data else current["host"]) or ""
|
||
host = validate_printer_host(str(host_raw).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 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")
|
||
start_window = data.get("start_window") if "start_window" in data else None
|
||
if start_window is not None and not isinstance(start_window, dict):
|
||
raise GrottiError("Zeitfenster muss ein Objekt sein.", "start_window")
|
||
row = store.update_printer(
|
||
conn(),
|
||
printer_id,
|
||
name,
|
||
host,
|
||
family,
|
||
locked=bool(locked),
|
||
start_window=start_window,
|
||
)
|
||
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/kamera/stream")
|
||
def camera_stream():
|
||
"""MJPEG vom Drucker durchreichen (kein Frame-Cache)."""
|
||
host = require_host(request.args.get("host", ""))
|
||
try:
|
||
upstream = moonraker.open_camera_stream(host)
|
||
except Exception:
|
||
abort(404)
|
||
ctype = upstream.headers.get("Content-Type") or "multipart/x-mixed-replace; boundary=--"
|
||
|
||
@stream_with_context
|
||
def generate():
|
||
try:
|
||
while True:
|
||
chunk = upstream.read(16384)
|
||
if not chunk:
|
||
break
|
||
yield chunk
|
||
finally:
|
||
try:
|
||
upstream.close()
|
||
except Exception:
|
||
pass
|
||
|
||
return Response(
|
||
generate(),
|
||
status=200,
|
||
headers={
|
||
"Content-Type": ctype,
|
||
"Cache-Control": "no-store",
|
||
"X-Accel-Buffering": "no",
|
||
},
|
||
)
|
||
|
||
@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(), watch_dir=cfg.get("watch_dir")))
|
||
|
||
@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.get("/api/v1/dateien/ordner")
|
||
def file_folders():
|
||
return jsonify({"folders": library.list_folders(cfg)})
|
||
|
||
@app.post("/api/v1/dateien/ordner")
|
||
def file_folder_create():
|
||
data = body()
|
||
folder = library.create_folder(cfg, data.get("folder"))
|
||
return jsonify({"folder": folder, "folders": library.list_folders(cfg)}), 201
|
||
|
||
@app.delete("/api/v1/dateien/ordner")
|
||
def file_folder_delete():
|
||
data = body()
|
||
folder = library.delete_folder(cfg, data.get("folder"))
|
||
return jsonify({"folder": folder, "folders": library.list_folders(cfg)})
|
||
|
||
@app.post("/api/v1/dateien/<int:file_id>/verschieben")
|
||
def file_move(file_id):
|
||
data = body()
|
||
folder = data.get("folder")
|
||
if folder is None:
|
||
folder = ""
|
||
moved = library.move_file(conn(), cfg, file_id, folder)
|
||
return jsonify(moved)
|
||
|
||
@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")
|
||
low = upload.filename.lower().replace("\\", "/")
|
||
base = low.rsplit("/", 1)[-1]
|
||
if base in (".", "..") or not base.strip("."):
|
||
raise GrottiError("Ungültiger Dateiname.", "datei")
|
||
if not any(base.endswith(suf) for suf in UPLOAD_SUFFIXES):
|
||
raise GrottiError(
|
||
"Nur .gcode, .3mf oder .gcode.3mf sind erlaubt.",
|
||
"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, conn=conn(), data_dir=cfg["data_dir"])
|
||
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 = library.resolve_stored_path(conn(), row, cfg["data_dir"])
|
||
if path is None or 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"]
|
||
data["version"] = TOOLPATH_VERSION
|
||
resp = jsonify(data)
|
||
resp.headers["Cache-Control"] = "no-store"
|
||
return resp
|
||
|
||
@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.get("/api/v1/jetzt")
|
||
def jetzt_api():
|
||
"""Priorisierte Handlungsliste, laufende Drucke und Farm-ETA."""
|
||
from grotti import jetzt as jetzt_mod
|
||
|
||
send_jobs = []
|
||
try:
|
||
send_jobs = app.extensions["send_queue"].list_jobs() or []
|
||
except Exception:
|
||
send_jobs = []
|
||
return jsonify(jetzt_mod.build(
|
||
conn(),
|
||
app.extensions["watcher"].latest or [],
|
||
printers=store.list_printers(conn()),
|
||
send_jobs=send_jobs,
|
||
))
|
||
|
||
@app.get("/api/v1/prognose")
|
||
def prognose_api():
|
||
"""Wann welcher Drucker frei ist und wann alles fertig ist."""
|
||
from grotti import jetzt as jetzt_mod
|
||
|
||
running = []
|
||
for live in app.extensions["watcher"].latest or []:
|
||
state = (live.get("state") or "").lower()
|
||
if state not in ("printing", "paused", "heating"):
|
||
continue
|
||
running.append({
|
||
"host": live.get("host") or "",
|
||
"name": live.get("name") or "",
|
||
"remain": live.get("remain") or 0,
|
||
"progress": live.get("progress") or 0,
|
||
"filename": live.get("filename") or "",
|
||
"state": state,
|
||
"free_at": None,
|
||
})
|
||
eta = jetzt_mod.farm_eta(running)
|
||
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"]
|
||
return jsonify({
|
||
"running": running,
|
||
"waiting_count": len(waiting),
|
||
"eta": eta,
|
||
})
|
||
|
||
@app.get("/api/v1/suche")
|
||
def suche_api():
|
||
"""Globale Suche für Ctrl+K."""
|
||
q = (request.args.get("q") or "").strip()
|
||
if len(q) < 1:
|
||
return jsonify({"items": []})
|
||
items = store.global_search(conn(), q, limit=20)
|
||
return jsonify({"items": 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
|
||
if not bool(data.get("confirm")):
|
||
raise GrottiError("Bestätigung fehlt.", "bestaetigung")
|
||
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)
|
||
c = conn()
|
||
store.set_bed_needs_clear(c, host, False, "quittiert")
|
||
run_json = None
|
||
run_row = store.run_for_abraeumen_verdict(c, host)
|
||
if any(k in data for k in ("verdict", "verdict_reason", "pieces_ok", "pieces_bad")):
|
||
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)
|
||
reprint = None
|
||
if bool(data.get("reprint")) and run_row and run_row.get("file_id"):
|
||
reprint = store.enqueue_print(
|
||
c,
|
||
run_row["file_id"],
|
||
host,
|
||
"",
|
||
50,
|
||
run_row.get("spool_id"),
|
||
[],
|
||
False,
|
||
False,
|
||
)
|
||
store.add_event(
|
||
c, host, "", "queue",
|
||
"Nochmal eingereiht: %s" % (run_row.get("filename") or run_row["file_id"]),
|
||
run_row.get("filename") or "",
|
||
)
|
||
return jsonify({
|
||
"ok": True,
|
||
"host": host,
|
||
"needs_clear": False,
|
||
"message": "Platte quittiert." + (" Nochmal in der Warteschlange." if reprint else ""),
|
||
"run": run_json,
|
||
"reprint": reprint,
|
||
"verdict_reasons": VERDICT_REASONS,
|
||
})
|
||
|
||
@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",):
|
||
# Undo: abgebrochen/entfernt wieder einreihen
|
||
if not (data.get("undo") and current_row.get("status") in ("cancelled", "dismissed")):
|
||
raise GrottiError("Status lässt sich so nicht setzen.", "status")
|
||
fields["message"] = ""
|
||
fields["phase"] = ""
|
||
fields["phase_started_at"] = None
|
||
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"
|
||
alerts_only = request.args.get("alerts") == "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()]
|
||
if alerts_only and not kinds:
|
||
kinds = list(store.ALERT_EVENT_KINDS)
|
||
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(),
|
||
kinds=list(store.ALERT_EVENT_KINDS) if alerts_only or unread else None,
|
||
),
|
||
})
|
||
|
||
@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, kinds=list(store.ALERT_EVENT_KINDS) if not ids else None)
|
||
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/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()))
|
||
|
||
@app.get("/api/v1/spulen/by-code/<path:code>")
|
||
def spool_by_code(code):
|
||
text = (code or "").strip()
|
||
if text.upper().startswith("D-"):
|
||
run = store.get_run_by_chargen(conn(), text)
|
||
if not run:
|
||
raise GrottiError("Kein Druck mit dieser Charge.", "charge", 404)
|
||
return jsonify({"kind": "lauf", "run": store.run_out(run), "redirect": "/protokoll/%s" % run["id"]})
|
||
row = store.get_spool_by_code(conn(), text)
|
||
if not row:
|
||
raise GrottiError("Keine Spule mit diesem Code.", "spule", 404)
|
||
return jsonify(row)
|
||
|
||
@app.get("/api/v1/spulen/<int:spool_id>/akte")
|
||
def spool_akte(spool_id):
|
||
spool = store.get_spool_row(conn(), spool_id)
|
||
if not spool:
|
||
raise GrottiError("Die Spule fehlt.", "spule", 404)
|
||
return jsonify({
|
||
"spool": store.spool_out(spool),
|
||
"runs": store.list_spool_runs(conn(), spool_id),
|
||
"stats": store.spool_usage_stats(conn(), spool_id),
|
||
})
|
||
|
||
@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)
|
||
live_run = store.find_live_run(conn(), host)
|
||
mm_now = float((live_run or {}).get("filament_used_mm") or 0)
|
||
printing = bool(
|
||
live_run and (live_run.get("state") or "") in ("in_progress", "printing", "paused")
|
||
)
|
||
if printing:
|
||
try:
|
||
snap = moonraker.query(host)
|
||
if isinstance(snap.get("filament_used"), (int, float)):
|
||
mm_now = float(snap["filament_used"])
|
||
except Exception:
|
||
pass
|
||
# 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()
|
||
printer = store.printer_by_host(conn(), host) or {}
|
||
if printing:
|
||
closed = store.close_open_segments(conn(), live_run["id"], slot, mm_now)
|
||
if not closed and old:
|
||
# Erster Wechsel: alter Abschnitt von 0 bis jetzt
|
||
for row in old:
|
||
store.open_segment(conn(), live_run["id"], row["id"], slot, 0)
|
||
store.close_open_segments(conn(), live_run["id"], slot, mm_now)
|
||
for row in old:
|
||
store.update_spool(conn(), row["id"], {"printer_host": "", "ace_slot": None})
|
||
store.add_event(
|
||
conn(),
|
||
host,
|
||
printer.get("name") or host,
|
||
"spule",
|
||
"%s: „%s“ ins Regal%s" % (
|
||
printer.get("name") or host,
|
||
row["name"],
|
||
(" (Platz %s)" % slot) if slot else "",
|
||
),
|
||
(live_run or {}).get("filename") or "",
|
||
run_id=(live_run or {}).get("id"),
|
||
)
|
||
updated = store.update_spool(conn(), spool_id, {"printer_host": host, "ace_slot": slot})
|
||
if printing:
|
||
store.open_segment(conn(), live_run["id"], spool_id, slot, mm_now)
|
||
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 "",
|
||
),
|
||
(live_run or {}).get("filename") or "",
|
||
run_id=(live_run or {}).get("id"),
|
||
)
|
||
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.get("/api/v1/laeufe/aktuell")
|
||
def run_aktuell():
|
||
"""Aktueller oder letzter Temperatur-/ED-Verlauf fürs Cockpit (host=)."""
|
||
host = require_host(request.args.get("host", ""))
|
||
run = store.latest_run_for_host(conn(), host)
|
||
if not run:
|
||
return jsonify({"run": None, "samples": [], "energy": None})
|
||
run_id = run["id"]
|
||
samples = store.list_run_samples(conn(), run_id)
|
||
from grotti.cost import energy_estimate
|
||
|
||
energy = energy_estimate(run.get("duration_s"), cfg["energy"], samples)
|
||
if len(samples) > 180:
|
||
step = max(1, len(samples) // 120)
|
||
samples = samples[::step]
|
||
return jsonify({
|
||
"run": run,
|
||
"samples": samples,
|
||
"energy": energy,
|
||
"sample_count": int(run.get("_sample_count") or len(samples)),
|
||
})
|
||
|
||
@app.get("/api/v1/laeufe/<int:run_id>")
|
||
def run_get(run_id):
|
||
row = store.get_run_row(conn(), run_id)
|
||
if not row:
|
||
raise GrottiError("Der Lauf fehlt.", "lauf", 404)
|
||
run = store.run_out(row)
|
||
if not run.get("chargen_nr"):
|
||
store.ensure_run_chargen(conn(), run_id)
|
||
row = store.get_run_row(conn(), run_id) or row
|
||
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)
|
||
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
|
||
samples = store.list_run_samples(conn(), run_id)
|
||
from grotti.cost import energy_estimate
|
||
|
||
energy = energy_estimate(run.get("duration_s"), cfg["energy"], samples)
|
||
# Anzeige auf ca. 1 Punkt / Minute verdichten
|
||
if len(samples) > 180:
|
||
step = max(1, len(samples) // 120)
|
||
samples = samples[::step]
|
||
return jsonify({
|
||
"run": run,
|
||
"spools": store.list_run_spools(conn(), run_id),
|
||
"segments": store.list_run_segments(conn(), run_id),
|
||
"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,
|
||
"project": project,
|
||
"energy": energy,
|
||
})
|
||
|
||
@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)
|
||
|
||
@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
|
||
until = request.args.get("until") or None
|
||
host = (request.args.get("host") or "").strip()
|
||
material = (request.args.get("material") or "").strip()
|
||
brand = (request.args.get("brand") or "").strip()
|
||
project_id = optional_id(request.args.get("project_id"))
|
||
return jsonify(store.statistics(
|
||
conn(),
|
||
since=since,
|
||
until=until,
|
||
host=host,
|
||
material=material or "",
|
||
brand=brand or "",
|
||
project_id=project_id,
|
||
))
|
||
|
||
@app.get("/api/v1/statistik.csv")
|
||
def statistik_csv():
|
||
import csv
|
||
import io
|
||
from flask import Response
|
||
|
||
since = request.args.get("since") or None
|
||
until = request.args.get("until") or None
|
||
host = (request.args.get("host") or "").strip()
|
||
material = (request.args.get("material") or "").strip()
|
||
brand = (request.args.get("brand") or "").strip()
|
||
runs = store.list_runs(
|
||
conn(),
|
||
limit=2000,
|
||
since=since,
|
||
until=until,
|
||
host=host,
|
||
material=material,
|
||
brand=brand,
|
||
)
|
||
buf = io.StringIO()
|
||
writer = csv.writer(buf, delimiter=";")
|
||
writer.writerow([
|
||
"id", "drucker", "datei", "zustand", "start", "ende", "dauer_s",
|
||
"soll_g", "ist_g", "quelle", "filament_eur", "strom_eur", "urteil", "charge",
|
||
])
|
||
for run in runs:
|
||
writer.writerow([
|
||
run["id"],
|
||
run.get("printer_name") or "",
|
||
run.get("filename") or "",
|
||
run.get("state") or "",
|
||
run.get("started_at") or "",
|
||
run.get("ended_at") or "",
|
||
run.get("duration_s") or 0,
|
||
run.get("weight_planned_g") or 0,
|
||
run.get("weight_used_g") or 0,
|
||
run.get("weight_source") or "",
|
||
run.get("filament_cost") or 0,
|
||
run.get("energy_cost") or 0,
|
||
run.get("verdict") or "",
|
||
run.get("chargen_nr") or "",
|
||
])
|
||
return Response(
|
||
buf.getvalue(),
|
||
mimetype="text/csv; charset=utf-8",
|
||
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)
|
||
|
||
# Kein Connection-Header: waitress verbietet Hop-by-Hop-Header (PEP 3333).
|
||
return Response(
|
||
stream_with_context(generate()),
|
||
mimetype="text/event-stream; charset=utf-8",
|
||
headers={
|
||
"Cache-Control": "no-cache",
|
||
"X-Accel-Buffering": "no",
|
||
},
|
||
)
|
||
|
||
return app
|