Senden und Abbruch laufen über den Treiber, Orca-Doppelzeilen und tote Ideen-API fallen weg, Drucker folgen der Datei, Etikettenformat liegt in den Einstellungen.
2968 lines
117 KiB
Python
2968 lines
117 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, timedelta
|
|
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, logutil, 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
|
|
|
|
log = logutil.get("grotti.web")
|
|
|
|
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)
|
|
PRINTER_FAMILIES = set(FAMILY_LABELS) | {""}
|
|
UPLOAD_SUFFIXES = (".gcode", ".3mf", ".gcode.3mf")
|
|
HOST_RE = re.compile(r"^[A-Za-z0-9.\-]+(?::\d{1,5})?$")
|
|
SERIAL_RE = re.compile(
|
|
r"^(?:COM\d+|/dev/tty(?:USB|ACM)\d+|/dev/serial/by-id/[A-Za-z0-9._\-]+)(?:@\d{3,7})?$",
|
|
re.IGNORECASE,
|
|
)
|
|
TOKEN_QUERY_PATHS = (
|
|
"/api/v1/kamera",
|
|
"/api/v1/kamera/stream",
|
|
"/api/v1/vorschau",
|
|
"/api/v1/dateien/",
|
|
"/api/v1/projekte/",
|
|
)
|
|
STEER_ACTIONS = {
|
|
"nozzle", "bed", "speed", "flow", "fan", "emergency_stop", "clear_error",
|
|
"exclude_object", "dryer_start", "dryer_stop", "slot_setzen",
|
|
}
|
|
|
|
|
|
def validate_printer_host(host):
|
|
host = (host or "").strip()
|
|
if not host or ".." in host or not (HOST_RE.fullmatch(host) or SERIAL_RE.fullmatch(host)):
|
|
raise GrottiError(
|
|
"Host als IP, Hostname, COM-Port (COM5 oder COM5@250000) oder /dev/tty…",
|
|
"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"
|
|
# Orca-App-Fenster schließt oft — Session-Cookie muss überleben (30 Tage, gleitend).
|
|
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=30)
|
|
app.config["SESSION_REFRESH_EACH_REQUEST"] = True
|
|
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/token",
|
|
"/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."
|
|
log.warning("DB: %s", err)
|
|
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.permanent = True
|
|
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.permanent = True
|
|
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/token")
|
|
def auth_token():
|
|
"""Passwort → API-Token (für Android/App). Keine Session, Rate-Limit wie Login."""
|
|
if store.user_count(conn()) == 0:
|
|
raise GrottiError("Zuerst Admin anlegen.", "setup", 503)
|
|
key = request.remote_addr or "unknown"
|
|
if not grotti_auth.login_rate_ok(key):
|
|
raise GrottiError("Zu viele Versuche. Bitte warten.", "rate", 429)
|
|
data = body()
|
|
username = (data.get("username") or "").strip()
|
|
password = data.get("password") or ""
|
|
device = (data.get("device_name") or data.get("name") or "App").strip()[:80] or "App"
|
|
row = store.get_user_by_name(conn(), username)
|
|
if not row or not row.get("active") or not grotti_auth.verify_password(row["password_hash"], password):
|
|
grotti_auth.login_rate_hit(key)
|
|
raise GrottiError("Benutzer oder Passwort falsch.", "auth", 401)
|
|
store.touch_user_login(conn(), row["id"])
|
|
raw = grotti_auth.new_api_token()
|
|
role = grotti_auth.clamp_token_role(row["role"], data.get("role"))
|
|
token_row = store.create_api_token(
|
|
conn(),
|
|
row["id"],
|
|
device,
|
|
grotti_auth.hash_token(raw),
|
|
grotti_auth.token_prefix(raw),
|
|
role,
|
|
)
|
|
store.add_event(conn(), "", "", "auth", "Token „%s“ für %s" % (device, row["username"]))
|
|
item = grotti_auth.public_token(token_row)
|
|
item["token"] = raw
|
|
item["user"] = grotti_auth.public_user(row)
|
|
return jsonify(item), 201
|
|
|
|
@app.post("/api/v1/auth/logout")
|
|
def auth_logout():
|
|
session.clear()
|
|
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-Installation-Pi",
|
|
"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-Installation-Pi": "Raspberry Pi (.deb)",
|
|
"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():
|
|
"""Alte Links → Wand (Kameras sitzen als Snapshot-Thumbs auf den Karten)."""
|
|
return redirect("/", code=301)
|
|
|
|
@app.get("/dateien")
|
|
def dateien():
|
|
return page("dateien.html", "Dateien")
|
|
|
|
@app.get("/projekte")
|
|
def projekte():
|
|
return page("projekte.html", "Projekte")
|
|
|
|
@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,
|
|
etikett=cfg.get("etikett") or grotti_config._etikett({}),
|
|
)
|
|
|
|
@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,
|
|
etikett=cfg.get("etikett") or grotti_config._etikett({}),
|
|
)
|
|
|
|
@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({
|
|
"name": "Grotti",
|
|
"version": __version__,
|
|
"api": 1,
|
|
"features": [
|
|
"auth_token",
|
|
"sse",
|
|
"queue",
|
|
"projects",
|
|
"orders",
|
|
"spools",
|
|
"camera",
|
|
"confirm",
|
|
"ntfy",
|
|
],
|
|
})
|
|
|
|
@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
|
|
etikett = data.get("etikett") if isinstance(data.get("etikett"), dict) 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,
|
|
etikett=etikett,
|
|
))
|
|
|
|
@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":
|
|
from grotti.drivers import for_printer
|
|
for row in rows:
|
|
if for_printer(row).transport == "moonraker":
|
|
row["mainsail"] = "http://%s/" % row["host"]
|
|
else:
|
|
row["mainsail"] = ""
|
|
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:
|
|
from grotti.drivers import snapshot
|
|
lives = 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"
|
|
from grotti.drivers import for_printer as _for_printer
|
|
if _for_printer(row).transport == "moonraker":
|
|
item["mainsail"] = "http://%s/" % row["host"]
|
|
else:
|
|
item["mainsail"] = ""
|
|
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.get("/api/v1/marlin-dienst")
|
|
def marlin_dienst():
|
|
from grotti.marlin_supervisor import current_status, ensure_if_enabled
|
|
|
|
ensure_if_enabled()
|
|
return jsonify(current_status())
|
|
|
|
@app.post("/api/v1/meldungen")
|
|
def meldung_create():
|
|
from grotti import meldung as grotti_meldung
|
|
|
|
require_role("operator")
|
|
data = body()
|
|
try:
|
|
row = grotti_meldung.create(
|
|
conn(),
|
|
cfg,
|
|
text=data.get("text") or "",
|
|
page=data.get("page") or "",
|
|
printer_host=data.get("printer_host") or "",
|
|
error_text=data.get("error_text") or "",
|
|
user=g.user,
|
|
)
|
|
conn().commit()
|
|
except ValueError as exc:
|
|
raise GrottiError(str(exc), "meldung") from exc
|
|
return jsonify(row), 201
|
|
|
|
@app.get("/api/v1/meldungen")
|
|
def meldung_list():
|
|
from grotti import meldung as grotti_meldung
|
|
|
|
require_role("admin")
|
|
status = (request.args.get("status") or "").strip() or None
|
|
return jsonify(grotti_meldung.list_rows(conn(), status=status))
|
|
|
|
@app.post("/api/v1/meldungen/<int:meldung_id>/erledigt")
|
|
def meldung_close(meldung_id):
|
|
from grotti import meldung as grotti_meldung
|
|
|
|
require_role("admin")
|
|
row = grotti_meldung.close(conn(), meldung_id)
|
|
if not row:
|
|
raise GrottiError("Die Meldung fehlt.", "meldung", 404)
|
|
conn().commit()
|
|
return jsonify(row)
|
|
|
|
@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)
|
|
aliases = {"resume": "fortsetzen", "cancel": "abbrechen"}
|
|
action = aliases.get(action, action)
|
|
if action not in ("pause", "fortsetzen", "abbrechen"):
|
|
raise GrottiError("Unbekannter Befehl.", "befehl")
|
|
return jsonify(jobs.control(conn(), host, action))
|
|
|
|
@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:
|
|
raise GrottiError("Kein Kamerabild.", "kamera", 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():
|
|
status = (request.args.get("status") or "").strip() or None
|
|
return jsonify(store.list_projects(conn(), status=status))
|
|
|
|
@app.post("/api/v1/projekte")
|
|
def project_create():
|
|
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")
|
|
folder = (data.get("folder") or "").strip()
|
|
if not folder:
|
|
folder = library.default_project_folder(name)
|
|
try:
|
|
if cfg.get("watch_dir"):
|
|
folder = library.ensure_project_folder(cfg, folder)
|
|
else:
|
|
folder = library.normalize_folder(folder)
|
|
except GrottiError as exc:
|
|
if exc.code in ("ordner", "watch"):
|
|
raise GrottiError(
|
|
"%s — Ordner relativ unter dem Slice-Pfad, z. B. Projekte/%s (kein D:\\…)."
|
|
% (exc.message.rstrip("."), library.project_slug(name)),
|
|
exc.code,
|
|
getattr(exc, "status", 400) or 400,
|
|
) from exc
|
|
raise
|
|
row = store.create_project(
|
|
conn(), name, (data.get("notes") or "").strip(), status, folder=folder
|
|
)
|
|
return jsonify(row), 201
|
|
|
|
@app.get("/api/v1/projekte/<int:project_id>")
|
|
def project_get(project_id):
|
|
row = store.get_project(conn(), project_id, watch_dir=cfg.get("watch_dir"))
|
|
if not row:
|
|
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
|
|
# Status „druckt“, wenn ein Lauf aktiv ist (nur Anzeige — kein Auto-Write ohne Wunsch)
|
|
return jsonify(row)
|
|
|
|
@app.get("/api/v1/projekte/<int:project_id>/laeufe")
|
|
def project_runs(project_id):
|
|
if not store.get_project(conn(), project_id):
|
|
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
|
|
try:
|
|
limit = max(1, min(200, int(request.args.get("limit") or 40)))
|
|
except (TypeError, ValueError):
|
|
limit = 40
|
|
try:
|
|
offset = max(0, int(request.args.get("offset") or 0))
|
|
except (TypeError, ValueError):
|
|
offset = 0
|
|
data = store.list_project_runs(conn(), project_id, limit=limit, offset=offset)
|
|
resp = jsonify(data["items"])
|
|
resp.headers["X-Total-Count"] = str(data["total"])
|
|
return resp
|
|
|
|
@app.post("/api/v1/projekte/<int:project_id>/einreihen")
|
|
def project_einreihen(project_id):
|
|
data = body()
|
|
if not bool(data.get("confirm")):
|
|
raise GrottiError("Bestätigung fehlt.", "bestaetigung")
|
|
project = store.get_project(conn(), project_id, watch_dir=cfg.get("watch_dir"))
|
|
if not project:
|
|
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
|
|
wanted = data.get("file_ids")
|
|
if isinstance(wanted, list) and wanted:
|
|
try:
|
|
wanted_ids = {int(x) for x in wanted}
|
|
except (TypeError, ValueError) as exc:
|
|
raise GrottiError("Ungültige Datei-IDs.", "datei") from exc
|
|
files = [f for f in (project.get("files") or []) if f["id"] in wanted_ids]
|
|
else:
|
|
files = list(project.get("files") or [])
|
|
if not files:
|
|
raise GrottiError("Keine Druckdateien zum Einreihen.", "datei")
|
|
try:
|
|
menge = max(1, min(50, int(data.get("menge") or 1)))
|
|
except (TypeError, ValueError):
|
|
menge = 1
|
|
try:
|
|
priority = int(data.get("priority", 100))
|
|
except (TypeError, ValueError):
|
|
priority = 100
|
|
host = optional_host(data.get("host") or "")
|
|
if host:
|
|
require_host(host)
|
|
bed = bool(data.get("bed_leveling"))
|
|
override = bool(data.get("override_machine"))
|
|
created = []
|
|
for file_row in files:
|
|
family = (data.get("family") or file_row.get("printer_family") or "").strip()
|
|
for _ in range(menge):
|
|
created.append(
|
|
store.enqueue_print(
|
|
conn(),
|
|
file_row["id"],
|
|
host,
|
|
family if not host else "",
|
|
priority,
|
|
optional_id(data.get("spool_id")),
|
|
data.get("ace_map") if isinstance(data.get("ace_map"), list) else [],
|
|
bed,
|
|
override,
|
|
)
|
|
)
|
|
# Status auf „druckt“ heben, wenn noch Idee/bereit
|
|
if project.get("status") in ("idee", "bereit"):
|
|
store.update_project(conn(), project_id, {"status": "druckt"})
|
|
return jsonify({"jobs": created, "count": len(created)}), 201
|
|
|
|
@app.patch("/api/v1/projekte/<int:project_id>")
|
|
def project_patch(project_id):
|
|
data = body()
|
|
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"]
|
|
if "folder" in data:
|
|
folder = (data.get("folder") or "").strip()
|
|
if folder and cfg.get("watch_dir"):
|
|
folder = library.ensure_project_folder(cfg, folder)
|
|
elif folder:
|
|
folder = library.normalize_folder(folder)
|
|
fields["folder"] = folder
|
|
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):
|
|
row = store.get_project(conn(), project_id)
|
|
if not row:
|
|
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
|
|
# ?force=1 löscht den Projektordner inkl. Inhalt
|
|
force = (request.args.get("force") or "").strip() in ("1", "true", "yes")
|
|
folder = (row.get("folder") or "").strip()
|
|
if folder and cfg.get("watch_dir"):
|
|
library.delete_project_disk_folder(cfg, folder, force=force)
|
|
if not store.delete_project(conn(), project_id):
|
|
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
|
|
return "", 204
|
|
|
|
@app.get("/api/v1/projekte/<int:project_id>/assets")
|
|
def project_assets_list(project_id):
|
|
row = store.get_project(conn(), project_id)
|
|
if not row:
|
|
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
|
|
folder = (row.get("folder") or "").strip()
|
|
if not folder:
|
|
return jsonify([])
|
|
if not cfg.get("watch_dir"):
|
|
return jsonify([])
|
|
return jsonify(library.list_project_assets(cfg, folder))
|
|
|
|
@app.post("/api/v1/projekte/<int:project_id>/assets")
|
|
def project_assets_upload(project_id):
|
|
row = store.get_project(conn(), project_id)
|
|
if not row:
|
|
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
|
|
folder = (row.get("folder") or "").strip()
|
|
if not folder:
|
|
raise GrottiError("Das Projekt hat keinen Ordner.", "ordner")
|
|
if not cfg.get("watch_dir"):
|
|
raise GrottiError("Kein Slice-Ordner gesetzt — unter Einstellungen festlegen.", "watch")
|
|
upload = request.files.get("file")
|
|
if not upload or not upload.filename:
|
|
raise GrottiError("Keine Datei angehängt.", "datei")
|
|
saved = library.save_project_asset(cfg, folder, upload.filename, upload.read())
|
|
return jsonify(saved), 201
|
|
|
|
@app.get("/api/v1/projekte/<int:project_id>/assets/<path:rel>")
|
|
def project_assets_download(project_id, rel):
|
|
row = store.get_project(conn(), project_id)
|
|
if not row:
|
|
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
|
|
folder = (row.get("folder") or "").strip()
|
|
if not folder:
|
|
raise GrottiError("Das Projekt hat keinen Ordner.", "ordner")
|
|
path = library.resolve_project_asset(cfg, folder, rel)
|
|
return send_file(path, as_attachment=True, download_name=path.name)
|
|
|
|
@app.delete("/api/v1/projekte/<int:project_id>/assets/<path:rel>")
|
|
def project_assets_delete(project_id, rel):
|
|
row = store.get_project(conn(), project_id)
|
|
if not row:
|
|
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
|
|
folder = (row.get("folder") or "").strip()
|
|
if not folder:
|
|
raise GrottiError("Das Projekt hat keinen Ordner.", "ordner")
|
|
library.delete_project_asset(cfg, folder, rel)
|
|
return "", 204
|
|
|
|
@app.get("/api/v1/dateien")
|
|
def file_list():
|
|
library.sync_watch(conn(), cfg, force=False)
|
|
family = (request.args.get("family") or "").strip()
|
|
material = (request.args.get("material") or "").strip()
|
|
q = (request.args.get("q") or "").strip()
|
|
host = optional_host(request.args.get("host") or "")
|
|
project_id = optional_id(request.args.get("project_id") or request.args.get("project"))
|
|
limit_raw = request.args.get("limit")
|
|
offset_raw = request.args.get("offset")
|
|
limit = None
|
|
offset = 0
|
|
if limit_raw is not None and str(limit_raw).strip() != "":
|
|
try:
|
|
limit = max(1, min(500, int(limit_raw)))
|
|
except (TypeError, ValueError):
|
|
limit = 40
|
|
if offset_raw is not None:
|
|
try:
|
|
offset = max(0, int(offset_raw))
|
|
except (TypeError, ValueError):
|
|
offset = 0
|
|
data = store.list_files(
|
|
conn(),
|
|
watch_dir=cfg.get("watch_dir"),
|
|
family=family,
|
|
material=material,
|
|
project_id=project_id,
|
|
q=q,
|
|
host=host,
|
|
limit=limit,
|
|
offset=offset,
|
|
)
|
|
resp = jsonify(data["items"])
|
|
resp.headers["X-Total-Count"] = str(data["total"])
|
|
return resp
|
|
|
|
@app.post("/api/v1/dateien/<int:file_id>/meta")
|
|
def file_meta_refresh(file_id):
|
|
"""Eine Datei neu parsen — z. B. paint_index für ACE nach Orca-Deep-Link."""
|
|
out = library.refresh_one_file(conn(), file_id, watch_dir=cfg.get("watch_dir"))
|
|
if not out:
|
|
raise GrottiError("Datei nicht gefunden.", "datei", 404)
|
|
return jsonify(out)
|
|
|
|
@app.get("/api/v1/orca-fokus")
|
|
def orca_focus():
|
|
"""Zuletzt von orca_senden gesetzte Datei — offene Tabs schalten um statt neu zu öffnen."""
|
|
path = Path(cfg["data_dir"]) / "orca-senden-focus.json"
|
|
if not path.is_file():
|
|
return jsonify({})
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
return jsonify({})
|
|
if not isinstance(data, dict) or data.get("file_id") is None:
|
|
return jsonify({})
|
|
return jsonify(data)
|
|
|
|
@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/<int:file_id>/umbenennen")
|
|
def file_rename(file_id):
|
|
data = body()
|
|
name = (data.get("name") or "").strip()
|
|
if not name:
|
|
raise GrottiError("Neuer Dateiname fehlt.", "datei")
|
|
return jsonify(library.rename_library_file(conn(), cfg, file_id, name))
|
|
|
|
@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:
|
|
raise GrottiError("Vorschau fehlt.", "vorschau", 404)
|
|
png = library.preview_png(row, conn=conn(), data_dir=cfg["data_dir"])
|
|
if not png:
|
|
raise GrottiError("Vorschau fehlt.", "vorschau", 404)
|
|
return png, 200, {"Content-Type": "image/png", "Cache-Control": "private, max-age=300"}
|
|
|
|
@app.get("/api/v1/dateien/<int:file_id>/pfad")
|
|
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):
|
|
library.delete_library_file(conn(), cfg, file_id)
|
|
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")),
|
|
marlin_mode=(data.get("marlin_mode") or "stream"),
|
|
)
|
|
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:
|
|
raise GrottiError("Host nötig.", "senden")
|
|
if file_id:
|
|
row = store.latest_send_for_file(conn(), host, file_id)
|
|
else:
|
|
row = store.latest_send_for_host(conn(), host)
|
|
if not row:
|
|
return jsonify(None)
|
|
return jsonify(row)
|
|
|
|
@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
|
|
try:
|
|
offset = max(0, int(request.args.get("offset") or 0))
|
|
except (TypeError, ValueError):
|
|
offset = 0
|
|
host = optional_host(request.args.get("host") or "")
|
|
status = (request.args.get("status") or "").strip()
|
|
data = store.list_print_queue(
|
|
conn(), limit=limit, offset=offset, host=host, status=status
|
|
)
|
|
resp = jsonify(data["items"])
|
|
resp.headers["X-Total-Count"] = str(data["total"])
|
|
return resp
|
|
|
|
@app.get("/api/v1/laeuft")
|
|
def running_now():
|
|
"""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)["items"]
|
|
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)
|
|
# Live-Lauf schließen ohne Abzug — History bucht; Fallback nach Frist.
|
|
if run_row and (run_row.get("state") or "") in ("in_progress", "printing", "paused"):
|
|
closed = store.complete_open_run(c, run_row["id"], state="completed")
|
|
if closed:
|
|
try:
|
|
jobs.sync_history(c, cfg, host=host)
|
|
except Exception as exc:
|
|
log.warning("Abräumen Abgleich %s: %s", host, exc)
|
|
row = store.get_run_row(c, closed["id"]) or closed
|
|
run_json = store.run_out(row)
|
|
reprint = None
|
|
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:
|
|
from grotti.drivers import for_printer
|
|
printer = store.printer_by_host(conn(), row["host"])
|
|
if printer:
|
|
for_printer(printer).command(row["host"], "abbrechen")
|
|
stopped = ", Druck gestoppt"
|
|
else:
|
|
stopped = ", Druck nicht 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/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():
|
|
aktiv_raw = (request.args.get("aktiv") or "").strip().lower()
|
|
aktiv = None
|
|
if aktiv_raw in ("1", "true", "yes"):
|
|
aktiv = True
|
|
elif aktiv_raw in ("0", "false", "no"):
|
|
aktiv = False
|
|
limit_raw = request.args.get("limit")
|
|
limit = None
|
|
offset = 0
|
|
if limit_raw is not None and str(limit_raw).strip() != "":
|
|
try:
|
|
limit = max(1, min(500, int(limit_raw)))
|
|
except (TypeError, ValueError):
|
|
limit = 100
|
|
try:
|
|
offset = max(0, int(request.args.get("offset") or 0))
|
|
except (TypeError, ValueError):
|
|
offset = 0
|
|
data = store.list_spools(conn(), aktiv=aktiv, limit=limit, offset=offset)
|
|
resp = jsonify(data["items"])
|
|
resp.headers["X-Total-Count"] = str(data["total"])
|
|
return resp
|
|
|
|
@app.get("/api/v1/spulen/by-code/<path:code>")
|
|
def spool_by_code(code):
|
|
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):
|
|
data = body()
|
|
fields = spool_fields(data, partial=True, spool_id=spool_id)
|
|
if "archived" in data or "archived_at" in data:
|
|
if data.get("archived") is False or data.get("archived_at") is None:
|
|
fields["archived_at"] = None
|
|
elif data.get("archived") is True or data.get("archived_at") in (True, "now"):
|
|
fields["archived_at"] = "now"
|
|
row = store.update_spool(conn(), spool_id, fields)
|
|
if not row:
|
|
raise GrottiError("Die Spule fehlt.", "spule", 404)
|
|
return jsonify(row)
|
|
|
|
@app.post("/api/v1/spulen/<int:spool_id>/leer")
|
|
def spool_empty(spool_id):
|
|
"""Rest 0, Regal, archivieren — wenn der Rest falsch geschätzt war."""
|
|
row = store.mark_spool_empty(conn(), spool_id)
|
|
if not row:
|
|
raise GrottiError("Die Spule fehlt.", "spule", 404)
|
|
store.add_event(conn(), "", "", "spule", "Spule „%s“ als leer markiert" % row["name"])
|
|
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)
|
|
# Auch offene History-Jobs (Pause/Resume nach Moonraker-Split), nicht nur live:
|
|
live_run = store.find_active_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)
|
|
elif not closed and mm_now > 0:
|
|
# ACE ohne Startabschnitt oder alte Rolle schon im Regal —
|
|
# slot-genau: Platz / ACE-Map / letzter Abschnitt / Lauf-Hinweis.
|
|
prev_id = store.prev_spool_id_for_slot(
|
|
conn(), host, slot, live_run, spool_id, old_rows=old,
|
|
)
|
|
if prev_id:
|
|
store.open_segment(conn(), live_run["id"], prev_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.assign_run_spool(conn(), live_run["id"], spool_id)
|
|
store.update_send_spool(conn(), host, live_run.get("filename") or "", spool_id)
|
|
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():
|
|
try:
|
|
limit = min(1000, max(1, int(request.args.get("limit") or 500)))
|
|
except (TypeError, ValueError):
|
|
limit = 500
|
|
return jsonify(store.list_runs(conn(), limit=limit))
|
|
|
|
@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)
|
|
if run.get("order_item_id") and not run.get("order_id"):
|
|
oi = conn().execute(
|
|
"SELECT order_id FROM order_items WHERE id = %s",
|
|
(run["order_item_id"],),
|
|
).fetchone()
|
|
if oi:
|
|
run["order_id"] = oi["order_id"]
|
|
file_row = store.get_file_row(conn(), run["file_id"]) if run.get("file_id") else None
|
|
project = (
|
|
store.get_project(conn(), run["project_id"], watch_dir=cfg.get("watch_dir"))
|
|
if run.get("project_id") else None
|
|
)
|
|
samples = store.list_run_samples(conn(), run_id)
|
|
from grotti.cost import energy_estimate
|
|
|
|
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, watch_dir=cfg.get("watch_dir"), conn=conn()) 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.post("/api/v1/laeufe/<int:run_id>/verbinden")
|
|
def run_merge(run_id):
|
|
"""Zwei Protokoll-Zeilen desselben physischen Drucks verbinden (admin)."""
|
|
require_role("admin")
|
|
data = body()
|
|
other = int(data.get("other_id") or data.get("mit") or 0)
|
|
if not other:
|
|
raise GrottiError("other_id fehlt.", "lauf")
|
|
return jsonify(jobs.manual_merge_runs(
|
|
conn(), run_id, other, confirm=bool(data.get("confirm"))
|
|
))
|
|
|
|
@app.patch("/api/v1/laeufe/<int:run_id>")
|
|
def run_patch(run_id):
|
|
data = body()
|
|
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()
|
|
color = (request.args.get("color") 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 "",
|
|
color=color 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
|