Grotti 2.2.0: Auth, API-Tokens, MCP und Doku-Trennung.
This commit is contained in:
1 parent
b20f8a7779
commit
5a3716b26a
65 files changed
+2212
-198
No files matched your search
+426
-36
@@ -3,6 +3,7 @@
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -10,10 +11,11 @@ from urllib.parse import quote
|
||||
|
||||
import markdown
|
||||
import psycopg
|
||||
from flask import Flask, abort, g, jsonify, render_template, request
|
||||
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
|
||||
@@ -29,7 +31,6 @@ from grotti.watcher import StateWatcher
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
TOOL_ROOT = ROOT.parent
|
||||
DOCS_DIR = TOOL_ROOT / "docs"
|
||||
CHANGELOG = TOOL_ROOT / "CHANGELOG.md"
|
||||
PROJECT_STATUS = {"idee", "bereit", "druckt", "fertig"}
|
||||
VERDICTS = {"", "gut", "ausschuss"}
|
||||
IDEA_STATUS = {"offen", "spaeter", "erledigt"}
|
||||
@@ -47,8 +48,13 @@ def create_app(cfg):
|
||||
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)
|
||||
@@ -58,19 +64,167 @@ def create_app(cfg):
|
||||
watcher.start()
|
||||
dispatcher.start()
|
||||
|
||||
@app.before_request
|
||||
def check_token():
|
||||
token = cfg["token"]
|
||||
if not token or not request.path.startswith("/api/"):
|
||||
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:]
|
||||
elif request.method == "GET":
|
||||
presented = request.args.get("token", "")
|
||||
if not presented or not secrets.compare_digest(presented, token):
|
||||
return jsonify({"error": "Token fehlt oder stimmt nicht.", "code": "token"}), 401
|
||||
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
|
||||
return redirect(url_for("login_page", 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
|
||||
@@ -88,16 +242,11 @@ def create_app(cfg):
|
||||
|
||||
@app.errorhandler(GrottiError)
|
||||
def grotti_error(err):
|
||||
conn = g.get("db")
|
||||
if conn:
|
||||
conn.rollback()
|
||||
db = g.get("db")
|
||||
if db:
|
||||
db.rollback()
|
||||
return jsonify({"error": err.message, "code": err.code}), err.status
|
||||
|
||||
def conn():
|
||||
if "db" not in g:
|
||||
g.db = psycopg.connect(cfg["database_url"], row_factory=dict_row)
|
||||
return g.db
|
||||
|
||||
def body():
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
@@ -124,16 +273,250 @@ def create_app(cfg):
|
||||
return host
|
||||
|
||||
@app.context_processor
|
||||
def inject_version():
|
||||
return {"version": __version__}
|
||||
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=cfg["token"])
|
||||
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("*.md"))
|
||||
return sorted(p.stem for p in DOCS_DIR.glob("Anleitung-*.md"))
|
||||
|
||||
ANLEITUNG_ORDER = (
|
||||
"Anleitung-Einrichtung",
|
||||
"Anleitung-Oberflaeche",
|
||||
"Anleitung-Wand",
|
||||
"Anleitung-Dateien",
|
||||
"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-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):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
@@ -180,7 +563,7 @@ def create_app(cfg):
|
||||
"etikett.html",
|
||||
page="etikett",
|
||||
title="Etikett %s" % spool["chargen_nr"],
|
||||
token=cfg["token"],
|
||||
token="",
|
||||
spool=spool,
|
||||
qr_url=qr_url,
|
||||
)
|
||||
@@ -203,37 +586,42 @@ def create_app(cfg):
|
||||
|
||||
@app.get("/doku")
|
||||
def doku():
|
||||
names = doc_names()
|
||||
anleitung = anleitung_list(names)
|
||||
return render_template(
|
||||
"doku.html",
|
||||
page="doku",
|
||||
title="Doku",
|
||||
token=cfg["token"],
|
||||
docs=doc_names(),
|
||||
token="",
|
||||
docs=names,
|
||||
anleitung=anleitung,
|
||||
anleitung_title=anleitung_title,
|
||||
)
|
||||
|
||||
@app.get("/doku/<name>")
|
||||
def doku_page(name):
|
||||
if name == "changelog":
|
||||
path, title = CHANGELOG, "Changelog"
|
||||
elif name in doc_names():
|
||||
path, title = DOCS_DIR / ("%s.md" % name), name.replace("-", " ")
|
||||
else:
|
||||
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=title,
|
||||
token=cfg["token"],
|
||||
title=anleitung_title(name),
|
||||
token="",
|
||||
content=render_markdown(path),
|
||||
docs=doc_names(),
|
||||
docs=names,
|
||||
anleitung=anleitung,
|
||||
anleitung_title=anleitung_title,
|
||||
current=name,
|
||||
)
|
||||
|
||||
@app.get("/api/v1/version")
|
||||
def version_info():
|
||||
return jsonify({"version": __version__, "changelog": "/doku/changelog"})
|
||||
return jsonify({"version": __version__})
|
||||
|
||||
@app.get("/api/v1/einstellungen")
|
||||
def settings():
|
||||
@@ -262,7 +650,7 @@ def create_app(cfg):
|
||||
return jsonify({"ok": True})
|
||||
|
||||
@app.get("/api/v1/drucker")
|
||||
@app.get("/api/drucker")
|
||||
@app.get("/api/drucker") # veraltet — Alias zu /api/v1/drucker
|
||||
def printers():
|
||||
rows = store.list_printers(conn())
|
||||
clears = store.bed_clear_map(conn())
|
||||
@@ -767,6 +1155,8 @@ def create_app(cfg):
|
||||
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:
|
||||
|
||||
Reference in new issue
Block a user