Grotti 3.0 Phase 1: Migrationen, Pool, Queue- und Abzug-Fixes.
Nummerierte SQL-Migrationen, psycopg-Pool, waitress, hängende Queue-Jobs, sichere History-Abzüge, Chargen-Locks, Upload-/Host-Validierung, ProxyFix.
This commit is contained in:
1 parent
9e22cebb81
commit
0236278929
19 files changed
+986
-81
No files matched your search
@@ -4,6 +4,26 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- Datenbank: nummerierte Migrationen unter `grotti/migrations/`, Connection-Pool, Indizes für Sends/Läufe/Dateien/Events, Retention für `run_samples` (90 Tage).
|
||||||
|
- Server: waitress statt Flask-Dev-Server; Hintergrund-Threads starten erst in `server.main()`.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- Spulenabzug aus Moonraker-History: keine Rate mehr ohne Send-Eintrag; Spulen, die nach Druckende angelegt wurden, werden ignoriert.
|
||||||
|
- Chargen-Vergabe (`S-…` / `D-…`) mit Advisory-Lock; Druck-Charge nach lokaler Tagesgrenze.
|
||||||
|
- MCP-Steuerung: Parameter `temp`/`percent` (Alias `value` bleibt in Moonraker).
|
||||||
|
- Upload nur `.gcode`/`.3mf`/`.gcode.3mf`; Drucker-Host validiert; `?token=` nur für Kamera/Vorschau; ProxyFix; Settings-Lock.
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- Hängende Queue-Jobs (`phase=starting` ohne Worker) nach Neustart werden nach 45 s auf Fehler gesetzt.
|
||||||
|
- Queue-Worker rollen die Transaktion bei Fehlern zurück; Exceptions werden geloggt.
|
||||||
|
- `snapshot()` fängt alle Verbindungsfehler ab, damit ein kaputter Drucker den Watcher nicht stoppt.
|
||||||
|
- `prepare_send` erhält die ACE-Map schon beim Einreihen in die Send-Queue.
|
||||||
|
- Spulenwarnung im Watcher berücksichtigt ACE-Plätze.
|
||||||
|
|
||||||
## [2.10.0] – 2026-10-18
|
## [2.10.0] – 2026-10-18
|
||||||
|
|
||||||
### Neu
|
### Neu
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from cryptography.hazmat.primitives import padding
|
|||||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||||
|
|
||||||
CRED_TTL = 300
|
CRED_TTL = 300
|
||||||
INFO_TTL = 10
|
INFO_TTL = 45
|
||||||
FAIL_TTL = 45
|
FAIL_TTL = 45
|
||||||
_CRED_CACHE = {}
|
_CRED_CACHE = {}
|
||||||
_INFO_CACHE = {}
|
_INFO_CACHE = {}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from grotti.errors import GrottiError
|
from grotti.errors import GrottiError
|
||||||
@@ -10,6 +11,7 @@ ROOT = Path(__file__).resolve().parent.parent
|
|||||||
LOOPBACK = {"127.0.0.1", "::1", "localhost"}
|
LOOPBACK = {"127.0.0.1", "::1", "localhost"}
|
||||||
CONFIG_PATH = ROOT / "config.json"
|
CONFIG_PATH = ROOT / "config.json"
|
||||||
DEFAULT_WATCH_REL = "slice"
|
DEFAULT_WATCH_REL = "slice"
|
||||||
|
_SETTINGS_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def _energy(raw):
|
def _energy(raw):
|
||||||
@@ -42,6 +44,7 @@ def _alerts(raw):
|
|||||||
"fehler": bool(alerts.get("fehler", True)),
|
"fehler": bool(alerts.get("fehler", True)),
|
||||||
"offline": bool(alerts.get("offline", True)),
|
"offline": bool(alerts.get("offline", True)),
|
||||||
"queue": bool(alerts.get("queue", True)),
|
"queue": bool(alerts.get("queue", True)),
|
||||||
|
"spule": bool(alerts.get("spule", True)),
|
||||||
"ablauf": bool(alerts.get("ablauf", False)),
|
"ablauf": bool(alerts.get("ablauf", False)),
|
||||||
"start": bool(alerts.get("start", False)),
|
"start": bool(alerts.get("start", False)),
|
||||||
}
|
}
|
||||||
@@ -196,6 +199,19 @@ def settings_public(cfg):
|
|||||||
|
|
||||||
def save_settings(cfg, energy=None, telegram=None, alerts=None, auto_queue=None, fqdn=None, watch_dir=None):
|
def save_settings(cfg, energy=None, telegram=None, alerts=None, auto_queue=None, fqdn=None, watch_dir=None):
|
||||||
"""Schreibt Einstellungen zurück nach config.json und aktualisiert cfg."""
|
"""Schreibt Einstellungen zurück nach config.json und aktualisiert cfg."""
|
||||||
|
with _SETTINGS_LOCK:
|
||||||
|
return _save_settings_locked(
|
||||||
|
cfg,
|
||||||
|
energy=energy,
|
||||||
|
telegram=telegram,
|
||||||
|
alerts=alerts,
|
||||||
|
auto_queue=auto_queue,
|
||||||
|
fqdn=fqdn,
|
||||||
|
watch_dir=watch_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _save_settings_locked(cfg, energy=None, telegram=None, alerts=None, auto_queue=None, fqdn=None, watch_dir=None):
|
||||||
path = cfg.get("_path") or CONFIG_PATH
|
path = cfg.get("_path") or CONFIG_PATH
|
||||||
raw = dict(cfg.get("_raw") or {})
|
raw = dict(cfg.get("_raw") or {})
|
||||||
if path.exists():
|
if path.exists():
|
||||||
@@ -243,6 +259,7 @@ def save_settings(cfg, energy=None, telegram=None, alerts=None, auto_queue=None,
|
|||||||
"fehler": bool(alerts.get("fehler", True)),
|
"fehler": bool(alerts.get("fehler", True)),
|
||||||
"offline": bool(alerts.get("offline", True)),
|
"offline": bool(alerts.get("offline", True)),
|
||||||
"queue": bool(alerts.get("queue", True)),
|
"queue": bool(alerts.get("queue", True)),
|
||||||
|
"spule": bool(alerts.get("spule", True)),
|
||||||
"ablauf": bool(alerts.get("ablauf", False)),
|
"ablauf": bool(alerts.get("ablauf", False)),
|
||||||
"start": bool(alerts.get("start", False)),
|
"start": bool(alerts.get("start", False)),
|
||||||
}
|
}
|
||||||
|
|||||||
+174
-13
@@ -1,6 +1,9 @@
|
|||||||
"""PostgreSQL-Verbindung und Schema."""
|
"""PostgreSQL-Verbindung, Pool und Migrationen."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
import threading
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import urlparse, urlunparse
|
from urllib.parse import urlparse, urlunparse
|
||||||
@@ -8,22 +11,105 @@ from urllib.parse import urlparse, urlunparse
|
|||||||
import psycopg
|
import psycopg
|
||||||
from psycopg import sql
|
from psycopg import sql
|
||||||
from psycopg.rows import dict_row
|
from psycopg.rows import dict_row
|
||||||
|
from psycopg_pool import ConnectionPool
|
||||||
|
|
||||||
SCHEMA = (Path(__file__).parent / "schema.sql").read_text(encoding="utf-8")
|
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
||||||
|
SCHEMA_LEGACY = Path(__file__).parent / "schema.sql"
|
||||||
|
|
||||||
|
_pool: ConnectionPool | None = None
|
||||||
|
_pool_lock = threading.Lock()
|
||||||
|
_user_count_cache = {"n": None, "at": 0.0}
|
||||||
|
_USER_COUNT_TTL = 5.0
|
||||||
|
|
||||||
|
|
||||||
def database_name(url):
|
def database_name(url):
|
||||||
name = urlparse(url).path.lstrip("/")
|
name = urlparse(url).path.lstrip("/")
|
||||||
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name or ""):
|
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name or ""):
|
||||||
raise SystemExit("Der Datenbankname in database_url darf nur Buchstaben, Zahlen und Unterstrich enthalten.")
|
raise SystemExit(
|
||||||
|
"Der Datenbankname in database_url darf nur Buchstaben, Zahlen und Unterstrich enthalten."
|
||||||
|
)
|
||||||
return name
|
return name
|
||||||
|
|
||||||
|
|
||||||
def apply_schema(conn):
|
def _migration_files():
|
||||||
for statement in SCHEMA.split(";"):
|
files = sorted(MIGRATIONS_DIR.glob("*.sql"))
|
||||||
|
out = []
|
||||||
|
for path in files:
|
||||||
|
match = re.match(r"^(\d+)_", path.name)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
out.append((int(match.group(1)), path))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _split_statements(text):
|
||||||
|
"""Einfache Trennung an Semikolon — Migrationen ohne Funktionen mit ; im Körper."""
|
||||||
|
parts = []
|
||||||
|
for statement in text.split(";"):
|
||||||
cleaned = statement.strip()
|
cleaned = statement.strip()
|
||||||
if cleaned:
|
if cleaned:
|
||||||
conn.execute(cleaned)
|
parts.append(cleaned)
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_version_table(conn):
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_version (
|
||||||
|
version integer PRIMARY KEY,
|
||||||
|
name text NOT NULL,
|
||||||
|
applied_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _applied_versions(conn):
|
||||||
|
rows = conn.execute("SELECT version FROM schema_version ORDER BY version").fetchall()
|
||||||
|
return {int(row["version"] if isinstance(row, dict) else row[0]) for row in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_schema_present(conn):
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'printers'
|
||||||
|
"""
|
||||||
|
).fetchone()
|
||||||
|
return bool(row)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_migrations(conn):
|
||||||
|
"""Wendet fehlende Migrationen an. Bestehende Installationen ohne Version bekommen 001 gutgeschrieben."""
|
||||||
|
_ensure_version_table(conn)
|
||||||
|
applied = _applied_versions(conn)
|
||||||
|
migrations = _migration_files()
|
||||||
|
if not migrations and SCHEMA_LEGACY.exists():
|
||||||
|
# Fallback: altes schema.sql einmalig
|
||||||
|
for statement in _split_statements(SCHEMA_LEGACY.read_text(encoding="utf-8")):
|
||||||
|
conn.execute(statement)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not applied and _legacy_schema_present(conn):
|
||||||
|
# Schema war schon da (vor Migrationssystem) → Basis als angewandt markieren.
|
||||||
|
base = next((m for m in migrations if m[0] == 1), None)
|
||||||
|
if base:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO schema_version (version, name) VALUES (%s, %s) ON CONFLICT DO NOTHING",
|
||||||
|
(1, base[1].name),
|
||||||
|
)
|
||||||
|
applied.add(1)
|
||||||
|
|
||||||
|
for version, path in migrations:
|
||||||
|
if version in applied:
|
||||||
|
continue
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
for statement in _split_statements(text):
|
||||||
|
conn.execute(statement)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO schema_version (version, name) VALUES (%s, %s)",
|
||||||
|
(version, path.name),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def prepare(url):
|
def prepare(url):
|
||||||
@@ -31,14 +117,16 @@ def prepare(url):
|
|||||||
admin = urlunparse(urlparse(url)._replace(path="/postgres"))
|
admin = urlunparse(urlparse(url)._replace(path="/postgres"))
|
||||||
try:
|
try:
|
||||||
with psycopg.connect(admin, autocommit=True, connect_timeout=5) as conn:
|
with psycopg.connect(admin, autocommit=True, connect_timeout=5) as conn:
|
||||||
exists = conn.execute("SELECT 1 FROM pg_database WHERE datname = %s", (name,)).fetchone()
|
exists = conn.execute(
|
||||||
|
"SELECT 1 FROM pg_database WHERE datname = %s", (name,)
|
||||||
|
).fetchone()
|
||||||
if not exists:
|
if not exists:
|
||||||
conn.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(name)))
|
conn.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(name)))
|
||||||
except psycopg.Error:
|
except psycopg.Error as exc:
|
||||||
pass
|
print("Datenbank anlegen: %s" % exc, flush=True)
|
||||||
try:
|
try:
|
||||||
with psycopg.connect(url, connect_timeout=5) as conn:
|
with psycopg.connect(url, row_factory=dict_row, connect_timeout=5) as conn:
|
||||||
apply_schema(conn)
|
apply_migrations(conn)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except psycopg.Error as exc:
|
except psycopg.Error as exc:
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
@@ -50,9 +138,50 @@ def prepare(url):
|
|||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def init_pool(url, min_size=2, max_size=16):
|
||||||
|
global _pool
|
||||||
|
with _pool_lock:
|
||||||
|
if _pool is not None:
|
||||||
|
return _pool
|
||||||
|
_pool = ConnectionPool(
|
||||||
|
conninfo=url,
|
||||||
|
min_size=min_size,
|
||||||
|
max_size=max_size,
|
||||||
|
kwargs={"row_factory": dict_row, "connect_timeout": 5},
|
||||||
|
open=True,
|
||||||
|
name="grotti",
|
||||||
|
)
|
||||||
|
return _pool
|
||||||
|
|
||||||
|
|
||||||
|
def close_pool():
|
||||||
|
global _pool
|
||||||
|
with _pool_lock:
|
||||||
|
if _pool is not None:
|
||||||
|
_pool.close()
|
||||||
|
_pool = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_pool():
|
||||||
|
if _pool is None:
|
||||||
|
raise RuntimeError("Datenbank-Pool ist nicht gestartet.")
|
||||||
|
return _pool
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def connect(url):
|
def connect(url=None):
|
||||||
conn = psycopg.connect(url, row_factory=dict_row)
|
"""Verbindung aus dem Pool, sonst einmalig direkt (Tests / prepare)."""
|
||||||
|
if _pool is not None:
|
||||||
|
with _pool.connection() as conn:
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
return
|
||||||
|
if not url:
|
||||||
|
raise RuntimeError("Keine database_url und kein Pool.")
|
||||||
|
conn = psycopg.connect(url, row_factory=dict_row, connect_timeout=5)
|
||||||
try:
|
try:
|
||||||
yield conn
|
yield conn
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -60,3 +189,35 @@ def connect(url):
|
|||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def cached_user_count(conn):
|
||||||
|
"""Zählt Benutzer mit kurzem Cache — vermeidet count(*) bei jedem Request."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
cached = _user_count_cache["n"]
|
||||||
|
if cached is not None and (now - _user_count_cache["at"]) < _USER_COUNT_TTL:
|
||||||
|
return cached
|
||||||
|
n = int(conn.execute("SELECT COUNT(*) AS n FROM users").fetchone()["n"])
|
||||||
|
_user_count_cache["n"] = n
|
||||||
|
_user_count_cache["at"] = now
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_user_count():
|
||||||
|
_user_count_cache["n"] = None
|
||||||
|
_user_count_cache["at"] = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def prune_run_samples(conn, keep_days=90):
|
||||||
|
"""Alte Temperaturproben löschen (Retention)."""
|
||||||
|
days = max(7, int(keep_days))
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM run_samples
|
||||||
|
WHERE sampled_at < now() - (%s || ' days')::interval
|
||||||
|
""",
|
||||||
|
(str(days),),
|
||||||
|
)
|
||||||
|
return row.rowcount if row is not None else 0
|
||||||
+36
-21
@@ -43,14 +43,7 @@ def _targets_from_ace(conn, host, ace):
|
|||||||
targets = []
|
targets = []
|
||||||
for item in ace:
|
for item in ace:
|
||||||
slot = int(item["ams_index"]) + 1
|
slot = int(item["ams_index"]) + 1
|
||||||
row = conn.execute(
|
row = store.spool_at_slot(conn, host, slot)
|
||||||
"""
|
|
||||||
SELECT * FROM spools
|
|
||||||
WHERE printer_host = %s AND ace_slot = %s
|
|
||||||
ORDER BY id LIMIT 1
|
|
||||||
""",
|
|
||||||
(host, slot),
|
|
||||||
).fetchone()
|
|
||||||
if row:
|
if row:
|
||||||
targets.append(row)
|
targets.append(row)
|
||||||
return targets
|
return targets
|
||||||
@@ -62,12 +55,18 @@ def unix_to_dt(value):
|
|||||||
return datetime.fromtimestamp(float(value), timezone.utc)
|
return datetime.fromtimestamp(float(value), timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def choose_spool(conn, host, material, hinted_id):
|
def choose_spool(conn, host, material, hinted_id, ended_at=None):
|
||||||
|
"""Spule für History-Abzug. Ohne Hint nur Rollen, die vor Druckende existierten."""
|
||||||
if hinted_id:
|
if hinted_id:
|
||||||
row = store.get_spool_row(conn, hinted_id)
|
row = store.get_spool_row(conn, hinted_id)
|
||||||
if row:
|
if row and _spool_valid_for_end(row, ended_at):
|
||||||
return row
|
return row
|
||||||
rows = store.spools_for_printer(conn, host)
|
if row and not _spool_valid_for_end(row, ended_at):
|
||||||
|
return None
|
||||||
|
rows = [
|
||||||
|
row for row in store.spools_for_printer(conn, host)
|
||||||
|
if _spool_valid_for_end(row, ended_at)
|
||||||
|
]
|
||||||
if len(rows) == 1:
|
if len(rows) == 1:
|
||||||
return rows[0]
|
return rows[0]
|
||||||
if material:
|
if material:
|
||||||
@@ -77,6 +76,24 @@ def choose_spool(conn, host, material, hinted_id):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _spool_valid_for_end(spool, ended_at):
|
||||||
|
"""Verhindert Abzug alter Moonraker-Jobs auf neu eingelegte Spulen."""
|
||||||
|
if ended_at is None or not spool:
|
||||||
|
return True
|
||||||
|
created = spool.get("created_at")
|
||||||
|
if created is None:
|
||||||
|
return True
|
||||||
|
end = ended_at
|
||||||
|
if getattr(created, "tzinfo", None) is None and getattr(end, "tzinfo", None) is not None:
|
||||||
|
created = created.replace(tzinfo=timezone.utc)
|
||||||
|
if getattr(end, "tzinfo", None) is None and getattr(created, "tzinfo", None) is not None:
|
||||||
|
end = end.replace(tzinfo=timezone.utc)
|
||||||
|
try:
|
||||||
|
return created <= end
|
||||||
|
except TypeError:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def file_material_types(file_row):
|
def file_material_types(file_row):
|
||||||
types = []
|
types = []
|
||||||
for item in (file_row or {}).get("filaments") or []:
|
for item in (file_row or {}).get("filaments") or []:
|
||||||
@@ -93,14 +110,7 @@ def resolve_spools_at_host(conn, host, ace_map=None):
|
|||||||
if mapping:
|
if mapping:
|
||||||
for item in mapping:
|
for item in mapping:
|
||||||
slot = int(item["ams_index"]) + 1
|
slot = int(item["ams_index"]) + 1
|
||||||
row = conn.execute(
|
row = store.spool_at_slot(conn, host, slot)
|
||||||
"""
|
|
||||||
SELECT * FROM spools
|
|
||||||
WHERE printer_host = %s AND ace_slot = %s
|
|
||||||
ORDER BY id LIMIT 1
|
|
||||||
""",
|
|
||||||
(host, slot),
|
|
||||||
).fetchone()
|
|
||||||
if row:
|
if row:
|
||||||
found.append(row)
|
found.append(row)
|
||||||
return found
|
return found
|
||||||
@@ -329,7 +339,12 @@ def sync_history(conn, cfg):
|
|||||||
file_id = (sent or {}).get("file_id") or (known or {}).get("id")
|
file_id = (sent or {}).get("file_id") or (known or {}).get("id")
|
||||||
project_id = (sent or {}).get("project_id") or (known or {}).get("project_id")
|
project_id = (sent or {}).get("project_id") or (known or {}).get("project_id")
|
||||||
hinted = (sent or {}).get("spool_id") or (known or {}).get("spool_id")
|
hinted = (sent or {}).get("spool_id") or (known or {}).get("spool_id")
|
||||||
spool = choose_spool(conn, printer["host"], material, hinted)
|
ended_at = unix_to_dt(job.get("end_time"))
|
||||||
|
# Ohne Send-Eintrag keine Rate — sonst zieht eine neue Spule alle alten Jobs ab.
|
||||||
|
if not sent and not hinted:
|
||||||
|
spool = None
|
||||||
|
else:
|
||||||
|
spool = choose_spool(conn, printer["host"], material, hinted, ended_at=ended_at)
|
||||||
terminal = state in TERMINAL
|
terminal = state in TERMINAL
|
||||||
live = store.find_live_run(conn, printer["host"], filename)
|
live = store.find_live_run(conn, printer["host"], filename)
|
||||||
progress = float((live or {}).get("progress_last") or 0)
|
progress = float((live or {}).get("progress_last") or 0)
|
||||||
@@ -503,7 +518,7 @@ def send_file(
|
|||||||
progress("pruefen", 15, "Drucker prüfen…")
|
progress("pruefen", 15, "Drucker prüfen…")
|
||||||
try:
|
try:
|
||||||
live = moonraker.query(host)
|
live = moonraker.query(host)
|
||||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError) as exc:
|
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, OSError, ValueError) as exc:
|
||||||
raise GrottiError("Der Drucker ist nicht erreichbar.", "offline") from exc
|
raise GrottiError("Der Drucker ist nicht erreichbar.", "offline") from exc
|
||||||
if live.get("state") in ("printing", "paused"):
|
if live.get("state") in ("printing", "paused"):
|
||||||
raise GrottiError("Der Drucker druckt schon.", "belegt", 409)
|
raise GrottiError("Der Drucker druckt schon.", "belegt", 409)
|
||||||
|
|||||||
+4
-1
@@ -100,7 +100,10 @@ def refresh_file_meta(conn, force=False):
|
|||||||
|
|
||||||
def safe_filename(name):
|
def safe_filename(name):
|
||||||
base = Path(name).name.replace("\\", "_").replace("/", "_").replace('"', "")
|
base = Path(name).name.replace("\\", "_").replace("/", "_").replace('"', "")
|
||||||
return base or "datei.gcode"
|
base = base.strip().strip(".")
|
||||||
|
if not base or base in (".", ".."):
|
||||||
|
return "datei.gcode"
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
def unique_path(directory, filename):
|
def unique_path(directory, filename):
|
||||||
|
|||||||
@@ -220,11 +220,11 @@ def main():
|
|||||||
out = []
|
out = []
|
||||||
if nozzle is not None:
|
if nozzle is not None:
|
||||||
out.append(_api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), {
|
out.append(_api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), {
|
||||||
"action": "nozzle", "value": nozzle,
|
"action": "nozzle", "temp": nozzle,
|
||||||
}))
|
}))
|
||||||
if bed is not None:
|
if bed is not None:
|
||||||
out.append(_api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), {
|
out.append(_api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), {
|
||||||
"action": "bed", "value": bed,
|
"action": "bed", "temp": bed,
|
||||||
}))
|
}))
|
||||||
if not out:
|
if not out:
|
||||||
raise RuntimeError("nozzle und/oder bed angeben.")
|
raise RuntimeError("nozzle und/oder bed angeben.")
|
||||||
@@ -236,7 +236,7 @@ def main():
|
|||||||
_write_guard()
|
_write_guard()
|
||||||
return json.dumps(
|
return json.dumps(
|
||||||
_api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), {
|
_api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), {
|
||||||
"action": "speed", "value": speed,
|
"action": "speed", "percent": speed,
|
||||||
}),
|
}),
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
@@ -247,7 +247,7 @@ def main():
|
|||||||
_write_guard()
|
_write_guard()
|
||||||
return json.dumps(
|
return json.dumps(
|
||||||
_api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), {
|
_api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), {
|
||||||
"action": "fan", "value": fan,
|
"action": "fan", "percent": fan,
|
||||||
}),
|
}),
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
name text NOT NULL,
|
||||||
|
notes text NOT NULL DEFAULT '',
|
||||||
|
status text NOT NULL DEFAULT 'idee',
|
||||||
|
quote jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS spools (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
name text NOT NULL,
|
||||||
|
brand text NOT NULL DEFAULT '',
|
||||||
|
material_type text NOT NULL DEFAULT '',
|
||||||
|
color text NOT NULL DEFAULT '',
|
||||||
|
price_per_kg double precision NOT NULL DEFAULT 0,
|
||||||
|
density double precision NOT NULL DEFAULT 1.24,
|
||||||
|
volumetric double precision NOT NULL DEFAULT 12,
|
||||||
|
remaining_g double precision NOT NULL DEFAULT 1000,
|
||||||
|
printer_host text NOT NULL DEFAULT '',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS files (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
project_id bigint REFERENCES projects (id) ON DELETE SET NULL,
|
||||||
|
spool_id bigint REFERENCES spools (id) ON DELETE SET NULL,
|
||||||
|
name text NOT NULL,
|
||||||
|
stored_path text NOT NULL,
|
||||||
|
source text NOT NULL,
|
||||||
|
kind text NOT NULL,
|
||||||
|
printer_family text NOT NULL DEFAULT '',
|
||||||
|
filaments jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
weight_g double precision NOT NULL DEFAULT 0,
|
||||||
|
estimated_s double precision NOT NULL DEFAULT 0,
|
||||||
|
thumb_entry text NOT NULL DEFAULT '',
|
||||||
|
plate text NOT NULL DEFAULT '',
|
||||||
|
size_bytes bigint NOT NULL DEFAULT 0,
|
||||||
|
mtime double precision NOT NULL DEFAULT 0,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (source, stored_path)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE files ADD COLUMN IF NOT EXISTS slice_meta jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sends (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
printer_host text NOT NULL,
|
||||||
|
filename text NOT NULL,
|
||||||
|
file_id bigint REFERENCES files (id) ON DELETE SET NULL,
|
||||||
|
project_id bigint REFERENCES projects (id) ON DELETE SET NULL,
|
||||||
|
spool_id bigint REFERENCES spools (id) ON DELETE SET NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE sends ADD COLUMN IF NOT EXISTS ace_map jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||||
|
ALTER TABLE sends ADD COLUMN IF NOT EXISTS bed_leveling boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS runs (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
printer_host text NOT NULL,
|
||||||
|
printer_name text NOT NULL DEFAULT '',
|
||||||
|
filename text NOT NULL DEFAULT '',
|
||||||
|
file_id bigint REFERENCES files (id) ON DELETE SET NULL,
|
||||||
|
project_id bigint REFERENCES projects (id) ON DELETE SET NULL,
|
||||||
|
spool_id bigint REFERENCES spools (id) ON DELETE SET NULL,
|
||||||
|
state text NOT NULL DEFAULT '',
|
||||||
|
started_at timestamptz,
|
||||||
|
ended_at timestamptz,
|
||||||
|
duration_s double precision NOT NULL DEFAULT 0,
|
||||||
|
weight_g double precision NOT NULL DEFAULT 0,
|
||||||
|
filament_cost double precision NOT NULL DEFAULT 0,
|
||||||
|
energy_cost double precision NOT NULL DEFAULT 0,
|
||||||
|
moonraker_job_id text NOT NULL,
|
||||||
|
deducted boolean NOT NULL DEFAULT false,
|
||||||
|
needs_spool boolean NOT NULL DEFAULT false,
|
||||||
|
verdict text NOT NULL DEFAULT '',
|
||||||
|
material_type text NOT NULL DEFAULT '',
|
||||||
|
UNIQUE (printer_host, moonraker_job_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS files_project ON files (project_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS runs_started ON runs (started_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS printers (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
name text NOT NULL,
|
||||||
|
host text NOT NULL UNIQUE,
|
||||||
|
family text NOT NULL DEFAULT '',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE printers ADD COLUMN IF NOT EXISTS locked boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ideas (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
title text NOT NULL,
|
||||||
|
body text NOT NULL DEFAULT '',
|
||||||
|
status text NOT NULL DEFAULT 'offen',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS materials (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
name text NOT NULL UNIQUE,
|
||||||
|
brand text NOT NULL DEFAULT '',
|
||||||
|
material_type text NOT NULL DEFAULT '',
|
||||||
|
density double precision NOT NULL,
|
||||||
|
volumetric double precision NOT NULL,
|
||||||
|
volumetric_s1 double precision,
|
||||||
|
machines text NOT NULL DEFAULT 'beide',
|
||||||
|
price_per_kg double precision NOT NULL DEFAULT 0,
|
||||||
|
note text NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE spools ADD COLUMN IF NOT EXISTS colors jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||||
|
ALTER TABLE spools ADD COLUMN IF NOT EXISTS ace_slot smallint;
|
||||||
|
ALTER TABLE spools ADD COLUMN IF NOT EXISTS material_id bigint REFERENCES materials (id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE spools ADD COLUMN IF NOT EXISTS chargen_nr text;
|
||||||
|
ALTER TABLE spools ADD COLUMN IF NOT EXISTS hersteller_charge text NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE spools ADD COLUMN IF NOT EXISTS code text;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS spools_chargen_nr ON spools (chargen_nr) WHERE chargen_nr IS NOT NULL AND chargen_nr <> '';
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS spools_code ON spools (code) WHERE code IS NOT NULL AND code <> '';
|
||||||
|
-- Ein ACE-Platz gehört höchstens einer Spule.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS spools_host_slot
|
||||||
|
ON spools (printer_host, ace_slot)
|
||||||
|
WHERE printer_host <> '' AND ace_slot IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
host text NOT NULL DEFAULT '',
|
||||||
|
printer text NOT NULL DEFAULT '',
|
||||||
|
kind text NOT NULL,
|
||||||
|
message text NOT NULL DEFAULT '',
|
||||||
|
filename text NOT NULL DEFAULT '',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
read_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS events_unread ON events (read_at, created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS print_queue (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
file_id bigint NOT NULL REFERENCES files (id) ON DELETE CASCADE,
|
||||||
|
host text NOT NULL DEFAULT '',
|
||||||
|
family text NOT NULL DEFAULT '',
|
||||||
|
priority integer NOT NULL DEFAULT 100,
|
||||||
|
status text NOT NULL DEFAULT 'waiting',
|
||||||
|
spool_id bigint REFERENCES spools (id) ON DELETE SET NULL,
|
||||||
|
ace_map jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
bed_leveling boolean NOT NULL DEFAULT false,
|
||||||
|
override_machine boolean NOT NULL DEFAULT false,
|
||||||
|
message text NOT NULL DEFAULT '',
|
||||||
|
steps jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
step_index integer NOT NULL DEFAULT 0,
|
||||||
|
repeat_total integer NOT NULL DEFAULT 1,
|
||||||
|
repeat_done integer NOT NULL DEFAULT 0,
|
||||||
|
phase text NOT NULL DEFAULT '',
|
||||||
|
phase_started_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS steps jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||||
|
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS step_index integer NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS repeat_total integer NOT NULL DEFAULT 1;
|
||||||
|
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS repeat_done integer NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS phase text NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS phase_started_at timestamptz;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS print_queue_waiting ON print_queue (status, priority, id);
|
||||||
|
CREATE INDEX IF NOT EXISTS print_queue_running_host ON print_queue (host, status) WHERE status = 'running';
|
||||||
|
|
||||||
|
-- Nach dem Druck: Platte manuell quittieren, bevor „wenn frei“ den nächsten Job startet.
|
||||||
|
CREATE TABLE IF NOT EXISTS bed_status (
|
||||||
|
host text PRIMARY KEY,
|
||||||
|
needs_clear boolean NOT NULL DEFAULT false,
|
||||||
|
cleared_at timestamptz,
|
||||||
|
message text NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
username text NOT NULL UNIQUE,
|
||||||
|
password_hash text NOT NULL,
|
||||||
|
role text NOT NULL DEFAULT 'operator',
|
||||||
|
active boolean NOT NULL DEFAULT true,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
last_login timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL DEFAULT '',
|
||||||
|
token_hash text NOT NULL UNIQUE,
|
||||||
|
token_prefix text NOT NULL DEFAULT '',
|
||||||
|
role text NOT NULL DEFAULT 'operator',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
last_used timestamptz,
|
||||||
|
revoked_at timestamptz,
|
||||||
|
expires_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS api_tokens_user ON api_tokens (user_id) WHERE revoked_at IS NULL;
|
||||||
|
|
||||||
|
-- OIDC später: Identitäten an lokale User binden.
|
||||||
|
CREATE TABLE IF NOT EXISTS oauth_identities (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
provider text NOT NULL,
|
||||||
|
subject text NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (provider, subject)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Druckbericht: Soll/Ist, Charge, Live-Fortschritt
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS weight_planned_g double precision NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS weight_used_g double precision NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS weight_source text NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS filament_used_mm double precision NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS filament_total_mm double precision NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS progress_last double precision NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS estimated_s double precision NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS chargen_nr text;
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS message text NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS objects jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS runs_chargen_nr
|
||||||
|
ON runs (chargen_nr) WHERE chargen_nr IS NOT NULL AND chargen_nr <> '';
|
||||||
|
|
||||||
|
ALTER TABLE events ADD COLUMN IF NOT EXISTS run_id bigint REFERENCES runs (id) ON DELETE SET NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS events_run ON events (run_id) WHERE run_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Abzug je Spule (Messwert oder Schätzwert)
|
||||||
|
CREATE TABLE IF NOT EXISTS run_spools (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
|
||||||
|
spool_id bigint NOT NULL REFERENCES spools (id) ON DELETE RESTRICT,
|
||||||
|
ace_slot smallint,
|
||||||
|
weight_g double precision NOT NULL DEFAULT 0,
|
||||||
|
weight_planned_g double precision NOT NULL DEFAULT 0,
|
||||||
|
cost double precision NOT NULL DEFAULT 0,
|
||||||
|
is_estimate boolean NOT NULL DEFAULT false,
|
||||||
|
UNIQUE (run_id, spool_id, ace_slot)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS run_spools_spool ON run_spools (spool_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS run_spools_run ON run_spools (run_id);
|
||||||
|
|
||||||
|
-- Abschnitte bei Rollenwechsel mitten im Druck
|
||||||
|
CREATE TABLE IF NOT EXISTS run_segments (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
|
||||||
|
spool_id bigint NOT NULL REFERENCES spools (id) ON DELETE RESTRICT,
|
||||||
|
ace_slot smallint,
|
||||||
|
mm_start double precision NOT NULL DEFAULT 0,
|
||||||
|
mm_end double precision,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
closed_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS run_segments_run ON run_segments (run_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS run_segments_open
|
||||||
|
ON run_segments (run_id) WHERE mm_end IS NULL;
|
||||||
|
|
||||||
|
-- Temperatur / Fortschritt während des Drucks
|
||||||
|
CREATE TABLE IF NOT EXISTS run_samples (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
|
||||||
|
sampled_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
nozzle double precision,
|
||||||
|
nozzle_target double precision,
|
||||||
|
bed double precision,
|
||||||
|
bed_target double precision,
|
||||||
|
chamber double precision,
|
||||||
|
fan double precision,
|
||||||
|
speed_factor double precision,
|
||||||
|
extrude_factor double precision,
|
||||||
|
layer integer,
|
||||||
|
progress double precision,
|
||||||
|
filament_used_mm double precision,
|
||||||
|
mmu_tool smallint,
|
||||||
|
nozzle_power double precision,
|
||||||
|
bed_power double precision
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS run_samples_run_time ON run_samples (run_id, sampled_at);
|
||||||
|
|
||||||
|
ALTER TABLE run_samples ADD COLUMN IF NOT EXISTS nozzle_power double precision;
|
||||||
|
ALTER TABLE run_samples ADD COLUMN IF NOT EXISTS bed_power double precision;
|
||||||
|
|
||||||
|
-- Objekt-Chargen für Etiketten
|
||||||
|
CREATE TABLE IF NOT EXISTS run_labels (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
|
||||||
|
object_name text NOT NULL DEFAULT '',
|
||||||
|
chargen_nr text NOT NULL,
|
||||||
|
excluded boolean NOT NULL DEFAULT false,
|
||||||
|
UNIQUE (run_id, object_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS run_labels_chargen ON run_labels (chargen_nr);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Heiße Abfragepfade für Sends, Läufe, Dateien und Ereignisse.
|
||||||
|
CREATE INDEX IF NOT EXISTS sends_host_filename ON sends (printer_host, filename);
|
||||||
|
CREATE INDEX IF NOT EXISTS sends_host_file ON sends (printer_host, file_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS runs_host_state ON runs (printer_host, state);
|
||||||
|
CREATE INDEX IF NOT EXISTS files_name ON files (name);
|
||||||
|
CREATE INDEX IF NOT EXISTS events_host_kind_created ON events (host, kind, created_at DESC);
|
||||||
+21
-1
@@ -17,6 +17,16 @@ SLOT_CACHE = {}
|
|||||||
MESH_CACHE = {}
|
MESH_CACHE = {}
|
||||||
OBJECT_CACHE = {}
|
OBJECT_CACHE = {}
|
||||||
THUMB_RE_LIMIT = 180_000
|
THUMB_RE_LIMIT = 180_000
|
||||||
|
_CACHE_MAX = 128
|
||||||
|
|
||||||
|
|
||||||
|
def _trim_cache(cache, max_items=_CACHE_MAX):
|
||||||
|
"""Einfache Größenbegrenzung (älteste Keys zuerst bei dict-Reihenfolge)."""
|
||||||
|
while len(cache) > max_items:
|
||||||
|
try:
|
||||||
|
cache.pop(next(iter(cache)))
|
||||||
|
except StopIteration:
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
def matrix_rows(points):
|
def matrix_rows(points):
|
||||||
@@ -343,6 +353,11 @@ def steuern(host, action, params=None):
|
|||||||
"""Whitelist-Steuerung ohne freie Konsole."""
|
"""Whitelist-Steuerung ohne freie Konsole."""
|
||||||
params = params if isinstance(params, dict) else {}
|
params = params if isinstance(params, dict) else {}
|
||||||
action = (action or "").strip()
|
action = (action or "").strip()
|
||||||
|
# MCP und ältere Clients schicken "value" statt temp/percent.
|
||||||
|
if "temp" not in params and params.get("value") is not None and action in ("nozzle", "bed", "dryer_start"):
|
||||||
|
params = {**params, "temp": params["value"]}
|
||||||
|
if "percent" not in params and params.get("value") is not None and action in ("speed", "flow", "fan"):
|
||||||
|
params = {**params, "percent": params["value"]}
|
||||||
|
|
||||||
def pct(key, default=100, lo=0, hi=200):
|
def pct(key, default=100, lo=0, hi=200):
|
||||||
try:
|
try:
|
||||||
@@ -492,6 +507,7 @@ def metadata(host, path):
|
|||||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError):
|
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError):
|
||||||
data = {}
|
data = {}
|
||||||
META_CACHE[key] = (now, data)
|
META_CACHE[key] = (now, data)
|
||||||
|
_trim_cache(META_CACHE)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -518,9 +534,11 @@ def embedded_preview(host, path):
|
|||||||
text = response.read().decode("utf-8", "replace")
|
text = response.read().decode("utf-8", "replace")
|
||||||
except (urllib.error.URLError, TimeoutError):
|
except (urllib.error.URLError, TimeoutError):
|
||||||
EMBEDDED[key] = None
|
EMBEDDED[key] = None
|
||||||
|
_trim_cache(EMBEDDED)
|
||||||
return None
|
return None
|
||||||
png = embedded_png(text)
|
png = embedded_png(text)
|
||||||
EMBEDDED[key] = png
|
EMBEDDED[key] = png
|
||||||
|
_trim_cache(EMBEDDED)
|
||||||
return png
|
return png
|
||||||
|
|
||||||
|
|
||||||
@@ -1117,7 +1135,9 @@ def snapshot(printers):
|
|||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
card.update(query(printer["host"]))
|
card.update(query(printer["host"]))
|
||||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError) as error:
|
except Exception as error:
|
||||||
|
# Auch RemoteDisconnected / IncompleteRead / ValueError — sonst fällt der ganze Tick aus.
|
||||||
|
print("snapshot %s: %s" % (printer.get("host"), error), flush=True)
|
||||||
card.update({
|
card.update({
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"state": "offline",
|
"state": "offline",
|
||||||
|
|||||||
+60
-1
@@ -20,6 +20,8 @@ BUSY = {"printing", "paused", "heating", "error"}
|
|||||||
KEEPALIVE_S = 120
|
KEEPALIVE_S = 120
|
||||||
# Nach Start ohne print_active: Upload/MQTT/LeviQ brauchen Zeit, aber nicht ewig
|
# Nach Start ohne print_active: Upload/MQTT/LeviQ brauchen Zeit, aber nicht ewig
|
||||||
PRINT_START_TIMEOUT_S = 600
|
PRINT_START_TIMEOUT_S = 600
|
||||||
|
# Einfacher Queue-Start ohne Worker (typisch Grotti-Neustart)
|
||||||
|
SIMPLE_START_STUCK_S = 45
|
||||||
|
|
||||||
|
|
||||||
def _ablauf_needs_motors(item):
|
def _ablauf_needs_motors(item):
|
||||||
@@ -120,6 +122,41 @@ class PrintQueueDispatcher:
|
|||||||
thread = self._workers.get(item_id)
|
thread = self._workers.get(item_id)
|
||||||
return bool(thread and thread.is_alive())
|
return bool(thread and thread.is_alive())
|
||||||
|
|
||||||
|
def _item_phase_age_s(self, item):
|
||||||
|
"""Alter von phase_started_at, sonst updated_at (für hängende Starts nach Neustart)."""
|
||||||
|
started = item.get("phase_started_at")
|
||||||
|
if started:
|
||||||
|
if getattr(started, "tzinfo", None) is None:
|
||||||
|
started = started.replace(tzinfo=timezone.utc)
|
||||||
|
return (datetime.now(timezone.utc) - started).total_seconds()
|
||||||
|
updated = item.get("updated_at")
|
||||||
|
if updated:
|
||||||
|
if getattr(updated, "tzinfo", None) is None:
|
||||||
|
updated = updated.replace(tzinfo=timezone.utc)
|
||||||
|
return (datetime.now(timezone.utc) - updated).total_seconds()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _fail_stuck_simple_start(self, conn, item, printer):
|
||||||
|
host = printer["host"]
|
||||||
|
store.update_print_queue(
|
||||||
|
conn,
|
||||||
|
item["id"],
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "Start abgebrochen (Neustart)",
|
||||||
|
"phase": "",
|
||||||
|
"phase_started_at": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._event(
|
||||||
|
conn,
|
||||||
|
host,
|
||||||
|
printer["name"],
|
||||||
|
"queue",
|
||||||
|
"%s: Start abgebrochen (Neustart)" % printer["name"],
|
||||||
|
item.get("filename") or "",
|
||||||
|
)
|
||||||
|
|
||||||
def _spawn_worker(self, item_id, target, *args):
|
def _spawn_worker(self, item_id, target, *args):
|
||||||
with self._worker_lock:
|
with self._worker_lock:
|
||||||
existing = self._workers.get(item_id)
|
existing = self._workers.get(item_id)
|
||||||
@@ -129,6 +166,8 @@ class PrintQueueDispatcher:
|
|||||||
def run():
|
def run():
|
||||||
try:
|
try:
|
||||||
target(*args)
|
target(*args)
|
||||||
|
except Exception as exc:
|
||||||
|
print("Queue-Worker %s: %s" % (item_id, exc), flush=True)
|
||||||
finally:
|
finally:
|
||||||
with self._worker_lock:
|
with self._worker_lock:
|
||||||
self._workers.pop(item_id, None)
|
self._workers.pop(item_id, None)
|
||||||
@@ -190,7 +229,16 @@ class PrintQueueDispatcher:
|
|||||||
continue
|
continue
|
||||||
if state not in FREE:
|
if state not in FREE:
|
||||||
continue
|
continue
|
||||||
if store.running_print_on_host(conn, host):
|
running = store.running_print_on_host(conn, host)
|
||||||
|
if running:
|
||||||
|
if (
|
||||||
|
not _has_ablauf(running)
|
||||||
|
and (running.get("phase") or "").lower() == "starting"
|
||||||
|
and not self._worker_busy(running["id"])
|
||||||
|
and self._item_phase_age_s(running) > SIMPLE_START_STUCK_S
|
||||||
|
):
|
||||||
|
self._fail_stuck_simple_start(conn, running, printer)
|
||||||
|
conn.commit()
|
||||||
continue
|
continue
|
||||||
if store.bed_needs_clear(conn, host):
|
if store.bed_needs_clear(conn, host):
|
||||||
continue
|
continue
|
||||||
@@ -276,6 +324,7 @@ class PrintQueueDispatcher:
|
|||||||
)
|
)
|
||||||
wconn.commit()
|
wconn.commit()
|
||||||
except GrottiError as exc:
|
except GrottiError as exc:
|
||||||
|
wconn.rollback()
|
||||||
store.update_print_queue(
|
store.update_print_queue(
|
||||||
wconn,
|
wconn,
|
||||||
item_id,
|
item_id,
|
||||||
@@ -291,6 +340,7 @@ class PrintQueueDispatcher:
|
|||||||
)
|
)
|
||||||
wconn.commit()
|
wconn.commit()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
wconn.rollback()
|
||||||
store.update_print_queue(
|
store.update_print_queue(
|
||||||
wconn,
|
wconn,
|
||||||
item_id,
|
item_id,
|
||||||
@@ -684,10 +734,12 @@ class PrintQueueDispatcher:
|
|||||||
self._step_event(wconn, row, printer_copy, "druckt %s" % name)
|
self._step_event(wconn, row, printer_copy, "druckt %s" % name)
|
||||||
wconn.commit()
|
wconn.commit()
|
||||||
except GrottiError as exc:
|
except GrottiError as exc:
|
||||||
|
wconn.rollback()
|
||||||
row = store.get_print_queue_row(wconn, item_id) or item
|
row = store.get_print_queue_row(wconn, item_id) or item
|
||||||
self._fail_ablauf(wconn, row, printer_copy, exc.message)
|
self._fail_ablauf(wconn, row, printer_copy, exc.message)
|
||||||
wconn.commit()
|
wconn.commit()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
wconn.rollback()
|
||||||
row = store.get_print_queue_row(wconn, item_id) or item
|
row = store.get_print_queue_row(wconn, item_id) or item
|
||||||
self._fail_ablauf(wconn, row, printer_copy, str(exc) or "Druckstart fehlgeschlagen.")
|
self._fail_ablauf(wconn, row, printer_copy, str(exc) or "Druckstart fehlgeschlagen.")
|
||||||
wconn.commit()
|
wconn.commit()
|
||||||
@@ -742,6 +794,7 @@ class PrintQueueDispatcher:
|
|||||||
return state
|
return state
|
||||||
|
|
||||||
def _start_cool_step(self, conn, item, printer, step):
|
def _start_cool_step(self, conn, item, printer, step):
|
||||||
|
# TODO: _try_cool_park (bis 60 s G-code) blockiert tick() — ggf. in Worker auslagern.
|
||||||
host = printer["host"]
|
host = printer["host"]
|
||||||
cool = eject.normalize_cool(step)
|
cool = eject.normalize_cool(step)
|
||||||
try:
|
try:
|
||||||
@@ -908,14 +961,17 @@ class PrintQueueDispatcher:
|
|||||||
self._next_step(wconn, row, printer_copy, {"state": "standby", "ok": True})
|
self._next_step(wconn, row, printer_copy, {"state": "standby", "ok": True})
|
||||||
wconn.commit()
|
wconn.commit()
|
||||||
except GrottiError as exc:
|
except GrottiError as exc:
|
||||||
|
wconn.rollback()
|
||||||
row = store.get_print_queue_row(wconn, item_id) or item
|
row = store.get_print_queue_row(wconn, item_id) or item
|
||||||
self._fail_ablauf(wconn, row, printer_copy, exc.message)
|
self._fail_ablauf(wconn, row, printer_copy, exc.message)
|
||||||
wconn.commit()
|
wconn.commit()
|
||||||
except moonraker.PrinterError as exc:
|
except moonraker.PrinterError as exc:
|
||||||
|
wconn.rollback()
|
||||||
row = store.get_print_queue_row(wconn, item_id) or item
|
row = store.get_print_queue_row(wconn, item_id) or item
|
||||||
self._fail_ablauf(wconn, row, printer_copy, eject.explain_printer_error(exc.message))
|
self._fail_ablauf(wconn, row, printer_copy, eject.explain_printer_error(exc.message))
|
||||||
wconn.commit()
|
wconn.commit()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
wconn.rollback()
|
||||||
row = store.get_print_queue_row(wconn, item_id) or item
|
row = store.get_print_queue_row(wconn, item_id) or item
|
||||||
self._fail_ablauf(wconn, row, printer_copy, "Schieben: %s" % exc)
|
self._fail_ablauf(wconn, row, printer_copy, "Schieben: %s" % exc)
|
||||||
wconn.commit()
|
wconn.commit()
|
||||||
@@ -1013,14 +1069,17 @@ class PrintQueueDispatcher:
|
|||||||
self._next_step(wconn, row, printer_copy, {"state": "standby", "ok": True})
|
self._next_step(wconn, row, printer_copy, {"state": "standby", "ok": True})
|
||||||
wconn.commit()
|
wconn.commit()
|
||||||
except GrottiError as exc:
|
except GrottiError as exc:
|
||||||
|
wconn.rollback()
|
||||||
row = store.get_print_queue_row(wconn, item_id) or item
|
row = store.get_print_queue_row(wconn, item_id) or item
|
||||||
self._fail_ablauf(wconn, row, printer_copy, exc.message)
|
self._fail_ablauf(wconn, row, printer_copy, exc.message)
|
||||||
wconn.commit()
|
wconn.commit()
|
||||||
except moonraker.PrinterError as exc:
|
except moonraker.PrinterError as exc:
|
||||||
|
wconn.rollback()
|
||||||
row = store.get_print_queue_row(wconn, item_id) or item
|
row = store.get_print_queue_row(wconn, item_id) or item
|
||||||
self._fail_ablauf(wconn, row, printer_copy, eject.explain_printer_error(exc.message))
|
self._fail_ablauf(wconn, row, printer_copy, eject.explain_printer_error(exc.message))
|
||||||
wconn.commit()
|
wconn.commit()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
wconn.rollback()
|
||||||
row = store.get_print_queue_row(wconn, item_id) or item
|
row = store.get_print_queue_row(wconn, item_id) or item
|
||||||
self._fail_ablauf(wconn, row, printer_copy, "Tasten: %s" % exc)
|
self._fail_ablauf(wconn, row, printer_copy, "Tasten: %s" % exc)
|
||||||
wconn.commit()
|
wconn.commit()
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
-- Referenz-Schema. Laufende Migrationen liegen in grotti/migrations/*.sql
|
||||||
|
-- (001_base.sql entspricht diesem Stand, danach nummerierte Änderungen).
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS projects (
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
name text NOT NULL,
|
name text NOT NULL,
|
||||||
|
|||||||
+26
-4
@@ -17,12 +17,19 @@ class SendQueue:
|
|||||||
def __init__(self, database_url, cfg, workers=4):
|
def __init__(self, database_url, cfg, workers=4):
|
||||||
self.database_url = database_url
|
self.database_url = database_url
|
||||||
self.cfg = cfg
|
self.cfg = cfg
|
||||||
|
self._workers_n = max(1, int(workers))
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._pending = Queue()
|
self._pending = Queue()
|
||||||
self._jobs = {}
|
self._jobs = {}
|
||||||
self._order = deque(maxlen=60)
|
self._order = deque(maxlen=60)
|
||||||
self._threads = []
|
self._threads = []
|
||||||
for index in range(max(1, int(workers))):
|
self._stop = threading.Event()
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
if self._threads:
|
||||||
|
return
|
||||||
|
self._stop.clear()
|
||||||
|
for index in range(self._workers_n):
|
||||||
thread = threading.Thread(
|
thread = threading.Thread(
|
||||||
target=self._worker,
|
target=self._worker,
|
||||||
name="grotti-send-queue-%s" % (index + 1),
|
name="grotti-send-queue-%s" % (index + 1),
|
||||||
@@ -31,6 +38,14 @@ class SendQueue:
|
|||||||
thread.start()
|
thread.start()
|
||||||
self._threads.append(thread)
|
self._threads.append(thread)
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._stop.set()
|
||||||
|
for _ in self._threads:
|
||||||
|
try:
|
||||||
|
self._pending.put_nowait(None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def enqueue(
|
def enqueue(
|
||||||
self,
|
self,
|
||||||
conn,
|
conn,
|
||||||
@@ -82,9 +97,14 @@ class SendQueue:
|
|||||||
want_level = bool(bed_leveling)
|
want_level = bool(bed_leveling)
|
||||||
created = []
|
created = []
|
||||||
for host in unique:
|
for host in unique:
|
||||||
printer, row, chosen = jobs.prepare_send(conn, file_id, host, True, override, spool_id)
|
# Mapping zuerst, damit Materialprüfung die ACE-Spulen sieht.
|
||||||
|
mapping = ace_maps.get(host) or []
|
||||||
|
printer, row, chosen = jobs.prepare_send(
|
||||||
|
conn, file_id, host, True, override, spool_id, ace_map=mapping
|
||||||
|
)
|
||||||
|
if not mapping:
|
||||||
|
mapping = ace_maps.get(printer["name"]) or []
|
||||||
job_id = uuid.uuid4().hex[:12]
|
job_id = uuid.uuid4().hex[:12]
|
||||||
mapping = ace_maps.get(host) or ace_maps.get(printer["name"]) or []
|
|
||||||
job = {
|
job = {
|
||||||
"id": job_id,
|
"id": job_id,
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
@@ -143,11 +163,13 @@ class SendQueue:
|
|||||||
job["updated_at"] = time.time()
|
job["updated_at"] = time.time()
|
||||||
|
|
||||||
def _worker(self):
|
def _worker(self):
|
||||||
while True:
|
while not self._stop.is_set():
|
||||||
try:
|
try:
|
||||||
item = self._pending.get(timeout=1)
|
item = self._pending.get(timeout=1)
|
||||||
except Empty:
|
except Empty:
|
||||||
continue
|
continue
|
||||||
|
if item is None:
|
||||||
|
break
|
||||||
self._run(item)
|
self._run(item)
|
||||||
|
|
||||||
def _run(self, item):
|
def _run(self, item):
|
||||||
|
|||||||
+42
-2
@@ -100,6 +100,8 @@ def spool_out(row):
|
|||||||
|
|
||||||
def next_chargen(conn):
|
def next_chargen(conn):
|
||||||
prefix = "S-%s-" % date.today().strftime("%Y%m%d")
|
prefix = "S-%s-" % date.today().strftime("%Y%m%d")
|
||||||
|
# Advisory-Lock gegen Doppelvergabe bei parallelen Worker/Requests.
|
||||||
|
conn.execute("SELECT pg_advisory_xact_lock(%s)", (870011,))
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT chargen_nr FROM spools
|
SELECT chargen_nr FROM spools
|
||||||
@@ -544,6 +546,28 @@ def seed_materials(conn):
|
|||||||
|
|
||||||
|
|
||||||
def delete_spool(conn, spool_id):
|
def delete_spool(conn, spool_id):
|
||||||
|
used = conn.execute(
|
||||||
|
"SELECT 1 FROM run_spools WHERE spool_id = %s LIMIT 1",
|
||||||
|
(spool_id,),
|
||||||
|
).fetchone()
|
||||||
|
if used:
|
||||||
|
from grotti.errors import GrottiError
|
||||||
|
raise GrottiError(
|
||||||
|
"Die Spule hat Läufe — erst archivieren oder behalten.",
|
||||||
|
"spule",
|
||||||
|
409,
|
||||||
|
)
|
||||||
|
seg = conn.execute(
|
||||||
|
"SELECT 1 FROM run_segments WHERE spool_id = %s LIMIT 1",
|
||||||
|
(spool_id,),
|
||||||
|
).fetchone()
|
||||||
|
if seg:
|
||||||
|
from grotti.errors import GrottiError
|
||||||
|
raise GrottiError(
|
||||||
|
"Die Spule steckt in Abschnitten — nicht löschen.",
|
||||||
|
"spule",
|
||||||
|
409,
|
||||||
|
)
|
||||||
row = conn.execute("DELETE FROM spools WHERE id = %s RETURNING id", (spool_id,)).fetchone()
|
row = conn.execute("DELETE FROM spools WHERE id = %s RETURNING id", (spool_id,)).fetchone()
|
||||||
return bool(row)
|
return bool(row)
|
||||||
|
|
||||||
@@ -555,6 +579,20 @@ def spools_for_printer(conn, host):
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def spool_at_slot(conn, host, slot):
|
||||||
|
"""Eine Spule an Host + ACE-Platz (1-basiert) oder None."""
|
||||||
|
if not host or slot is None:
|
||||||
|
return None
|
||||||
|
return conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM spools
|
||||||
|
WHERE printer_host = %s AND ace_slot = %s
|
||||||
|
ORDER BY id LIMIT 1
|
||||||
|
""",
|
||||||
|
(host, int(slot)),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
def file_by_name(conn, filename):
|
def file_by_name(conn, filename):
|
||||||
base = (filename or "").replace("\\", "/").rsplit("/", 1)[-1]
|
base = (filename or "").replace("\\", "/").rsplit("/", 1)[-1]
|
||||||
if not base:
|
if not base:
|
||||||
@@ -871,10 +909,11 @@ def assign_run_spool(conn, run_id, spool_id):
|
|||||||
|
|
||||||
def next_druck_chargen(conn):
|
def next_druck_chargen(conn):
|
||||||
"""Nächste Druck-Charge des Tages. Objekt-Etiketten (`D-…-01`) blockieren die Nummer."""
|
"""Nächste Druck-Charge des Tages. Objekt-Etiketten (`D-…-01`) blockieren die Nummer."""
|
||||||
day = datetime.now(timezone.utc).strftime("%Y%m%d")
|
day = date.today().strftime("%Y%m%d")
|
||||||
prefix = "D-%s-" % day
|
prefix = "D-%s-" % day
|
||||||
batch_re = "^D-%s-[0-9]{4}$" % day
|
batch_re = "^D-%s-[0-9]{4}$" % day
|
||||||
object_re = "^D-%s-[0-9]{4}-[0-9]{2}$" % day
|
object_re = "^D-%s-[0-9]{4}-[0-9]{2}$" % day
|
||||||
|
conn.execute("SELECT pg_advisory_xact_lock(%s)", (870012,))
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT n FROM (
|
SELECT n FROM (
|
||||||
@@ -2531,7 +2570,8 @@ def ensure_bed_after_print(conn, host, ended_at=None, message=""):
|
|||||||
|
|
||||||
|
|
||||||
def user_count(conn):
|
def user_count(conn):
|
||||||
return int(conn.execute("SELECT count(*) AS n FROM users").fetchone()["n"])
|
from grotti.db import cached_user_count
|
||||||
|
return cached_user_count(conn)
|
||||||
|
|
||||||
|
|
||||||
def list_users(conn):
|
def list_users(conn):
|
||||||
|
|||||||
+15
-8
@@ -362,22 +362,29 @@ class StateWatcher:
|
|||||||
if need <= 0 or not filename:
|
if need <= 0 or not filename:
|
||||||
return False
|
return False
|
||||||
sent = store.latest_send(conn, host, filename)
|
sent = store.latest_send(conn, host, filename)
|
||||||
spool_id = (sent or {}).get("spool_id")
|
ace = (sent or {}).get("ace_map") or []
|
||||||
if not spool_id:
|
from grotti import jobs as grotti_jobs
|
||||||
|
|
||||||
|
at_host = grotti_jobs.resolve_spools_at_host(conn, host, ace)
|
||||||
|
if not at_host:
|
||||||
|
spool_id = (sent or {}).get("spool_id")
|
||||||
|
if spool_id:
|
||||||
|
row = store.get_spool_row(conn, spool_id)
|
||||||
|
if row:
|
||||||
|
at_host = [row]
|
||||||
|
if not at_host:
|
||||||
return False
|
return False
|
||||||
spool = store.get_spool_row(conn, spool_id)
|
left = sum(float(s.get("remaining_g") or 0) for s in at_host)
|
||||||
if not spool:
|
|
||||||
return False
|
|
||||||
left = float(spool["remaining_g"] or 0)
|
|
||||||
if left + 0.5 >= need:
|
if left + 0.5 >= need:
|
||||||
return False
|
return False
|
||||||
|
label = " / ".join(s.get("name") or "?" for s in at_host[:3])
|
||||||
self._emit(
|
self._emit(
|
||||||
conn,
|
conn,
|
||||||
host,
|
host,
|
||||||
name,
|
name,
|
||||||
"spule",
|
"spule",
|
||||||
"%s: Spule „%s“ hat nur %.0f g, Druck braucht %.0f g."
|
"%s: %s hat nur %.0f g, Druck braucht %.0f g."
|
||||||
% (name, spool["name"], left, need),
|
% (name, label, left, need),
|
||||||
filename,
|
filename,
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
"""Grotti starten. Kern und Seiten liegen daneben."""
|
"""Grotti starten. Kern und Seiten liegen daneben."""
|
||||||
|
|
||||||
|
import atexit
|
||||||
|
import signal
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
from grotti import jobs, library, store
|
from waitress import serve
|
||||||
|
|
||||||
|
from grotti import db, jobs, library, store
|
||||||
from grotti.config import load
|
from grotti.config import load
|
||||||
from grotti.db import connect, prepare
|
|
||||||
from web.app import create_app
|
from web.app import create_app
|
||||||
|
|
||||||
|
|
||||||
def background(cfg):
|
def background(cfg):
|
||||||
def work():
|
def work():
|
||||||
try:
|
try:
|
||||||
with connect(cfg["database_url"]) as conn:
|
with db.connect(cfg["database_url"]) as conn:
|
||||||
library.sync_watch(conn, cfg, force=False)
|
library.sync_watch(conn, cfg, force=False)
|
||||||
jobs.sync_history(conn, cfg)
|
jobs.sync_history(conn, cfg)
|
||||||
|
deleted = db.prune_run_samples(conn, keep_days=90)
|
||||||
|
if deleted:
|
||||||
|
print("Proben gelöscht: %s" % deleted, flush=True)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print("Abgleich: %s" % exc, flush=True)
|
print("Abgleich: %s" % exc, flush=True)
|
||||||
@@ -21,19 +28,79 @@ def background(cfg):
|
|||||||
threading.Thread(target=work, name="grotti-abgleich", daemon=True).start()
|
threading.Thread(target=work, name="grotti-abgleich", daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def _retention_loop(cfg, stop_event):
|
||||||
|
"""Nächtlich alte run_samples entfernen."""
|
||||||
|
while not stop_event.wait(3600):
|
||||||
|
hour = time.localtime().tm_hour
|
||||||
|
if hour != 3:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with db.connect(cfg["database_url"]) as conn:
|
||||||
|
n = db.prune_run_samples(conn, keep_days=90)
|
||||||
|
conn.commit()
|
||||||
|
if n:
|
||||||
|
print("Retention: %s Proben gelöscht." % n, flush=True)
|
||||||
|
except Exception as exc:
|
||||||
|
print("Retention: %s" % exc, flush=True)
|
||||||
|
stop_event.wait(3600)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
cfg = load()
|
cfg = load()
|
||||||
cfg["data_dir"].mkdir(parents=True, exist_ok=True)
|
cfg["data_dir"].mkdir(parents=True, exist_ok=True)
|
||||||
(cfg["data_dir"] / "eingang").mkdir(exist_ok=True)
|
(cfg["data_dir"] / "eingang").mkdir(exist_ok=True)
|
||||||
prepare(cfg["database_url"])
|
db.prepare(cfg["database_url"])
|
||||||
with connect(cfg["database_url"]) as conn:
|
db.init_pool(cfg["database_url"], min_size=2, max_size=16)
|
||||||
|
with db.connect(cfg["database_url"]) as conn:
|
||||||
store.seed_printers(conn, cfg["printers_file"])
|
store.seed_printers(conn, cfg["printers_file"])
|
||||||
store.seed_materials(conn)
|
store.seed_materials(conn)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
app = create_app(cfg)
|
app = create_app(cfg)
|
||||||
|
watcher = app.extensions["watcher"]
|
||||||
|
dispatcher = app.extensions["print_queue"]
|
||||||
|
send_queue = app.extensions["send_queue"]
|
||||||
|
watcher.start()
|
||||||
|
dispatcher.start()
|
||||||
|
if hasattr(send_queue, "start"):
|
||||||
|
send_queue.start()
|
||||||
|
|
||||||
|
stop_event = threading.Event()
|
||||||
|
retention = threading.Thread(
|
||||||
|
target=_retention_loop,
|
||||||
|
args=(cfg, stop_event),
|
||||||
|
name="grotti-retention",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
retention.start()
|
||||||
background(cfg)
|
background(cfg)
|
||||||
|
|
||||||
|
def shutdown(*_args):
|
||||||
|
stop_event.set()
|
||||||
|
try:
|
||||||
|
watcher.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
dispatcher.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
if hasattr(send_queue, "stop"):
|
||||||
|
send_queue.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
db.close_pool()
|
||||||
|
|
||||||
|
atexit.register(shutdown)
|
||||||
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||||
|
try:
|
||||||
|
signal.signal(sig, lambda *_: shutdown())
|
||||||
|
except (ValueError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
print("Grotti: http://%s:%s/" % (cfg["host"], cfg["port"]), flush=True)
|
print("Grotti: http://%s:%s/" % (cfg["host"], cfg["port"]), flush=True)
|
||||||
app.run(host=cfg["host"], port=cfg["port"], threaded=True, use_reloader=False)
|
serve(app, host=cfg["host"], port=cfg["port"], threads=16, channel_timeout=120)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Unit-Tests für Spulenwahl und Steuer-Parameter-Aliase."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from grotti import jobs
|
||||||
|
from grotti import moonraker
|
||||||
|
|
||||||
|
|
||||||
|
class ChooseSpoolTests(unittest.TestCase):
|
||||||
|
def test_rejects_spool_created_after_print_end(self):
|
||||||
|
end = datetime(2026, 10, 1, 12, 0, tzinfo=timezone.utc)
|
||||||
|
old = {
|
||||||
|
"id": 1,
|
||||||
|
"material_type": "PLA",
|
||||||
|
"created_at": end - timedelta(days=2),
|
||||||
|
}
|
||||||
|
new = {
|
||||||
|
"id": 2,
|
||||||
|
"material_type": "PLA",
|
||||||
|
"created_at": end + timedelta(hours=1),
|
||||||
|
}
|
||||||
|
conn = MagicMock()
|
||||||
|
with patch("grotti.jobs.store.spools_for_printer", return_value=[new]):
|
||||||
|
with patch("grotti.jobs.store.get_spool_row", return_value=new):
|
||||||
|
self.assertIsNone(jobs.choose_spool(conn, "h", "PLA", 2, ended_at=end))
|
||||||
|
with patch("grotti.jobs.store.spools_for_printer", return_value=[old]):
|
||||||
|
self.assertEqual(jobs.choose_spool(conn, "h", "PLA", None, ended_at=end)["id"], 1)
|
||||||
|
|
||||||
|
def test_spool_valid_for_end_naive(self):
|
||||||
|
end = datetime(2026, 10, 1, 12, 0, tzinfo=timezone.utc)
|
||||||
|
spool = {"created_at": datetime(2026, 9, 1, 12, 0)}
|
||||||
|
self.assertTrue(jobs._spool_valid_for_end(spool, end))
|
||||||
|
|
||||||
|
|
||||||
|
class SteuernAliasTests(unittest.TestCase):
|
||||||
|
@patch("grotti.moonraker.gcode_script", return_value={"ok": True})
|
||||||
|
def test_value_alias_for_temp(self, gcode):
|
||||||
|
moonraker.steuern("10.0.0.1", "nozzle", {"value": 210})
|
||||||
|
script = gcode.call_args[0][1]
|
||||||
|
self.assertIn("TARGET=210.0", script)
|
||||||
|
|
||||||
|
@patch("grotti.moonraker.gcode_script", return_value={"ok": True})
|
||||||
|
def test_percent_alias_for_speed(self, gcode):
|
||||||
|
moonraker.steuern("10.0.0.1", "speed", {"value": 120})
|
||||||
|
script = gcode.call_args[0][1]
|
||||||
|
self.assertIn("M220 S120", script)
|
||||||
|
|
||||||
|
|
||||||
|
class HostValidationTests(unittest.TestCase):
|
||||||
|
def test_validate_printer_host(self):
|
||||||
|
from web.app import validate_printer_host
|
||||||
|
from grotti.errors import GrottiError
|
||||||
|
|
||||||
|
self.assertEqual(validate_printer_host("10.10.1.60"), "10.10.1.60")
|
||||||
|
self.assertEqual(validate_printer_host("kobra.local:80"), "kobra.local:80")
|
||||||
|
with self.assertRaises(GrottiError):
|
||||||
|
validate_printer_host("http://evil")
|
||||||
|
with self.assertRaises(GrottiError):
|
||||||
|
validate_printer_host("../etc/passwd")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Pfad- und Dateiname-Grenzen."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from grotti import library
|
||||||
|
from grotti.errors import GrottiError
|
||||||
|
|
||||||
|
|
||||||
|
class SafeFilenameTests(unittest.TestCase):
|
||||||
|
def test_rejects_dotdot(self):
|
||||||
|
self.assertEqual(library.safe_filename(".."), "datei.gcode")
|
||||||
|
self.assertEqual(library.safe_filename("../x.gcode"), "x.gcode")
|
||||||
|
|
||||||
|
def test_normalize_folder_blocks_escape(self):
|
||||||
|
with self.assertRaises(GrottiError):
|
||||||
|
library.normalize_folder("../secret")
|
||||||
|
self.assertEqual(library.normalize_folder("Gridfinity/A"), "Gridfinity/A")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+93
-19
@@ -14,9 +14,11 @@ import psycopg
|
|||||||
from flask import Flask, abort, g, jsonify, redirect, render_template, request, session, url_for
|
from flask import Flask, abort, g, jsonify, redirect, render_template, request, session, url_for
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
from psycopg.rows import dict_row
|
from psycopg.rows import dict_row
|
||||||
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
|
|
||||||
from grotti import auth as grotti_auth
|
from grotti import auth as grotti_auth
|
||||||
from grotti import config as grotti_config
|
from grotti import config as grotti_config
|
||||||
|
from grotti import db as grotti_db
|
||||||
from grotti import eject, jobs, library, moonraker, notify, store
|
from grotti import eject, jobs, library, moonraker, notify, store
|
||||||
from grotti.catalog import color_catalog
|
from grotti.catalog import color_catalog
|
||||||
from grotti.cost import calculate
|
from grotti.cost import calculate
|
||||||
@@ -35,12 +37,30 @@ PROJECT_STATUS = {"idee", "bereit", "druckt", "fertig"}
|
|||||||
VERDICTS = {"", "gut", "ausschuss"}
|
VERDICTS = {"", "gut", "ausschuss"}
|
||||||
IDEA_STATUS = {"offen", "spaeter", "erledigt"}
|
IDEA_STATUS = {"offen", "spaeter", "erledigt"}
|
||||||
PRINTER_FAMILIES = set(FAMILY_LABELS) | {""}
|
PRINTER_FAMILIES = set(FAMILY_LABELS) | {""}
|
||||||
|
UPLOAD_SUFFIXES = (".gcode", ".3mf", ".gcode.3mf")
|
||||||
|
HOST_RE = re.compile(r"^[A-Za-z0-9.\-]+(?::\d{1,5})?$")
|
||||||
|
TOKEN_QUERY_PATHS = (
|
||||||
|
"/api/v1/kamera",
|
||||||
|
"/api/v1/vorschau",
|
||||||
|
)
|
||||||
STEER_ACTIONS = {
|
STEER_ACTIONS = {
|
||||||
"nozzle", "bed", "speed", "flow", "fan", "emergency_stop", "clear_error",
|
"nozzle", "bed", "speed", "flow", "fan", "emergency_stop", "clear_error",
|
||||||
"exclude_object", "dryer_start", "dryer_stop", "slot_setzen",
|
"exclude_object", "dryer_start", "dryer_stop", "slot_setzen",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_printer_host(host):
|
||||||
|
host = (host or "").strip()
|
||||||
|
if not host or not HOST_RE.fullmatch(host):
|
||||||
|
raise GrottiError(
|
||||||
|
"Host nur als Hostname oder IP, optional mit Port (z. B. 10.10.1.60).",
|
||||||
|
"host",
|
||||||
|
)
|
||||||
|
if ".." in host:
|
||||||
|
raise GrottiError("Ungültiger Host.", "host")
|
||||||
|
return host
|
||||||
|
|
||||||
|
|
||||||
def create_app(cfg):
|
def create_app(cfg):
|
||||||
app = Flask(
|
app = Flask(
|
||||||
__name__,
|
__name__,
|
||||||
@@ -56,13 +76,14 @@ def create_app(cfg):
|
|||||||
if cfg.get("fqdn", "").startswith("https://"):
|
if cfg.get("fqdn", "").startswith("https://"):
|
||||||
app.config["SESSION_COOKIE_SECURE"] = True
|
app.config["SESSION_COOKIE_SECURE"] = True
|
||||||
app.json.ensure_ascii = False
|
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)
|
app.extensions["send_queue"] = SendQueue(cfg["database_url"], cfg)
|
||||||
watcher = StateWatcher(cfg["database_url"], cfg)
|
watcher = StateWatcher(cfg["database_url"], cfg)
|
||||||
dispatcher = PrintQueueDispatcher(cfg["database_url"], cfg, watcher=watcher)
|
dispatcher = PrintQueueDispatcher(cfg["database_url"], cfg, watcher=watcher)
|
||||||
app.extensions["print_queue"] = dispatcher
|
app.extensions["print_queue"] = dispatcher
|
||||||
app.extensions["watcher"] = watcher
|
app.extensions["watcher"] = watcher
|
||||||
watcher.start()
|
|
||||||
dispatcher.start()
|
|
||||||
|
|
||||||
PUBLIC_PATHS = {
|
PUBLIC_PATHS = {
|
||||||
"/login",
|
"/login",
|
||||||
@@ -86,7 +107,13 @@ def create_app(cfg):
|
|||||||
|
|
||||||
def conn():
|
def conn():
|
||||||
if "db" not in g:
|
if "db" not in g:
|
||||||
g.db = psycopg.connect(cfg["database_url"], row_factory=dict_row)
|
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
|
return g.db
|
||||||
|
|
||||||
def ensure_csrf():
|
def ensure_csrf():
|
||||||
@@ -176,7 +203,11 @@ def create_app(cfg):
|
|||||||
header = request.headers.get("Authorization", "")
|
header = request.headers.get("Authorization", "")
|
||||||
if header.startswith("Bearer "):
|
if header.startswith("Bearer "):
|
||||||
presented = header[7:].strip()
|
presented = header[7:].strip()
|
||||||
elif request.method == "GET" and request.path.startswith("/api/"):
|
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()
|
presented = (request.args.get("token") or "").strip()
|
||||||
|
|
||||||
user = None
|
user = None
|
||||||
@@ -233,24 +264,60 @@ def create_app(cfg):
|
|||||||
|
|
||||||
@app.teardown_appcontext
|
@app.teardown_appcontext
|
||||||
def close_db(exc):
|
def close_db(exc):
|
||||||
conn = g.pop("db", None)
|
db_conn = g.pop("db", None)
|
||||||
if not conn:
|
from_pool = g.pop("db_from_pool", False)
|
||||||
|
if not db_conn:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
if exc:
|
if exc:
|
||||||
conn.rollback()
|
db_conn.rollback()
|
||||||
else:
|
else:
|
||||||
conn.commit()
|
db_conn.commit()
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
db_conn.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
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)
|
@app.errorhandler(GrottiError)
|
||||||
def grotti_error(err):
|
def grotti_error(err):
|
||||||
db = g.get("db")
|
db_conn = g.get("db")
|
||||||
if db:
|
if db_conn:
|
||||||
db.rollback()
|
try:
|
||||||
|
db_conn.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return jsonify({"error": err.message, "code": err.code}), err.status
|
return jsonify({"error": err.message, "code": err.code}), err.status
|
||||||
|
|
||||||
|
@app.errorhandler(psycopg.Error)
|
||||||
|
def psycopg_error(err):
|
||||||
|
db_conn = g.get("db")
|
||||||
|
if db_conn:
|
||||||
|
try:
|
||||||
|
db_conn.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
message = "Datenbankfehler."
|
||||||
|
if isinstance(err, psycopg.errors.ForeignKeyViolation):
|
||||||
|
message = "Der Eintrag hängt noch an anderen Daten."
|
||||||
|
elif isinstance(err, psycopg.errors.UniqueViolation):
|
||||||
|
message = "Diesen Eintrag gibt es schon."
|
||||||
|
elif isinstance(err, psycopg.errors.InvalidTextRepresentation):
|
||||||
|
message = "Ungültiger Wert in der Anfrage."
|
||||||
|
print("DB: %s" % err, flush=True)
|
||||||
|
return jsonify({"error": message, "code": "db"}), 500
|
||||||
|
|
||||||
def body():
|
def body():
|
||||||
data = request.get_json(silent=True)
|
data = request.get_json(silent=True)
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
@@ -334,6 +401,7 @@ def create_app(cfg):
|
|||||||
username = grotti_auth.validate_username(data.get("username") or "")
|
username = grotti_auth.validate_username(data.get("username") or "")
|
||||||
password = grotti_auth.validate_password(data.get("password") or "")
|
password = grotti_auth.validate_password(data.get("password") or "")
|
||||||
row = store.create_user(conn(), username, grotti_auth.hash_password(password), "admin")
|
row = store.create_user(conn(), username, grotti_auth.hash_password(password), "admin")
|
||||||
|
grotti_db.invalidate_user_count()
|
||||||
store.touch_user_login(conn(), row["id"])
|
store.touch_user_login(conn(), row["id"])
|
||||||
session.clear()
|
session.clear()
|
||||||
session["user_id"] = row["id"]
|
session["user_id"] = row["id"]
|
||||||
@@ -392,6 +460,7 @@ def create_app(cfg):
|
|||||||
if store.get_user_by_name(conn(), username):
|
if store.get_user_by_name(conn(), username):
|
||||||
raise GrottiError("Benutzer gibt es schon.", "user", 409)
|
raise GrottiError("Benutzer gibt es schon.", "user", 409)
|
||||||
row = store.create_user(conn(), username, grotti_auth.hash_password(password), role)
|
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))
|
audit("auth", "Benutzer %s (%s) angelegt" % (username, role))
|
||||||
return jsonify(grotti_auth.public_user(row)), 201
|
return jsonify(grotti_auth.public_user(row)), 201
|
||||||
|
|
||||||
@@ -779,11 +848,9 @@ def create_app(cfg):
|
|||||||
def printer_create():
|
def printer_create():
|
||||||
data = body()
|
data = body()
|
||||||
name = (data.get("name") or "").strip()
|
name = (data.get("name") or "").strip()
|
||||||
host = (data.get("host") or "").strip()
|
host = validate_printer_host(data.get("host") or "")
|
||||||
if not name:
|
if not name:
|
||||||
raise GrottiError("Der Drucker braucht einen Namen.", "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)
|
family = (data.get("family") or "").strip() or family_from_name(name)
|
||||||
if family not in PRINTER_FAMILIES:
|
if family not in PRINTER_FAMILIES:
|
||||||
raise GrottiError("Unbekannte Druckerfamilie.", "family")
|
raise GrottiError("Unbekannte Druckerfamilie.", "family")
|
||||||
@@ -799,14 +866,12 @@ def create_app(cfg):
|
|||||||
raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404)
|
raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404)
|
||||||
name = (data.get("name") if "name" in data else current["name"]) or ""
|
name = (data.get("name") if "name" in data else current["name"]) or ""
|
||||||
name = str(name).strip()
|
name = str(name).strip()
|
||||||
host = (data.get("host") if "host" in data else current["host"]) or ""
|
host_raw = (data.get("host") if "host" in data else current["host"]) or ""
|
||||||
host = str(host).strip()
|
host = validate_printer_host(str(host_raw).strip())
|
||||||
family = data.get("family") if "family" in data else current["family"]
|
family = data.get("family") if "family" in data else current["family"]
|
||||||
family = (family or "").strip() or family_from_name(name)
|
family = (family or "").strip() or family_from_name(name)
|
||||||
if not name:
|
if not name:
|
||||||
raise GrottiError("Der Drucker braucht einen Namen.", "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:
|
if family not in PRINTER_FAMILIES:
|
||||||
raise GrottiError("Unbekannte Druckerfamilie.", "family")
|
raise GrottiError("Unbekannte Druckerfamilie.", "family")
|
||||||
other = store.printer_by_host(conn(), host)
|
other = store.printer_by_host(conn(), host)
|
||||||
@@ -957,6 +1022,15 @@ def create_app(cfg):
|
|||||||
upload = request.files.get("file")
|
upload = request.files.get("file")
|
||||||
if not upload or not upload.filename:
|
if not upload or not upload.filename:
|
||||||
raise GrottiError("Keine Datei angehängt.", "datei")
|
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"))
|
project_id = optional_id(request.form.get("project_id"))
|
||||||
saved = library.save_upload(conn(), upload.filename, upload.read(), cfg, project_id)
|
saved = library.save_upload(conn(), upload.filename, upload.read(), cfg, project_id)
|
||||||
return jsonify(saved), 201
|
return jsonify(saved), 201
|
||||||
|
|||||||
Reference in new issue
Block a user