Slice-Watch mit Ordnern/Filtern, Orca-Nachbearbeitung, Cockpit-Verlauf mit ED, Zeitzone im Browser. Tasten-Grenze und README-API angeglichen, toter Code entfernt, Requirements gepinnt.
1986 lines
79 KiB
Python
1986 lines
79 KiB
Python
"""Flask hängt die API und die Seiten an den Kern."""
|
|
|
|
import json
|
|
import re
|
|
import secrets
|
|
import time
|
|
import uuid
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from urllib.parse import quote
|
|
|
|
import markdown
|
|
import psycopg
|
|
from flask import Flask, abort, g, jsonify, redirect, render_template, request, session, url_for
|
|
from markupsafe import Markup
|
|
from psycopg.rows import dict_row
|
|
|
|
from grotti import auth as grotti_auth
|
|
from grotti import config as grotti_config
|
|
from grotti import eject, jobs, library, moonraker, notify, store
|
|
from grotti.catalog import color_catalog
|
|
from grotti.cost import calculate
|
|
from grotti.errors import GrottiError
|
|
from grotti.printers import FAMILY_LABELS, family_from_name
|
|
from grotti.print_queue import PrintQueueDispatcher
|
|
from grotti.send_queue import SendQueue
|
|
from grotti.toolpath import TOOLPATH_VERSION, build_toolpath
|
|
from grotti.version import __version__
|
|
from grotti.watcher import StateWatcher
|
|
|
|
ROOT = Path(__file__).resolve().parent
|
|
TOOL_ROOT = ROOT.parent
|
|
DOCS_DIR = TOOL_ROOT / "docs"
|
|
PROJECT_STATUS = {"idee", "bereit", "druckt", "fertig"}
|
|
VERDICTS = {"", "gut", "ausschuss"}
|
|
IDEA_STATUS = {"offen", "spaeter", "erledigt"}
|
|
PRINTER_FAMILIES = set(FAMILY_LABELS) | {""}
|
|
STEER_ACTIONS = {
|
|
"nozzle", "bed", "speed", "flow", "fan", "emergency_stop", "clear_error",
|
|
"exclude_object", "dryer_start", "dryer_stop", "slot_setzen",
|
|
}
|
|
|
|
|
|
def create_app(cfg):
|
|
app = Flask(
|
|
__name__,
|
|
template_folder=str(ROOT / "templates"),
|
|
static_folder=str(ROOT / "static"),
|
|
)
|
|
app.config["GROTTI"] = cfg
|
|
app.config["SECRET_KEY"] = cfg.get("secret_key") or secrets.token_hex(32)
|
|
app.config["TEMPLATES_AUTO_RELOAD"] = True
|
|
app.config["MAX_CONTENT_LENGTH"] = 256 * 1024 * 1024
|
|
app.config["SESSION_COOKIE_HTTPONLY"] = True
|
|
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
|
if cfg.get("fqdn", "").startswith("https://"):
|
|
app.config["SESSION_COOKIE_SECURE"] = True
|
|
app.json.ensure_ascii = False
|
|
app.extensions["send_queue"] = SendQueue(cfg["database_url"], cfg)
|
|
watcher = StateWatcher(cfg["database_url"], cfg)
|
|
dispatcher = PrintQueueDispatcher(cfg["database_url"], cfg, watcher=watcher)
|
|
app.extensions["print_queue"] = dispatcher
|
|
app.extensions["watcher"] = watcher
|
|
watcher.start()
|
|
dispatcher.start()
|
|
|
|
PUBLIC_PATHS = {
|
|
"/login",
|
|
"/setup",
|
|
"/api/v1/auth/status",
|
|
"/api/v1/auth/login",
|
|
"/api/v1/auth/setup",
|
|
"/api/v1/version",
|
|
}
|
|
ADMIN_API_PREFIXES = (
|
|
"/api/v1/users",
|
|
"/api/v1/einstellungen",
|
|
)
|
|
ADMIN_API_EXACT = {
|
|
"/api/v1/telegram/test",
|
|
}
|
|
ADMIN_METHODS_ON = {
|
|
# Drucker anlegen/ändern/löschen nur Admin
|
|
("POST", "/api/v1/drucker"),
|
|
}
|
|
|
|
def conn():
|
|
if "db" not in g:
|
|
g.db = psycopg.connect(cfg["database_url"], row_factory=dict_row)
|
|
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):
|
|
legacy = cfg.get("token") or ""
|
|
if legacy and secrets.compare_digest(presented, legacy):
|
|
return {
|
|
"id": None,
|
|
"username": "legacy-token",
|
|
"role": "admin",
|
|
"auth": "legacy",
|
|
}
|
|
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 request.path.startswith("/api/"):
|
|
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 auto_queue schalten
|
|
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"} != 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):
|
|
conn = g.pop("db", None)
|
|
if not conn:
|
|
return
|
|
try:
|
|
if exc:
|
|
conn.rollback()
|
|
else:
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
@app.errorhandler(GrottiError)
|
|
def grotti_error(err):
|
|
db = g.get("db")
|
|
if db:
|
|
db.rollback()
|
|
return jsonify({"error": err.message, "code": err.code}), err.status
|
|
|
|
def body():
|
|
data = request.get_json(silent=True)
|
|
if not isinstance(data, dict):
|
|
raise GrottiError("JSON-Objekt erwartet.", "json")
|
|
return data
|
|
|
|
def optional_id(value):
|
|
if value in (None, "", 0, "0"):
|
|
return None
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError) as exc:
|
|
raise GrottiError("Die Nummer stimmt nicht.", "id") from exc
|
|
|
|
def require_host(host):
|
|
if not store.printer_by_host(conn(), host or ""):
|
|
raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404)
|
|
return host
|
|
|
|
def optional_host(host):
|
|
host = (host or "").strip()
|
|
if host:
|
|
require_host(host)
|
|
return host
|
|
|
|
@app.context_processor
|
|
def inject_globals():
|
|
user = auth_user()
|
|
return {
|
|
"version": __version__,
|
|
"csrf": session.get("csrf") or "",
|
|
"current_user": user,
|
|
}
|
|
|
|
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")
|
|
store.touch_user_login(conn(), row["id"])
|
|
session.clear()
|
|
session["user_id"] = row["id"]
|
|
session["csrf"] = grotti_auth.new_csrf()
|
|
store.add_event(conn(), "", "", "auth", "Admin %s angelegt" % username)
|
|
return jsonify({"user": grotti_auth.public_user(row), "csrf": session["csrf"]}), 201
|
|
|
|
@app.post("/api/v1/auth/login")
|
|
def auth_login():
|
|
if store.user_count(conn()) == 0:
|
|
raise GrottiError("Zuerst Admin anlegen.", "setup", 503)
|
|
key = request.remote_addr or "unknown"
|
|
if not grotti_auth.login_rate_ok(key):
|
|
raise GrottiError("Zu viele Versuche. Bitte warten.", "rate", 429)
|
|
data = body()
|
|
username = (data.get("username") or "").strip()
|
|
password = data.get("password") or ""
|
|
row = store.get_user_by_name(conn(), username)
|
|
if not row or not row.get("active") or not grotti_auth.verify_password(row["password_hash"], password):
|
|
grotti_auth.login_rate_hit(key)
|
|
raise GrottiError("Benutzer oder Passwort falsch.", "auth", 401)
|
|
store.touch_user_login(conn(), row["id"])
|
|
session.clear()
|
|
session["user_id"] = row["id"]
|
|
session["csrf"] = grotti_auth.new_csrf()
|
|
store.add_event(conn(), "", "", "auth", "Login %s" % row["username"])
|
|
return jsonify({"user": grotti_auth.public_user(row), "csrf": session["csrf"]})
|
|
|
|
@app.post("/api/v1/auth/logout")
|
|
def auth_logout():
|
|
session.clear()
|
|
return jsonify({"ok": True})
|
|
|
|
@app.get("/api/v1/me")
|
|
def me():
|
|
user = require_role("operator")
|
|
return jsonify({"user": {
|
|
"id": user["id"],
|
|
"username": user["username"],
|
|
"role": user["role"],
|
|
"auth": user.get("auth"),
|
|
}, "csrf": ensure_csrf()})
|
|
|
|
@app.get("/api/v1/users")
|
|
def users_list():
|
|
require_role("admin")
|
|
return jsonify([grotti_auth.public_user(r) for r in store.list_users(conn())])
|
|
|
|
@app.post("/api/v1/users")
|
|
def users_create():
|
|
require_role("admin")
|
|
data = body()
|
|
username = grotti_auth.validate_username(data.get("username") or "")
|
|
password = grotti_auth.validate_password(data.get("password") or "")
|
|
role = grotti_auth.normalize_role(data.get("role") or "operator")
|
|
if store.get_user_by_name(conn(), username):
|
|
raise GrottiError("Benutzer gibt es schon.", "user", 409)
|
|
row = store.create_user(conn(), username, grotti_auth.hash_password(password), role)
|
|
audit("auth", "Benutzer %s (%s) angelegt" % (username, role))
|
|
return jsonify(grotti_auth.public_user(row)), 201
|
|
|
|
@app.patch("/api/v1/users/<int:user_id>")
|
|
def users_patch(user_id):
|
|
actor = require_role("admin")
|
|
data = body()
|
|
row = store.get_user(conn(), user_id)
|
|
if not row:
|
|
raise GrottiError("Benutzer fehlt.", "user", 404)
|
|
password_hash = None
|
|
if data.get("password"):
|
|
password_hash = grotti_auth.hash_password(grotti_auth.validate_password(data["password"]))
|
|
role = grotti_auth.normalize_role(data["role"]) if "role" in data else None
|
|
active = data.get("active") if "active" in data else None
|
|
if active is False and row["id"] == actor.get("id"):
|
|
raise GrottiError("Eigenes Konto nicht deaktivieren.", "user")
|
|
if role == "operator" and row["role"] == "admin" and row["id"] == actor.get("id"):
|
|
admins = [u for u in store.list_users(conn()) if u["role"] == "admin" and u["active"]]
|
|
if len(admins) <= 1:
|
|
raise GrottiError("Letzter Admin bleibt Admin.", "user")
|
|
updated = store.update_user(conn(), user_id, password_hash=password_hash, role=role, active=active)
|
|
audit("auth", "Benutzer %s geändert" % row["username"])
|
|
return jsonify(grotti_auth.public_user(updated))
|
|
|
|
@app.delete("/api/v1/users/<int:user_id>")
|
|
def users_delete(user_id):
|
|
actor = require_role("admin")
|
|
row = store.get_user(conn(), user_id)
|
|
if not row:
|
|
raise GrottiError("Benutzer fehlt.", "user", 404)
|
|
if row["id"] == actor.get("id"):
|
|
raise GrottiError("Eigenes Konto nicht löschen.", "user")
|
|
if row["role"] == "admin":
|
|
admins = [u for u in store.list_users(conn()) if u["role"] == "admin" and u["active"]]
|
|
if len(admins) <= 1:
|
|
raise GrottiError("Letzter Admin bleibt.", "user")
|
|
store.delete_user(conn(), user_id)
|
|
audit("auth", "Benutzer %s gelöscht" % row["username"])
|
|
return "", 204
|
|
|
|
@app.get("/api/v1/tokens")
|
|
def tokens_list():
|
|
user = require_role("operator")
|
|
if user.get("role") == "admin" and request.args.get("all") == "1":
|
|
rows = store.list_api_tokens(conn(), user_id=None)
|
|
else:
|
|
if not user.get("id"):
|
|
return jsonify([])
|
|
rows = store.list_api_tokens(conn(), user_id=user["id"])
|
|
out = []
|
|
for row in rows:
|
|
item = grotti_auth.public_token(row)
|
|
item["username"] = row.get("username")
|
|
out.append(item)
|
|
return jsonify(out)
|
|
|
|
@app.post("/api/v1/tokens")
|
|
def tokens_create():
|
|
user = require_role("operator")
|
|
if not user.get("id"):
|
|
raise GrottiError("Legacy-Token: bitte Benutzer-Token anlegen.", "token")
|
|
data = body()
|
|
name = (data.get("name") or "Token").strip()[:80]
|
|
role = grotti_auth.clamp_token_role(user["role"], data.get("role"))
|
|
raw = grotti_auth.new_api_token()
|
|
row = store.create_api_token(
|
|
conn(),
|
|
user["id"],
|
|
name,
|
|
grotti_auth.hash_token(raw),
|
|
grotti_auth.token_prefix(raw),
|
|
role,
|
|
)
|
|
audit("auth", "API-Token „%s“ erzeugt" % name)
|
|
item = grotti_auth.public_token(row)
|
|
item["token"] = raw
|
|
return jsonify(item), 201
|
|
|
|
@app.delete("/api/v1/tokens/<int:token_id>")
|
|
def tokens_revoke(token_id):
|
|
user = require_role("operator")
|
|
if user.get("role") == "admin":
|
|
row = store.revoke_api_token(conn(), token_id, user_id=None)
|
|
else:
|
|
row = store.revoke_api_token(conn(), token_id, user_id=user.get("id"))
|
|
if not row:
|
|
raise GrottiError("Token fehlt.", "token", 404)
|
|
audit("auth", "API-Token #%s widerrufen" % token_id)
|
|
return jsonify(grotti_auth.public_token(row))
|
|
|
|
def doc_names():
|
|
"""Nur Anleitung-*.md — Technik liegt unter docs/internal/ (nicht in der App)."""
|
|
if not DOCS_DIR.is_dir():
|
|
return []
|
|
return sorted(p.stem for p in DOCS_DIR.glob("Anleitung-*.md"))
|
|
|
|
ANLEITUNG_ORDER = (
|
|
"Anleitung-Einrichtung",
|
|
"Anleitung-Oberflaeche",
|
|
"Anleitung-Wand",
|
|
"Anleitung-Dateien",
|
|
"Anleitung-Orca",
|
|
"Anleitung-ACE",
|
|
"Anleitung-Warteschlange",
|
|
"Anleitung-Material",
|
|
"Anleitung-Einstellungen",
|
|
"Anleitung-Stoerungen",
|
|
)
|
|
|
|
def anleitung_list(names=None):
|
|
names = names if names is not None else doc_names()
|
|
anleitung_set = set(names)
|
|
ordered = [n for n in ANLEITUNG_ORDER if n in anleitung_set]
|
|
ordered += sorted(anleitung_set - set(ordered))
|
|
return ordered
|
|
|
|
def anleitung_title(name):
|
|
labels = {
|
|
"Anleitung-Einrichtung": "Einrichtung",
|
|
"Anleitung-Oberflaeche": "Oberfläche",
|
|
"Anleitung-Wand": "Druckerwand",
|
|
"Anleitung-Dateien": "Dateien senden",
|
|
"Anleitung-Orca": "Orca an Grotti",
|
|
"Anleitung-ACE": "ACE und Spulen",
|
|
"Anleitung-Warteschlange": "Warteschlange",
|
|
"Anleitung-Material": "Material und mehr",
|
|
"Anleitung-Einstellungen": "Einstellungen",
|
|
"Anleitung-Stoerungen": "Störungen",
|
|
}
|
|
return labels.get(name, name.replace("Anleitung-", "").replace("-", " "))
|
|
|
|
def render_markdown(path):
|
|
"""Markdown für die App: Git-relative Bild-/Kapitelpfade → App-URLs."""
|
|
text = path.read_text(encoding="utf-8")
|
|
# docs/… → web/static/… im Repo; unter /doku als /static/…
|
|
text = text.replace("../web/static/", "/static/")
|
|
text = re.sub(
|
|
r"\]\((Anleitung-[A-Za-z0-9_-]+)(?:\.md)?\)",
|
|
r"](/doku/\1)",
|
|
text,
|
|
)
|
|
html = markdown.markdown(text, extensions=["tables", "fenced_code", "sane_lists"])
|
|
return Markup(html)
|
|
|
|
@app.get("/")
|
|
def wand():
|
|
return page("wand.html", "Drucker")
|
|
|
|
@app.get("/kameras")
|
|
def kameras():
|
|
return page("kameras.html", "Kameras")
|
|
|
|
@app.get("/dateien")
|
|
def dateien():
|
|
return page("dateien.html", "Dateien")
|
|
|
|
@app.get("/projekte")
|
|
def projekte():
|
|
return page("projekte.html", "Projekte")
|
|
|
|
@app.get("/einstellungen")
|
|
def einstellungen_page():
|
|
return page("einstellungen.html", "Einstellungen")
|
|
|
|
@app.get("/material")
|
|
def material():
|
|
return page("material.html", "Material")
|
|
|
|
@app.get("/etikett/<int:spool_id>")
|
|
def etikett(spool_id):
|
|
row = store.get_spool_row(conn(), spool_id)
|
|
if not row:
|
|
abort(404)
|
|
spool = store.spool_out(row)
|
|
if not spool.get("chargen_nr"):
|
|
store.ensure_spool_codes(conn())
|
|
row = store.get_spool_row(conn(), spool_id)
|
|
spool = store.spool_out(row)
|
|
base = grotti_config.public_base(cfg, request.url_root)
|
|
qr_url = "%s/material?spool=%s" % (base, spool_id)
|
|
return render_template(
|
|
"etikett.html",
|
|
page="etikett",
|
|
title="Etikett %s" % spool["chargen_nr"],
|
|
token="",
|
|
spool=spool,
|
|
qr_url=qr_url,
|
|
)
|
|
|
|
@app.get("/protokoll")
|
|
def protokoll():
|
|
return page("protokoll.html", "Protokoll")
|
|
|
|
@app.get("/protokoll/<int:run_id>")
|
|
def protokoll_detail(run_id):
|
|
row = store.get_run_row(conn(), run_id)
|
|
if not row:
|
|
abort(404)
|
|
return page("bericht.html", "Druckbericht")
|
|
|
|
@app.get("/statistik")
|
|
def statistik_page():
|
|
return page("statistik.html", "Statistik")
|
|
|
|
@app.get("/etikett/lauf/<int:run_id>")
|
|
def etikett_lauf(run_id):
|
|
row = store.get_run_row(conn(), run_id)
|
|
if not row:
|
|
abort(404)
|
|
run = store.run_out(row)
|
|
if not run.get("chargen_nr"):
|
|
store.ensure_run_chargen(conn(), run_id, run.get("weight_used_g"))
|
|
row = store.get_run_row(conn(), run_id)
|
|
run = store.run_out(row)
|
|
if not run.get("chargen_nr"):
|
|
abort(404)
|
|
labels = store.list_run_labels(conn(), run_id)
|
|
mode = (request.args.get("mode") or "druck").strip()
|
|
qty = max(1, min(50, int(request.args.get("qty") or 1)))
|
|
if mode == "objekt":
|
|
labels = store.ensure_run_labels(conn(), run_id, run.get("objects") or [], qty=qty)
|
|
sheets = [lab for lab in labels if not lab.get("excluded")]
|
|
else:
|
|
sheets = [{"object_name": "", "chargen_nr": run["chargen_nr"], "excluded": False}]
|
|
if not sheets:
|
|
abort(404)
|
|
spools = store.etikett_spools_for_run(conn(), run)
|
|
base = grotti_config.public_base(cfg, request.url_root)
|
|
target_g = float(run.get("weight_planned_g") or run.get("weight_g") or 0)
|
|
for sheet in sheets:
|
|
sheet["qr_url"] = "%s/protokoll/%s" % (base, run_id)
|
|
sheet["target_g"] = target_g
|
|
if sheet.get("object_name"):
|
|
sheet["qr_url"] += "#objekt-%s" % quote(sheet["object_name"])
|
|
return render_template(
|
|
"etikett_lauf.html",
|
|
page="etikett",
|
|
title="Etikett %s" % run["chargen_nr"],
|
|
token="",
|
|
run=run,
|
|
sheets=sheets,
|
|
spools=spools,
|
|
target_g=target_g,
|
|
)
|
|
|
|
@app.get("/ideen")
|
|
def ideen():
|
|
return page("ideen.html", "Ideen")
|
|
|
|
@app.get("/aktivitaet")
|
|
def aktivitaet():
|
|
return page("aktivitaet.html", "Aktivität")
|
|
|
|
@app.get("/warteschlange")
|
|
def warteschlange_page():
|
|
return page("warteschlange.html", "Warteschlange")
|
|
|
|
@app.get("/doku")
|
|
def doku():
|
|
names = doc_names()
|
|
anleitung = anleitung_list(names)
|
|
return render_template(
|
|
"doku.html",
|
|
page="doku",
|
|
title="Doku",
|
|
token="",
|
|
docs=names,
|
|
anleitung=anleitung,
|
|
anleitung_title=anleitung_title,
|
|
)
|
|
|
|
@app.get("/doku/<name>")
|
|
def doku_page(name):
|
|
names = doc_names()
|
|
if name not in names:
|
|
abort(404)
|
|
path = DOCS_DIR / ("%s.md" % name)
|
|
if not path.is_file():
|
|
abort(404)
|
|
anleitung = anleitung_list(names)
|
|
return render_template(
|
|
"doku_page.html",
|
|
page="doku",
|
|
title=anleitung_title(name),
|
|
token="",
|
|
content=render_markdown(path),
|
|
docs=names,
|
|
anleitung=anleitung,
|
|
anleitung_title=anleitung_title,
|
|
current=name,
|
|
)
|
|
|
|
@app.get("/api/v1/version")
|
|
def version_info():
|
|
return jsonify({"version": __version__})
|
|
|
|
@app.get("/api/v1/einstellungen")
|
|
def settings():
|
|
return jsonify(grotti_config.settings_public(cfg))
|
|
|
|
@app.patch("/api/v1/einstellungen")
|
|
def settings_patch():
|
|
data = body()
|
|
energy = data.get("energy") if isinstance(data.get("energy"), dict) else None
|
|
telegram = data.get("telegram") if isinstance(data.get("telegram"), dict) else None
|
|
alerts = data.get("alerts") if isinstance(data.get("alerts"), dict) else None
|
|
auto_queue = data.get("auto_queue") if "auto_queue" in data else None
|
|
fqdn = data.get("fqdn") if "fqdn" in data else None
|
|
watch_dir = data.get("watch_dir") if "watch_dir" in data else None
|
|
return jsonify(grotti_config.save_settings(
|
|
cfg,
|
|
energy=energy,
|
|
telegram=telegram,
|
|
alerts=alerts,
|
|
auto_queue=auto_queue,
|
|
fqdn=fqdn,
|
|
watch_dir=watch_dir,
|
|
))
|
|
|
|
@app.post("/api/v1/telegram/test")
|
|
def telegram_test():
|
|
ok = notify.send_telegram(cfg, "Grotti-Test: Telegram ist verbunden.")
|
|
if not ok:
|
|
raise GrottiError(
|
|
"Telegram nicht erreichbar. Token, Chat-ID und „aktiv“ prüfen.",
|
|
"telegram",
|
|
)
|
|
return jsonify({"ok": True})
|
|
|
|
@app.get("/api/v1/drucker")
|
|
@app.get("/api/drucker") # veraltet — Alias zu /api/v1/drucker
|
|
def printers():
|
|
rows = store.list_printers(conn())
|
|
clears = store.bed_clear_map(conn())
|
|
queues = store.queue_by_host(conn(), rows)
|
|
|
|
def attach(item):
|
|
host = item.get("host") or ""
|
|
item["needs_clear"] = bool(clears.get(host))
|
|
queue = queues.get(host) or {}
|
|
item["queue_running"] = queue.get("running")
|
|
item["queue_failed"] = queue.get("failed")
|
|
item["queue_waiting"] = queue.get("waiting", 0)
|
|
item["queue_waiting_next"] = queue.get("waiting_next", "")
|
|
live_run = store.find_live_run(conn(), host) if host else None
|
|
item["live_run_id"] = (live_run or {}).get("id")
|
|
item["live_chargen"] = (live_run or {}).get("chargen_nr") or ""
|
|
|
|
by_id = {row["host"]: row for row in rows}
|
|
if request.args.get("live", "1") == "0":
|
|
for row in rows:
|
|
row["mainsail"] = "http://%s/" % row["host"]
|
|
attach(row)
|
|
return jsonify(rows)
|
|
# Eine Live-Quelle: Watcher. Frischer als 60 s, sonst einmal selbst abfragen.
|
|
watcher = app.extensions["watcher"]
|
|
cached = watcher.by_host()
|
|
age = watcher.age_s()
|
|
if not cached or age is None or age > 60:
|
|
lives = moonraker.snapshot(rows)
|
|
else:
|
|
lives = []
|
|
for row in rows:
|
|
live = cached.get(row["host"])
|
|
if live:
|
|
item = dict(live)
|
|
item["name"] = row["name"]
|
|
item["family"] = row["family"]
|
|
item["family_label"] = row["family_label"]
|
|
lives.append(item)
|
|
else:
|
|
item = dict(row)
|
|
item["ok"] = False
|
|
item["state"] = "offline"
|
|
item["mainsail"] = "http://%s/" % row["host"]
|
|
lives.append(item)
|
|
for live in lives:
|
|
meta = by_id.get(live.get("host") or "") or {}
|
|
live["locked"] = bool(meta.get("locked"))
|
|
attach(live)
|
|
return jsonify(lives)
|
|
|
|
@app.post("/api/v1/drucker")
|
|
def printer_create():
|
|
data = body()
|
|
name = (data.get("name") or "").strip()
|
|
host = (data.get("host") or "").strip()
|
|
if not name:
|
|
raise GrottiError("Der Drucker braucht einen Namen.", "name")
|
|
if not host:
|
|
raise GrottiError("Der Drucker braucht eine IP oder einen Host.", "host")
|
|
family = (data.get("family") or "").strip() or family_from_name(name)
|
|
if family not in PRINTER_FAMILIES:
|
|
raise GrottiError("Unbekannte Druckerfamilie.", "family")
|
|
if store.printer_by_host(conn(), host):
|
|
raise GrottiError("Diesen Host gibt es schon.", "host")
|
|
return jsonify(store.create_printer(conn(), uuid.uuid4(), name, host, family)), 201
|
|
|
|
@app.patch("/api/v1/drucker/id/<printer_id>")
|
|
def printer_patch(printer_id):
|
|
data = body()
|
|
current = store.printer_by_id(conn(), printer_id)
|
|
if not current:
|
|
raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404)
|
|
name = (data.get("name") if "name" in data else current["name"]) or ""
|
|
name = str(name).strip()
|
|
host = (data.get("host") if "host" in data else current["host"]) or ""
|
|
host = str(host).strip()
|
|
family = data.get("family") if "family" in data else current["family"]
|
|
family = (family or "").strip() or family_from_name(name)
|
|
if not name:
|
|
raise GrottiError("Der Drucker braucht einen Namen.", "name")
|
|
if not host:
|
|
raise GrottiError("Der Drucker braucht eine IP oder einen Host.", "host")
|
|
if family not in PRINTER_FAMILIES:
|
|
raise GrottiError("Unbekannte Druckerfamilie.", "family")
|
|
other = store.printer_by_host(conn(), host)
|
|
if other and str(other["id"]) != str(printer_id):
|
|
raise GrottiError("Diesen Host gibt es schon.", "host")
|
|
locked = data.get("locked") if "locked" in data else current.get("locked")
|
|
row = store.update_printer(conn(), printer_id, name, host, family, locked=bool(locked))
|
|
return jsonify(row)
|
|
|
|
@app.delete("/api/v1/drucker/id/<printer_id>")
|
|
def printer_delete(printer_id):
|
|
if not store.delete_printer(conn(), printer_id):
|
|
raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404)
|
|
return "", 204
|
|
|
|
@app.post("/api/v1/drucker/<host>/<action>")
|
|
def printer_action(host, action):
|
|
require_host(host)
|
|
if action not in ("pause", "fortsetzen", "abbrechen"):
|
|
raise GrottiError("Unbekannter Befehl.", "befehl")
|
|
return jsonify(jobs.control(conn(), host, action))
|
|
|
|
@app.post("/api/v1/drucker/<host>/steuern")
|
|
def printer_steer(host):
|
|
require_host(host)
|
|
data = body()
|
|
action = (data.get("action") or "").strip()
|
|
if action not in STEER_ACTIONS:
|
|
raise GrottiError("Unbekannte Steuerung.", "befehl")
|
|
if action in ("emergency_stop", "clear_error", "exclude_object") and not data.get("confirm"):
|
|
raise GrottiError("Bestätigung fehlt.", "bestaetigung")
|
|
return jsonify(jobs.steuern(conn(), host, action, data))
|
|
|
|
@app.get("/api/v1/kamera")
|
|
def camera():
|
|
host = require_host(request.args.get("host", ""))
|
|
image = moonraker.camera_image(host)
|
|
if not image:
|
|
abort(404)
|
|
return image[1], 200, {"Content-Type": image[0], "Cache-Control": "no-store"}
|
|
|
|
@app.get("/api/v1/vorschau")
|
|
def live_preview():
|
|
host = require_host(request.args.get("host", ""))
|
|
embedded_name = request.args.get("embedded", "")
|
|
if embedded_name:
|
|
png = moonraker.embedded_bytes(host, embedded_name)
|
|
if not png:
|
|
abort(404)
|
|
return png, 200, {"Content-Type": "image/png", "Cache-Control": "private, max-age=300"}
|
|
file_path = request.args.get("file", "").replace("\\", "/")
|
|
if ".." in file_path or ".thumbs/" not in file_path or not file_path.endswith(".png"):
|
|
abort(404)
|
|
try:
|
|
kind, data = moonraker.proxy_image(
|
|
host, "http://%s/server/files/gcodes/%s" % (host, quote(file_path, safe="/"))
|
|
)
|
|
except Exception:
|
|
abort(404)
|
|
return data, 200, {"Content-Type": kind, "Cache-Control": "private, max-age=300"}
|
|
|
|
@app.get("/api/v1/projekte")
|
|
def project_list():
|
|
return jsonify(store.list_projects(conn()))
|
|
|
|
@app.post("/api/v1/projekte")
|
|
def project_create():
|
|
data = body()
|
|
name = (data.get("name") or "").strip()
|
|
if not name:
|
|
raise GrottiError("Das Projekt braucht einen Namen.", "name")
|
|
status = data.get("status") or "idee"
|
|
if status not in PROJECT_STATUS:
|
|
raise GrottiError("Unbekannter Status.", "status")
|
|
return jsonify(store.create_project(conn(), name, (data.get("notes") or "").strip(), status)), 201
|
|
|
|
@app.get("/api/v1/projekte/<int:project_id>")
|
|
def project_get(project_id):
|
|
row = store.get_project(conn(), project_id)
|
|
if not row:
|
|
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
|
|
return jsonify(row)
|
|
|
|
@app.patch("/api/v1/projekte/<int:project_id>")
|
|
def project_patch(project_id):
|
|
data = body()
|
|
fields = {}
|
|
if "name" in data:
|
|
name = (data.get("name") or "").strip()
|
|
if not name:
|
|
raise GrottiError("Das Projekt braucht einen Namen.", "name")
|
|
fields["name"] = name
|
|
if "notes" in data:
|
|
fields["notes"] = data.get("notes") or ""
|
|
if "status" in data:
|
|
if data["status"] not in PROJECT_STATUS:
|
|
raise GrottiError("Unbekannter Status.", "status")
|
|
fields["status"] = data["status"]
|
|
row = store.update_project(conn(), project_id, fields)
|
|
if not row:
|
|
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
|
|
return jsonify(row)
|
|
|
|
@app.delete("/api/v1/projekte/<int:project_id>")
|
|
def project_delete(project_id):
|
|
if not store.delete_project(conn(), project_id):
|
|
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
|
|
return "", 204
|
|
|
|
@app.get("/api/v1/dateien")
|
|
def file_list():
|
|
library.sync_watch(conn(), cfg, force=False)
|
|
return jsonify(store.list_files(conn(), watch_dir=cfg.get("watch_dir")))
|
|
|
|
@app.post("/api/v1/dateien/neu-lesen")
|
|
def file_rescan():
|
|
result = library.sync_watch(conn(), cfg, force=True)
|
|
result["metadaten"] = library.refresh_file_meta(conn(), force=True)
|
|
return jsonify(result)
|
|
|
|
@app.get("/api/v1/dateien/ordner")
|
|
def file_folders():
|
|
return jsonify({"folders": library.list_folders(cfg)})
|
|
|
|
@app.post("/api/v1/dateien/ordner")
|
|
def file_folder_create():
|
|
data = body()
|
|
folder = library.create_folder(cfg, data.get("folder"))
|
|
return jsonify({"folder": folder, "folders": library.list_folders(cfg)}), 201
|
|
|
|
@app.delete("/api/v1/dateien/ordner")
|
|
def file_folder_delete():
|
|
data = body()
|
|
folder = library.delete_folder(cfg, data.get("folder"))
|
|
return jsonify({"folder": folder, "folders": library.list_folders(cfg)})
|
|
|
|
@app.post("/api/v1/dateien/<int:file_id>/verschieben")
|
|
def file_move(file_id):
|
|
data = body()
|
|
folder = data.get("folder")
|
|
if folder is None:
|
|
folder = ""
|
|
moved = library.move_file(conn(), cfg, file_id, folder)
|
|
return jsonify(moved)
|
|
|
|
@app.post("/api/v1/dateien")
|
|
def file_upload():
|
|
upload = request.files.get("file")
|
|
if not upload or not upload.filename:
|
|
raise GrottiError("Keine Datei angehängt.", "datei")
|
|
project_id = optional_id(request.form.get("project_id"))
|
|
saved = library.save_upload(conn(), upload.filename, upload.read(), cfg, project_id)
|
|
return jsonify(saved), 201
|
|
|
|
@app.get("/api/v1/dateien/<int:file_id>/vorschau")
|
|
def file_preview(file_id):
|
|
row = store.get_file_row(conn(), file_id)
|
|
if not row:
|
|
abort(404)
|
|
png = library.preview_png(row, conn=conn(), data_dir=cfg["data_dir"])
|
|
if not png:
|
|
abort(404)
|
|
return png, 200, {"Content-Type": "image/png", "Cache-Control": "private, max-age=300"}
|
|
|
|
@app.get("/api/v1/dateien/<int:file_id>/pfad")
|
|
def file_toolpath(file_id):
|
|
row = store.get_file_row(conn(), file_id)
|
|
if not row:
|
|
raise GrottiError("Die Datei fehlt.", "datei", 404)
|
|
path = library.resolve_stored_path(conn(), row, cfg["data_dir"])
|
|
if path is None or not path.is_file():
|
|
raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404)
|
|
cache_dir = Path(cfg["data_dir"]) / "toolpath"
|
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
stamp = "%s-%s-%s-v%s" % (
|
|
file_id,
|
|
int(row.get("mtime") or 0),
|
|
int(row.get("size_bytes") or 0),
|
|
TOOLPATH_VERSION,
|
|
)
|
|
cache_path = cache_dir / (stamp + ".json")
|
|
if cache_path.is_file():
|
|
try:
|
|
data = json.loads(cache_path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
data = None
|
|
else:
|
|
data = None
|
|
if not isinstance(data, dict) or data.get("version") != TOOLPATH_VERSION:
|
|
data = build_toolpath(path)
|
|
try:
|
|
cache_path.write_text(json.dumps(data), encoding="utf-8")
|
|
except OSError:
|
|
pass
|
|
data["file_id"] = file_id
|
|
data["name"] = row["name"]
|
|
data["version"] = TOOLPATH_VERSION
|
|
resp = jsonify(data)
|
|
resp.headers["Cache-Control"] = "no-store"
|
|
return resp
|
|
|
|
@app.patch("/api/v1/dateien/<int:file_id>")
|
|
def file_patch(file_id):
|
|
data = body()
|
|
fields = {}
|
|
if "project_id" in data:
|
|
fields["project_id"] = optional_id(data.get("project_id"))
|
|
if "spool_id" in data:
|
|
spool_id = optional_id(data.get("spool_id"))
|
|
if spool_id and not store.get_spool_row(conn(), spool_id):
|
|
raise GrottiError("Die Spule fehlt.", "spule", 404)
|
|
fields["spool_id"] = spool_id
|
|
row = store.update_file(conn(), file_id, fields)
|
|
if not row:
|
|
raise GrottiError("Die Datei fehlt.", "datei", 404)
|
|
return jsonify(row)
|
|
|
|
@app.delete("/api/v1/dateien/<int:file_id>")
|
|
def file_delete(file_id):
|
|
row = store.get_file_row(conn(), file_id)
|
|
if not row:
|
|
raise GrottiError("Die Datei fehlt.", "datei", 404)
|
|
if row["source"] != "upload":
|
|
raise GrottiError("Die Datei kommt aus dem Watch-Ordner und bleibt, solange sie dort liegt.", "watch")
|
|
store.delete_file(conn(), file_id)
|
|
Path(row["stored_path"]).unlink(missing_ok=True)
|
|
return "", 204
|
|
|
|
@app.post("/api/v1/senden")
|
|
def send():
|
|
data = body()
|
|
try:
|
|
file_id = int(data.get("file_id"))
|
|
except (TypeError, ValueError) as exc:
|
|
raise GrottiError("Die Datei fehlt.", "datei") from exc
|
|
hosts = data.get("hosts")
|
|
if isinstance(hosts, list):
|
|
hosts = [str(item or "").strip() for item in hosts if str(item or "").strip()]
|
|
else:
|
|
host = (data.get("host") or "").strip()
|
|
hosts = [host] if host else []
|
|
ace_maps = data.get("ace_maps") if isinstance(data.get("ace_maps"), dict) else {}
|
|
if not ace_maps and isinstance(data.get("ace_map"), list) and len(hosts) == 1:
|
|
ace_maps = {hosts[0]: data.get("ace_map")}
|
|
# when_free: in Druckwarteschlange statt sofort starten
|
|
if data.get("when_free"):
|
|
if not bool(data.get("confirm")):
|
|
raise GrottiError("Start braucht eine Bestätigung.", "bestaetigung")
|
|
file_row = store.get_file_row(conn(), file_id)
|
|
if not file_row:
|
|
raise GrottiError("Die Datei fehlt.", "datei", 404)
|
|
family = (data.get("family") or file_row.get("printer_family") or "").strip()
|
|
spool_id = optional_id(data.get("spool_id"))
|
|
bed = bool(data.get("bed_leveling"))
|
|
override = bool(data.get("override_machine"))
|
|
try:
|
|
priority = int(data.get("priority", 100))
|
|
except (TypeError, ValueError):
|
|
priority = 100
|
|
created = []
|
|
targets = hosts or [""]
|
|
for host in targets:
|
|
if host:
|
|
require_host(host)
|
|
# Nur die Map dieses Hosts — nie die eines anderen Druckers
|
|
# (bei „nächster freier“ host="" bleibt die Map leer bis zum Claim).
|
|
mapping = ace_maps.get(host) if host else []
|
|
if not isinstance(mapping, list):
|
|
mapping = []
|
|
if host:
|
|
jobs.assert_filament_enough(conn(), file_row, spool_id, mapping, host)
|
|
created.append(
|
|
store.enqueue_print(
|
|
conn(),
|
|
file_id,
|
|
host,
|
|
family if not host else "",
|
|
priority,
|
|
spool_id,
|
|
mapping,
|
|
bed,
|
|
override,
|
|
)
|
|
)
|
|
return jsonify({"jobs": created, "count": len(created), "mode": "when_free"}), 202
|
|
created = app.extensions["send_queue"].enqueue_many(
|
|
conn(),
|
|
file_id,
|
|
hosts,
|
|
bool(data.get("confirm")),
|
|
bool(data.get("override_machine")),
|
|
optional_id(data.get("spool_id")),
|
|
ace_maps,
|
|
bed_leveling=bool(data.get("bed_leveling")),
|
|
)
|
|
return jsonify({"jobs": created, "count": len(created), "mode": "now"}), 202
|
|
|
|
@app.get("/api/v1/sende-auftraege")
|
|
def send_list():
|
|
return jsonify(app.extensions["send_queue"].list_jobs())
|
|
|
|
@app.get("/api/v1/senden-zuletzt")
|
|
def send_last():
|
|
host = optional_host(request.args.get("host") or "")
|
|
try:
|
|
file_id = int(request.args.get("file_id") or 0)
|
|
except (TypeError, ValueError):
|
|
file_id = 0
|
|
if not host or not file_id:
|
|
raise GrottiError("Host und Datei nötig.", "senden")
|
|
row = store.latest_send_for_file(conn(), host, file_id)
|
|
if not row:
|
|
return jsonify(None)
|
|
return jsonify(row)
|
|
|
|
@app.get("/api/v1/senden/<job_id>")
|
|
def send_status(job_id):
|
|
job = app.extensions["send_queue"].get(job_id)
|
|
if not job:
|
|
raise GrottiError("Der Auftrag fehlt.", "auftrag", 404)
|
|
return jsonify(job)
|
|
|
|
def enqueue_ablauf_payload(data):
|
|
"""Ablauf (Drucken/Abkühlen/Schieben) in die Druckwarteschlange."""
|
|
if not bool(data.get("confirm")):
|
|
raise GrottiError("Start braucht eine Bestätigung.", "bestaetigung")
|
|
hosts = data.get("hosts")
|
|
if isinstance(hosts, list):
|
|
hosts = [str(item or "").strip() for item in hosts if str(item or "").strip()]
|
|
else:
|
|
host = (data.get("host") or "").strip()
|
|
hosts = [host] if host else []
|
|
if not hosts:
|
|
raise GrottiError("Ablauf braucht einen Kobra-3-Drucker.", "drucker")
|
|
for host in hosts:
|
|
require_host(host)
|
|
printer = store.printer_by_host(conn(), host)
|
|
eject.require_kobra_3(printer)
|
|
fallback = {
|
|
"file_id": data.get("file_id"),
|
|
"spool_id": data.get("spool_id"),
|
|
"ace_map": data.get("ace_map") if isinstance(data.get("ace_map"), list) else [],
|
|
"bed_leveling": bool(data.get("bed_leveling")),
|
|
"override_machine": bool(data.get("override_machine")),
|
|
}
|
|
ace_maps = data.get("ace_maps") if isinstance(data.get("ace_maps"), dict) else {}
|
|
steps = eject.normalize_steps(data.get("steps"), fallback)
|
|
for step in steps:
|
|
if step["type"] != "print":
|
|
continue
|
|
if not store.get_file_row(conn(), step["file_id"]):
|
|
raise GrottiError("Die Datei fehlt.", "datei", 404)
|
|
file_id = eject.first_print_file_id(steps)
|
|
try:
|
|
priority = int(data.get("priority", 100))
|
|
except (TypeError, ValueError):
|
|
priority = 100
|
|
try:
|
|
repeat_total = int(data.get("repeat_total", 1))
|
|
except (TypeError, ValueError):
|
|
repeat_total = 1
|
|
repeat_total = max(1, min(99, repeat_total))
|
|
created = []
|
|
for host in hosts:
|
|
mapping = ace_maps.get(host) if isinstance(ace_maps.get(host), list) else None
|
|
host_steps = []
|
|
for step in steps:
|
|
if step["type"] == "print" and mapping is not None:
|
|
copy = dict(step)
|
|
copy["ace_map"] = mapping
|
|
host_steps.append(copy)
|
|
else:
|
|
host_steps.append(step)
|
|
first = next(s for s in host_steps if s["type"] == "print")
|
|
file_row = store.get_file_row(conn(), first["file_id"])
|
|
jobs.assert_filament_enough(
|
|
conn(),
|
|
file_row,
|
|
first.get("spool_id"),
|
|
first.get("ace_map") or [],
|
|
host,
|
|
)
|
|
created.append(
|
|
store.enqueue_print(
|
|
conn(),
|
|
file_id,
|
|
host,
|
|
"kobra_3",
|
|
priority,
|
|
first.get("spool_id"),
|
|
first.get("ace_map") or [],
|
|
bool(first.get("bed_leveling")),
|
|
bool(first.get("override_machine")),
|
|
steps=host_steps,
|
|
repeat_total=repeat_total,
|
|
)
|
|
)
|
|
return {"jobs": created, "count": len(created), "mode": "ablauf"}
|
|
|
|
@app.get("/api/v1/druck-queue")
|
|
def print_queue_list():
|
|
try:
|
|
limit = max(1, min(200, int(request.args.get("limit") or 40)))
|
|
except (TypeError, ValueError):
|
|
limit = 40
|
|
return jsonify(store.list_print_queue(conn(), limit=limit))
|
|
|
|
@app.get("/api/v1/laeuft")
|
|
def running_now():
|
|
"""Was gerade druckt, auch ohne Auftrag in der Queue (Stand des Watchers)."""
|
|
items = []
|
|
for live in app.extensions["watcher"].latest or []:
|
|
state = (live.get("state") or "").lower()
|
|
if state not in ("printing", "paused", "heating"):
|
|
continue
|
|
items.append({
|
|
"host": live.get("host") or "",
|
|
"name": live.get("name") or "",
|
|
"state": state,
|
|
"progress": live.get("progress") or 0,
|
|
"remain": live.get("remain") or 0,
|
|
"filename": live.get("filename") or "",
|
|
})
|
|
return jsonify(items)
|
|
|
|
@app.get("/api/v1/aufgaben")
|
|
def open_tasks():
|
|
"""Platte belegt und kein Ablauf räumt sie: Hand muss ran."""
|
|
clears = store.bed_clear_map(conn())
|
|
busy = {
|
|
row["host"]
|
|
for row in conn().execute("SELECT DISTINCT host FROM print_queue WHERE status = 'running'").fetchall()
|
|
}
|
|
items = []
|
|
for live in app.extensions["watcher"].latest or []:
|
|
host = live.get("host") or ""
|
|
state = (live.get("state") or "").lower()
|
|
if not clears.get(host) or host in busy or state in ("printing", "paused", "heating"):
|
|
continue
|
|
items.append({
|
|
"host": host,
|
|
"name": live.get("name") or "",
|
|
"state": state,
|
|
"filename": live.get("filename") or "",
|
|
})
|
|
return jsonify(items)
|
|
|
|
@app.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)
|
|
store.set_bed_needs_clear(conn(), host, False, "quittiert")
|
|
return jsonify({"ok": True, "host": host, "needs_clear": False, "message": "Platte quittiert."})
|
|
|
|
@app.get("/api/v1/abraeumen-defaults")
|
|
def abraeumen_defaults():
|
|
return jsonify({
|
|
"cool": eject.DEFAULT_COOL,
|
|
"sweep": eject.DEFAULT_SWEEP,
|
|
"check": eject.DEFAULT_CHECK,
|
|
"limits": eject.LIMITS,
|
|
})
|
|
|
|
@app.patch("/api/v1/druck-queue/<int:item_id>")
|
|
def print_queue_patch(item_id):
|
|
data = body()
|
|
fields = {}
|
|
if "priority" in data:
|
|
try:
|
|
fields["priority"] = int(data.get("priority"))
|
|
except (TypeError, ValueError) as exc:
|
|
raise GrottiError("Priorität muss eine Zahl sein.", "zahl") from exc
|
|
if "status" in data:
|
|
status = data.get("status")
|
|
if status not in ("waiting", "cancelled", "error", "done", "running", "dismissed"):
|
|
raise GrottiError("Unbekannter Status.", "status")
|
|
current_row = store.get_print_queue_row(conn(), item_id)
|
|
if not current_row:
|
|
raise GrottiError("Der Queue-Eintrag fehlt.", "queue", 404)
|
|
# Von außen nur Abbrechen, Verwerfen oder Fortsetzen — kein freies done/running.
|
|
if status == "done":
|
|
raise GrottiError("Status „fertig“ setzt nur Grotti.", "status")
|
|
if status == "running" and not data.get("resume"):
|
|
raise GrottiError("Nur ein gestoppter Ablauf lässt sich fortsetzen.", "status")
|
|
if status == "waiting" and current_row.get("status") not in ("waiting",):
|
|
raise GrottiError("Status lässt sich so nicht setzen.", "status")
|
|
fields["status"] = status
|
|
if status == "running" and data.get("resume"):
|
|
row = current_row
|
|
if row.get("status") != "error" or not row.get("steps"):
|
|
raise GrottiError("Nur ein gestoppter Ablauf lässt sich fortsetzen.", "status")
|
|
fields["phase"] = ""
|
|
fields["phase_started_at"] = None
|
|
fields["message"] = "Wird fortgesetzt…"
|
|
store.add_event(
|
|
conn(), row.get("host") or "", row.get("printer_name") or "", "ablauf",
|
|
"%s Ablauf von Hand fortgesetzt bei Schritt %d (%s)" % (
|
|
row.get("printer_name") or "",
|
|
int(row.get("step_index") or 0) + 1,
|
|
store.print_queue_out(row).get("step_label_current") or "",
|
|
),
|
|
row.get("filename") or "",
|
|
)
|
|
if status == "cancelled":
|
|
fields["message"] = "Abgebrochen."
|
|
fields["phase"] = ""
|
|
fields["phase_started_at"] = None
|
|
row = store.get_print_queue_row(conn(), item_id)
|
|
if row:
|
|
was_running = row.get("status") == "running"
|
|
what = "Ablauf" if row.get("steps") else "Auftrag"
|
|
stopped = ""
|
|
# laufenden Druck auf dem Host best-effort stoppen
|
|
if was_running and row.get("host"):
|
|
try:
|
|
moonraker.command(row["host"], "abbrechen")
|
|
stopped = ", Druck gestoppt"
|
|
except Exception:
|
|
stopped = ", Druck nicht gestoppt"
|
|
store.add_event(
|
|
conn(),
|
|
row.get("host") or "",
|
|
row.get("printer_name") or "",
|
|
"ablauf",
|
|
"%s %s von Hand %s%s: %s" % (
|
|
row.get("printer_name") or "Warteschlange",
|
|
what,
|
|
"abgebrochen" if was_running else "entfernt",
|
|
stopped,
|
|
row.get("filename") or "",
|
|
),
|
|
row.get("filename") or "",
|
|
)
|
|
if "message" in data:
|
|
fields["message"] = str(data.get("message") or "")
|
|
row = store.update_print_queue(conn(), item_id, fields)
|
|
if not row:
|
|
raise GrottiError("Der Queue-Eintrag fehlt.", "queue", 404)
|
|
return jsonify(row)
|
|
|
|
@app.get("/api/v1/ereignisse")
|
|
def events_list():
|
|
unread = request.args.get("unread") == "1"
|
|
try:
|
|
limit = max(1, min(1000, int(request.args.get("limit") or 40)))
|
|
except (TypeError, ValueError):
|
|
limit = 40
|
|
kinds = [k for k in (request.args.get("kind") or "").split(",") if k.strip()]
|
|
since = None
|
|
if request.args.get("since"):
|
|
try:
|
|
since = datetime.fromisoformat(request.args["since"].replace("Z", "+00:00"))
|
|
except ValueError as exc:
|
|
raise GrottiError("since muss ein ISO-Zeitpunkt sein.", "since") from exc
|
|
return jsonify({
|
|
"items": store.list_events(
|
|
conn(),
|
|
limit=limit,
|
|
unread_only=unread,
|
|
host=(request.args.get("host") or "").strip() or None,
|
|
kinds=kinds or None,
|
|
since=since,
|
|
),
|
|
"unread": store.unread_event_count(conn()),
|
|
})
|
|
|
|
@app.post("/api/v1/ereignisse/gelesen")
|
|
def events_read():
|
|
data = body() if request.data else {}
|
|
ids = data.get("ids") if isinstance(data.get("ids"), list) else None
|
|
if ids is not None:
|
|
try:
|
|
ids = [int(item) for item in ids]
|
|
except (TypeError, ValueError) as exc:
|
|
raise GrottiError("IDs müssen Zahlen sein.", "id") from exc
|
|
unread = store.mark_events_read(conn(), ids)
|
|
return jsonify({"unread": unread})
|
|
|
|
@app.post("/api/v1/dateien/von-drucker")
|
|
def file_from_printer():
|
|
data = body()
|
|
host = require_host(data.get("host") or "")
|
|
filename = (data.get("filename") or "").strip()
|
|
saved = library.ensure_library_gcode(conn(), cfg, host, filename)
|
|
if not saved:
|
|
raise GrottiError("Datei konnte nicht vom Drucker geladen werden.", "datei")
|
|
return jsonify(saved), 201
|
|
|
|
@app.get("/api/v1/materialien")
|
|
def material_list():
|
|
return jsonify(store.list_materials(conn()))
|
|
|
|
@app.post("/api/v1/materialien")
|
|
def material_create():
|
|
data = body()
|
|
name = (data.get("name") or "").strip()
|
|
if not name:
|
|
raise GrottiError("Die Sorte braucht einen Namen.", "name")
|
|
machines = data.get("machines") or "beide"
|
|
if machines not in ("beide", "kobra_3", "kobra_s1"):
|
|
raise GrottiError("Unbekannte Maschinenwahl.", "machines")
|
|
try:
|
|
row = store.create_material(
|
|
conn(),
|
|
{
|
|
"name": name,
|
|
"brand": (data.get("brand") or "").strip(),
|
|
"material_type": (data.get("material_type") or "PLA").strip() or "PLA",
|
|
"density": float(data.get("density") or 1.24),
|
|
"volumetric": float(data.get("volumetric") or 12),
|
|
"volumetric_s1": (
|
|
float(data["volumetric_s1"])
|
|
if data.get("volumetric_s1") not in (None, "")
|
|
else None
|
|
),
|
|
"machines": machines,
|
|
"price_per_kg": float(data.get("price_per_kg") or 0),
|
|
"note": (data.get("note") or "").strip(),
|
|
},
|
|
)
|
|
except Exception as exc:
|
|
if "unique" in str(exc).lower() or "duplicate" in str(exc).lower():
|
|
raise GrottiError("Eine Sorte mit diesem Namen gibt es schon.", "name") from exc
|
|
raise
|
|
return jsonify(row), 201
|
|
|
|
@app.patch("/api/v1/materialien/<int:material_id>")
|
|
def material_patch(material_id):
|
|
data = body()
|
|
fields = {}
|
|
if "name" in data:
|
|
name = (data.get("name") or "").strip()
|
|
if not name:
|
|
raise GrottiError("Die Sorte braucht einen Namen.", "name")
|
|
fields["name"] = name
|
|
if "brand" in data:
|
|
fields["brand"] = (data.get("brand") or "").strip()
|
|
if "material_type" in data:
|
|
fields["material_type"] = (data.get("material_type") or "PLA").strip() or "PLA"
|
|
if "density" in data:
|
|
fields["density"] = float(data.get("density") or 1.24)
|
|
if "volumetric" in data:
|
|
fields["volumetric"] = float(data.get("volumetric") or 12)
|
|
if "volumetric_s1" in data:
|
|
raw = data.get("volumetric_s1")
|
|
fields["volumetric_s1"] = float(raw) if raw not in (None, "") else None
|
|
if "machines" in data:
|
|
machines = data.get("machines") or "beide"
|
|
if machines not in ("beide", "kobra_3", "kobra_s1"):
|
|
raise GrottiError("Unbekannte Maschinenwahl.", "machines")
|
|
fields["machines"] = machines
|
|
if "price_per_kg" in data:
|
|
fields["price_per_kg"] = float(data.get("price_per_kg") or 0)
|
|
if "note" in data:
|
|
fields["note"] = (data.get("note") or "").strip()
|
|
try:
|
|
row = store.update_material(conn(), material_id, fields)
|
|
except Exception as exc:
|
|
if "unique" in str(exc).lower() or "duplicate" in str(exc).lower():
|
|
raise GrottiError("Eine Sorte mit diesem Namen gibt es schon.", "name") from exc
|
|
raise
|
|
if not row:
|
|
raise GrottiError("Die Sorte fehlt.", "material", 404)
|
|
return jsonify(row)
|
|
|
|
@app.delete("/api/v1/materialien/<int:material_id>")
|
|
def material_delete(material_id):
|
|
result = store.delete_material(conn(), material_id)
|
|
if result == "in_use":
|
|
raise GrottiError("Sorte ist noch an Spulen gebunden.", "material")
|
|
if not result:
|
|
raise GrottiError("Die Sorte fehlt.", "material", 404)
|
|
return "", 204
|
|
|
|
@app.get("/api/v1/farben")
|
|
def color_list_api():
|
|
return jsonify(color_catalog())
|
|
|
|
@app.get("/api/v1/ideen")
|
|
def idea_list():
|
|
return jsonify(store.list_ideas(conn()))
|
|
|
|
@app.post("/api/v1/ideen")
|
|
def idea_create():
|
|
data = body()
|
|
title = (data.get("title") or "").strip()
|
|
if not title:
|
|
raise GrottiError("Die Idee braucht einen Titel.", "titel")
|
|
status = data.get("status") or "offen"
|
|
if status not in IDEA_STATUS:
|
|
raise GrottiError("Unbekannter Status.", "status")
|
|
return jsonify(store.create_idea(conn(), title, (data.get("body") or "").strip(), status)), 201
|
|
|
|
@app.patch("/api/v1/ideen/<int:idea_id>")
|
|
def idea_patch(idea_id):
|
|
data = body()
|
|
fields = {}
|
|
if "title" in data:
|
|
title = (data.get("title") or "").strip()
|
|
if not title:
|
|
raise GrottiError("Die Idee braucht einen Titel.", "titel")
|
|
fields["title"] = title
|
|
if "body" in data:
|
|
fields["body"] = (data.get("body") or "").strip()
|
|
if "status" in data:
|
|
if data.get("status") not in IDEA_STATUS:
|
|
raise GrottiError("Unbekannter Status.", "status")
|
|
fields["status"] = data.get("status")
|
|
row = store.update_idea(conn(), idea_id, fields)
|
|
if not row:
|
|
raise GrottiError("Die Idee fehlt.", "idee", 404)
|
|
return jsonify(row)
|
|
|
|
@app.delete("/api/v1/ideen/<int:idea_id>")
|
|
def idea_delete(idea_id):
|
|
if not store.delete_idea(conn(), idea_id):
|
|
raise GrottiError("Die Idee fehlt.", "idee", 404)
|
|
return "", 204
|
|
|
|
@app.get("/api/v1/spulen")
|
|
def spool_list():
|
|
return jsonify(store.list_spools(conn()))
|
|
|
|
@app.get("/api/v1/spulen/by-code/<path:code>")
|
|
def spool_by_code(code):
|
|
text = (code or "").strip()
|
|
if text.upper().startswith("D-"):
|
|
run = store.get_run_by_chargen(conn(), text)
|
|
if not run:
|
|
raise GrottiError("Kein Druck mit dieser Charge.", "charge", 404)
|
|
return jsonify({"kind": "lauf", "run": store.run_out(run), "redirect": "/protokoll/%s" % run["id"]})
|
|
row = store.get_spool_by_code(conn(), text)
|
|
if not row:
|
|
raise GrottiError("Keine Spule mit diesem Code.", "spule", 404)
|
|
return jsonify(row)
|
|
|
|
@app.get("/api/v1/spulen/<int:spool_id>/akte")
|
|
def spool_akte(spool_id):
|
|
spool = store.get_spool_row(conn(), spool_id)
|
|
if not spool:
|
|
raise GrottiError("Die Spule fehlt.", "spule", 404)
|
|
return jsonify({
|
|
"spool": store.spool_out(spool),
|
|
"runs": store.list_spool_runs(conn(), spool_id),
|
|
"stats": store.spool_usage_stats(conn(), spool_id),
|
|
})
|
|
|
|
@app.post("/api/v1/spulen")
|
|
def spool_create():
|
|
return jsonify(store.create_spool(conn(), spool_fields(body()))), 201
|
|
|
|
@app.patch("/api/v1/spulen/<int:spool_id>")
|
|
def spool_patch(spool_id):
|
|
row = store.update_spool(conn(), spool_id, spool_fields(body(), partial=True, spool_id=spool_id))
|
|
if not row:
|
|
raise GrottiError("Die Spule fehlt.", "spule", 404)
|
|
return jsonify(row)
|
|
|
|
@app.post("/api/v1/spulen/<int:spool_id>/einlegen")
|
|
def spool_load(spool_id):
|
|
"""Spule an Drucker/Platz legen; vorherige Rolle dort geht ins Regal."""
|
|
data = body()
|
|
host = require_host((data.get("host") or "").strip())
|
|
slot = ace_slot(data.get("ace_slot"))
|
|
spool = store.get_spool_row(conn(), spool_id)
|
|
if not spool:
|
|
raise GrottiError("Die Spule fehlt.", "spule", 404)
|
|
live_run = store.find_live_run(conn(), host)
|
|
mm_now = float((live_run or {}).get("filament_used_mm") or 0)
|
|
printing = bool(
|
|
live_run and (live_run.get("state") or "") in ("in_progress", "printing", "paused")
|
|
)
|
|
if printing:
|
|
try:
|
|
snap = moonraker.query(host)
|
|
if isinstance(snap.get("filament_used"), (int, float)):
|
|
mm_now = float(snap["filament_used"])
|
|
except Exception:
|
|
pass
|
|
# Alte Rolle am Platz / externe Rolle am Host freimachen
|
|
if slot:
|
|
old = conn().execute(
|
|
"SELECT * FROM spools WHERE printer_host = %s AND ace_slot = %s AND id <> %s",
|
|
(host, slot, spool_id),
|
|
).fetchall()
|
|
else:
|
|
old = conn().execute(
|
|
"SELECT * FROM spools WHERE printer_host = %s AND ace_slot IS NULL AND id <> %s",
|
|
(host, spool_id),
|
|
).fetchall()
|
|
printer = store.printer_by_host(conn(), host) or {}
|
|
if printing:
|
|
closed = store.close_open_segments(conn(), live_run["id"], slot, mm_now)
|
|
if not closed and old:
|
|
# Erster Wechsel: alter Abschnitt von 0 bis jetzt
|
|
for row in old:
|
|
store.open_segment(conn(), live_run["id"], row["id"], slot, 0)
|
|
store.close_open_segments(conn(), live_run["id"], slot, mm_now)
|
|
for row in old:
|
|
store.update_spool(conn(), row["id"], {"printer_host": "", "ace_slot": None})
|
|
store.add_event(
|
|
conn(),
|
|
host,
|
|
printer.get("name") or host,
|
|
"spule",
|
|
"%s: „%s“ ins Regal%s" % (
|
|
printer.get("name") or host,
|
|
row["name"],
|
|
(" (Platz %s)" % slot) if slot else "",
|
|
),
|
|
(live_run or {}).get("filename") or "",
|
|
run_id=(live_run or {}).get("id"),
|
|
)
|
|
updated = store.update_spool(conn(), spool_id, {"printer_host": host, "ace_slot": slot})
|
|
if printing:
|
|
store.open_segment(conn(), live_run["id"], spool_id, slot, mm_now)
|
|
store.add_event(
|
|
conn(),
|
|
host,
|
|
printer.get("name") or host,
|
|
"spule",
|
|
"%s: „%s“ eingelegt%s" % (
|
|
printer.get("name") or host,
|
|
updated["name"],
|
|
(" auf Platz %s" % slot) if slot else "",
|
|
),
|
|
(live_run or {}).get("filename") or "",
|
|
run_id=(live_run or {}).get("id"),
|
|
)
|
|
return jsonify(updated)
|
|
|
|
@app.delete("/api/v1/spulen/<int:spool_id>")
|
|
def spool_delete(spool_id):
|
|
if not store.delete_spool(conn(), spool_id):
|
|
raise GrottiError("Die Spule fehlt.", "spule", 404)
|
|
return "", 204
|
|
|
|
def spool_fields(data, partial=False, spool_id=None):
|
|
fields = {}
|
|
if not partial or "name" in data:
|
|
name = (data.get("name") or "").strip()
|
|
if not name:
|
|
raise GrottiError("Die Spule braucht einen Namen.", "name")
|
|
fields["name"] = name
|
|
text_keys = ("brand", "material_type", "hersteller_charge")
|
|
for key in text_keys:
|
|
if not partial or key in data:
|
|
fields[key] = (data.get(key) or "").strip()
|
|
if not partial or "colors" in data or "color" in data:
|
|
fields["colors"] = color_list(data.get("colors"))
|
|
label = (data.get("color") or "").strip()
|
|
if not label:
|
|
label = " / ".join(item["name"] for item in fields["colors"])
|
|
fields["color"] = label
|
|
for key, default in (("price_per_kg", 0), ("density", 1.24), ("volumetric", 12), ("remaining_g", 1000)):
|
|
if not partial or key in data:
|
|
try:
|
|
fields[key] = float(data.get(key, default) or 0)
|
|
except (TypeError, ValueError) as exc:
|
|
raise GrottiError("%s muss eine Zahl sein." % key, "zahl") from exc
|
|
if not partial or "printer_host" in data:
|
|
fields["printer_host"] = optional_host(data.get("printer_host") or "")
|
|
if not partial or "ace_slot" in data:
|
|
fields["ace_slot"] = ace_slot(data.get("ace_slot"))
|
|
if not partial or "material_id" in data:
|
|
fields["material_id"] = optional_id(data.get("material_id"))
|
|
# Doppelte Plätze verhindern
|
|
host = fields.get("printer_host")
|
|
slot = fields.get("ace_slot")
|
|
if host is None or slot is None:
|
|
current = store.get_spool_row(conn(), spool_id) if spool_id else None
|
|
if host is None and current:
|
|
host = current.get("printer_host") or ""
|
|
if slot is None and current:
|
|
slot = current.get("ace_slot")
|
|
if host and slot:
|
|
taken = conn().execute(
|
|
"""
|
|
SELECT id, name FROM spools
|
|
WHERE printer_host = %s AND ace_slot = %s
|
|
AND (%s::bigint IS NULL OR id <> %s)
|
|
LIMIT 1
|
|
""",
|
|
(host, slot, spool_id, spool_id),
|
|
).fetchone()
|
|
if taken:
|
|
raise GrottiError(
|
|
"Platz %s auf diesem Drucker ist schon von „%s“ belegt."
|
|
% (slot, taken["name"]),
|
|
"ace",
|
|
409,
|
|
)
|
|
return fields
|
|
|
|
def color_list(raw):
|
|
if raw in (None, ""):
|
|
return []
|
|
if not isinstance(raw, list):
|
|
raise GrottiError("Farben sind eine Liste.", "farbe")
|
|
if len(raw) > 8:
|
|
raise GrottiError("Eine Spule hat höchstens acht Farben.", "farbe")
|
|
colors = []
|
|
for item in raw:
|
|
if isinstance(item, dict):
|
|
name = (item.get("name") or "").strip()
|
|
hex_value = (item.get("hex") or "").strip()
|
|
else:
|
|
raise GrottiError("Eine Farbe stimmt nicht.", "farbe")
|
|
if not re.fullmatch(r"#[0-9A-Fa-f]{6}", hex_value):
|
|
raise GrottiError("Eine Farbe braucht einen Hex-Wert.", "farbe")
|
|
colors.append({"name": name or hex_value, "hex": hex_value.lower()})
|
|
return colors
|
|
|
|
def ace_slot(raw):
|
|
if raw in (None, "", 0, "0"):
|
|
return None
|
|
try:
|
|
slot = int(raw)
|
|
except (TypeError, ValueError) as exc:
|
|
raise GrottiError("Der ACE-Platz ist eine Zahl.", "ace") from exc
|
|
if slot < 1 or slot > 8:
|
|
raise GrottiError("Ein ACE hat vier Plätze, ein zweites ACE die Plätze 5 bis 8.", "ace")
|
|
return slot
|
|
|
|
@app.post("/api/v1/kalkulation")
|
|
def quote():
|
|
data = body()
|
|
spool = None
|
|
spool_id = optional_id(data.get("spool_id"))
|
|
if spool_id:
|
|
spool = store.get_spool_row(conn(), spool_id)
|
|
if not spool:
|
|
raise GrottiError("Die Spule fehlt.", "spule", 404)
|
|
material = {
|
|
"price_per_kg": spool["price_per_kg"] if spool else float(data.get("price_per_kg") or 0),
|
|
"density": spool["density"] if spool else float(data.get("density") or 1.24),
|
|
"volumetric": spool["volumetric"] if spool else float(data.get("volumetric") or 12),
|
|
}
|
|
job = {
|
|
"weight_g": float(data.get("weight_g") or 0),
|
|
"qty": float(data.get("qty") or 1),
|
|
"waste_percent": float(data.get("waste_percent", cfg["energy"]["waste_percent"]) or 0),
|
|
"utilization": float(data.get("utilization", cfg["energy"]["utilization"]) or 1),
|
|
"watts": float(data.get("watts", cfg["energy"]["watts"]) or 0),
|
|
"euro_per_kwh": float(data.get("euro_per_kwh", cfg["energy"]["euro_per_kwh"]) or 0),
|
|
"dryer_watts": float(data.get("dryer_watts", cfg["energy"]["dryer_watts"]) or 0),
|
|
"dry_hours": float(data.get("dry_hours", cfg["energy"]["dry_hours"]) or 0),
|
|
"price_mode": data.get("price_mode") or "perPart",
|
|
"price": float(data.get("price") or 0),
|
|
}
|
|
result = calculate(job, material)
|
|
payload = {"inputs": job, "material": material, "result": result}
|
|
project_id = optional_id(data.get("project_id"))
|
|
if project_id:
|
|
saved = store.update_project(conn(), project_id, {"quote": payload})
|
|
if not saved:
|
|
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
|
|
return jsonify(payload)
|
|
|
|
@app.get("/api/v1/laeufe")
|
|
def run_list():
|
|
return jsonify(store.list_runs(conn()))
|
|
|
|
@app.get("/api/v1/laeufe/aktuell")
|
|
def run_aktuell():
|
|
"""Aktueller oder letzter Temperatur-/ED-Verlauf fürs Cockpit (host=)."""
|
|
host = require_host(request.args.get("host", ""))
|
|
run = store.latest_run_for_host(conn(), host)
|
|
if not run:
|
|
return jsonify({"run": None, "samples": [], "energy": None})
|
|
run_id = run["id"]
|
|
samples = store.list_run_samples(conn(), run_id)
|
|
from grotti.cost import energy_estimate
|
|
|
|
energy = energy_estimate(run.get("duration_s"), cfg["energy"], samples)
|
|
if len(samples) > 180:
|
|
step = max(1, len(samples) // 120)
|
|
samples = samples[::step]
|
|
return jsonify({
|
|
"run": run,
|
|
"samples": samples,
|
|
"energy": energy,
|
|
"sample_count": int(run.get("_sample_count") or len(samples)),
|
|
})
|
|
|
|
@app.get("/api/v1/laeufe/<int:run_id>")
|
|
def run_get(run_id):
|
|
row = store.get_run_row(conn(), run_id)
|
|
if not row:
|
|
raise GrottiError("Der Lauf fehlt.", "lauf", 404)
|
|
run = store.run_out(row)
|
|
if not run.get("chargen_nr"):
|
|
store.ensure_run_chargen(conn(), run_id)
|
|
row = store.get_run_row(conn(), run_id) or row
|
|
run = store.run_out(row)
|
|
if run.get("chargen_nr") and run.get("objects"):
|
|
store.ensure_run_labels(conn(), run_id, run.get("objects") or [], qty=1)
|
|
file_row = store.get_file_row(conn(), run["file_id"]) if run.get("file_id") else None
|
|
project = store.get_project(conn(), run["project_id"]) if run.get("project_id") else None
|
|
samples = store.list_run_samples(conn(), run_id)
|
|
from grotti.cost import energy_estimate
|
|
|
|
energy = energy_estimate(run.get("duration_s"), cfg["energy"], samples)
|
|
# Anzeige auf ca. 1 Punkt / Minute verdichten
|
|
if len(samples) > 180:
|
|
step = max(1, len(samples) // 120)
|
|
samples = samples[::step]
|
|
return jsonify({
|
|
"run": run,
|
|
"spools": store.list_run_spools(conn(), run_id),
|
|
"segments": store.list_run_segments(conn(), run_id),
|
|
"samples": samples,
|
|
"labels": store.list_run_labels(conn(), run_id),
|
|
"events": store.list_run_events(conn(), run_id),
|
|
"file": store.file_out(file_row) if file_row else None,
|
|
"project": project,
|
|
"energy": energy,
|
|
})
|
|
|
|
@app.post("/api/v1/laeufe/abgleich")
|
|
def run_sync():
|
|
library.sync_watch(conn(), cfg, force=False)
|
|
return jsonify(jobs.sync_history(conn(), cfg))
|
|
|
|
@app.patch("/api/v1/laeufe/<int:run_id>")
|
|
def run_patch(run_id):
|
|
data = body()
|
|
row = None
|
|
if "spool_id" in data and data.get("spool_id"):
|
|
row = jobs.assign_and_deduct(conn(), cfg, run_id, int(data["spool_id"]))
|
|
if "verdict" in data:
|
|
if data["verdict"] not in VERDICTS:
|
|
raise GrottiError("Urteil ist gut oder Ausschuss.", "urteil")
|
|
row = store.set_verdict(conn(), run_id, data["verdict"])
|
|
if row is None:
|
|
current = store.get_run_row(conn(), run_id)
|
|
if not current:
|
|
raise GrottiError("Der Lauf fehlt.", "lauf", 404)
|
|
row = store.run_out(current)
|
|
return jsonify(row)
|
|
|
|
@app.get("/api/v1/statistik")
|
|
def statistik_api():
|
|
since = request.args.get("since") or None
|
|
until = request.args.get("until") or None
|
|
host = (request.args.get("host") or "").strip()
|
|
material = (request.args.get("material") or "").strip()
|
|
brand = (request.args.get("brand") or "").strip()
|
|
project_id = optional_id(request.args.get("project_id"))
|
|
return jsonify(store.statistics(
|
|
conn(),
|
|
since=since,
|
|
until=until,
|
|
host=host,
|
|
material=material or "",
|
|
brand=brand or "",
|
|
project_id=project_id,
|
|
))
|
|
|
|
@app.get("/api/v1/statistik.csv")
|
|
def statistik_csv():
|
|
import csv
|
|
import io
|
|
from flask import Response
|
|
|
|
since = request.args.get("since") or None
|
|
until = request.args.get("until") or None
|
|
host = (request.args.get("host") or "").strip()
|
|
material = (request.args.get("material") or "").strip()
|
|
brand = (request.args.get("brand") or "").strip()
|
|
runs = store.list_runs(
|
|
conn(),
|
|
limit=2000,
|
|
since=since,
|
|
until=until,
|
|
host=host,
|
|
material=material,
|
|
brand=brand,
|
|
)
|
|
buf = io.StringIO()
|
|
writer = csv.writer(buf, delimiter=";")
|
|
writer.writerow([
|
|
"id", "drucker", "datei", "zustand", "start", "ende", "dauer_s",
|
|
"soll_g", "ist_g", "quelle", "filament_eur", "strom_eur", "urteil", "charge",
|
|
])
|
|
for run in runs:
|
|
writer.writerow([
|
|
run["id"],
|
|
run.get("printer_name") or "",
|
|
run.get("filename") or "",
|
|
run.get("state") or "",
|
|
run.get("started_at") or "",
|
|
run.get("ended_at") or "",
|
|
run.get("duration_s") or 0,
|
|
run.get("weight_planned_g") or 0,
|
|
run.get("weight_used_g") or 0,
|
|
run.get("weight_source") or "",
|
|
run.get("filament_cost") or 0,
|
|
run.get("energy_cost") or 0,
|
|
run.get("verdict") or "",
|
|
run.get("chargen_nr") or "",
|
|
])
|
|
return Response(
|
|
buf.getvalue(),
|
|
mimetype="text/csv; charset=utf-8",
|
|
headers={"Content-Disposition": "attachment; filename=grotti-laeufe.csv"},
|
|
)
|
|
|
|
return app
|