3312 lines
110 KiB
Python
3312 lines
110 KiB
Python
"""Projekte, Dateien, Spulen und Läufe in PostgreSQL."""
|
||
|
||
import json
|
||
import uuid
|
||
from datetime import date, datetime, timedelta, timezone
|
||
from pathlib import Path
|
||
|
||
from psycopg.types.json import Json
|
||
|
||
from grotti.catalog import MACHINE_LABELS, MATERIALS
|
||
from grotti.printers import FAMILY_LABELS, family_from_name
|
||
def iso(value):
|
||
return value.isoformat() if value else None
|
||
|
||
|
||
def project_out(row, files=None, file_count=None):
|
||
data = {
|
||
"id": row["id"],
|
||
"name": row["name"],
|
||
"notes": row["notes"],
|
||
"status": row["status"],
|
||
"quote": row["quote"] or {},
|
||
"folder": (row.get("folder") or "").strip(),
|
||
"created_at": iso(row["created_at"]),
|
||
}
|
||
if files is not None:
|
||
data["files"] = files
|
||
if file_count is not None:
|
||
data["file_count"] = file_count
|
||
return data
|
||
|
||
|
||
def file_folder(stored_path, source, watch_dir=None):
|
||
"""Relativpfad des Elternordners unter watch_dir; Uploads → „Upload“."""
|
||
if (source or "") == "upload":
|
||
return "Upload"
|
||
if not stored_path or not watch_dir:
|
||
return ""
|
||
try:
|
||
rel = Path(stored_path).resolve().relative_to(Path(watch_dir).resolve())
|
||
except (ValueError, OSError):
|
||
return ""
|
||
parent = rel.parent.as_posix()
|
||
return "" if parent in (".", "") else parent
|
||
|
||
|
||
def file_out(row, watch_dir=None):
|
||
# stored_path bleibt intern (get_file_row / list_file_rows), nicht in der API.
|
||
from grotti.parse import path_has_ace_trailer
|
||
|
||
stored = row.get("stored_path") or ""
|
||
ace_trailer = False
|
||
if stored:
|
||
ace_trailer = path_has_ace_trailer(stored, kind=row.get("kind") or "", plate=row.get("plate") or "")
|
||
return {
|
||
"id": row["id"],
|
||
"project_id": row["project_id"],
|
||
"spool_id": row["spool_id"],
|
||
"name": row["name"],
|
||
"source": row["source"],
|
||
"kind": row["kind"],
|
||
"printer_family": row["printer_family"],
|
||
"filaments": row["filaments"] or [],
|
||
"weight_g": row["weight_g"],
|
||
"estimated_s": row["estimated_s"],
|
||
"plate": row["plate"],
|
||
"size_bytes": row["size_bytes"],
|
||
"mtime": row["mtime"],
|
||
"slice_meta": row.get("slice_meta") or {},
|
||
"ace_trailer": ace_trailer,
|
||
"folder": file_folder(stored, row.get("source"), watch_dir),
|
||
"preview": "/api/v1/dateien/%s/vorschau" % row["id"] if row["thumb_entry"] else "",
|
||
}
|
||
|
||
|
||
def spool_out(row):
|
||
colors = row.get("colors") or []
|
||
if isinstance(colors, str):
|
||
colors = json.loads(colors)
|
||
archived = row.get("archived_at")
|
||
return {
|
||
"id": row["id"],
|
||
"name": row["name"],
|
||
"brand": row["brand"],
|
||
"material_type": row["material_type"],
|
||
"color": row["color"],
|
||
"colors": colors,
|
||
"price_per_kg": row["price_per_kg"],
|
||
"density": row["density"],
|
||
"volumetric": row["volumetric"],
|
||
"remaining_g": row["remaining_g"],
|
||
"printer_host": row["printer_host"],
|
||
"ace_slot": row.get("ace_slot"),
|
||
"material_id": row.get("material_id"),
|
||
"chargen_nr": row.get("chargen_nr") or "",
|
||
"hersteller_charge": row.get("hersteller_charge") or "",
|
||
"code": row.get("code") or "",
|
||
"archived_at": archived.isoformat() if archived and hasattr(archived, "isoformat") else (archived or None),
|
||
"archived": bool(archived),
|
||
}
|
||
|
||
|
||
def next_chargen(conn):
|
||
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(
|
||
"""
|
||
SELECT chargen_nr FROM spools
|
||
WHERE chargen_nr LIKE %s
|
||
ORDER BY chargen_nr DESC
|
||
LIMIT 1
|
||
""",
|
||
(prefix + "%",),
|
||
).fetchone()
|
||
number = 1
|
||
if row and row["chargen_nr"]:
|
||
try:
|
||
number = int(str(row["chargen_nr"]).rsplit("-", 1)[-1]) + 1
|
||
except ValueError:
|
||
number = 1
|
||
return "%s%04d" % (prefix, number)
|
||
|
||
|
||
def ensure_spool_codes(conn):
|
||
rows = conn.execute(
|
||
"""
|
||
SELECT id, chargen_nr, code FROM spools
|
||
WHERE chargen_nr IS NULL OR chargen_nr = '' OR code IS NULL OR code = ''
|
||
"""
|
||
).fetchall()
|
||
for row in rows:
|
||
code = (row["chargen_nr"] or row["code"] or "").strip() or next_chargen(conn)
|
||
conn.execute(
|
||
"""
|
||
UPDATE spools
|
||
SET chargen_nr = COALESCE(NULLIF(chargen_nr, ''), %s),
|
||
code = COALESCE(NULLIF(code, ''), %s)
|
||
WHERE id = %s
|
||
""",
|
||
(code, code, row["id"]),
|
||
)
|
||
|
||
|
||
def run_out(row):
|
||
planned = float(row.get("weight_planned_g") or 0) or float(row.get("weight_g") or 0)
|
||
used = float(row.get("weight_used_g") or 0)
|
||
if used <= 0 and row.get("deducted"):
|
||
used = float(row.get("weight_g") or 0)
|
||
return {
|
||
"id": row["id"],
|
||
"printer_host": row["printer_host"],
|
||
"printer_name": row["printer_name"],
|
||
"filename": row["filename"],
|
||
"file_id": row["file_id"],
|
||
"project_id": row["project_id"],
|
||
"spool_id": row["spool_id"],
|
||
"state": row["state"],
|
||
"started_at": iso(row["started_at"]),
|
||
"ended_at": iso(row["ended_at"]),
|
||
"duration_s": row["duration_s"],
|
||
"weight_g": float(row.get("weight_g") or 0),
|
||
"weight_planned_g": planned,
|
||
"weight_used_g": used,
|
||
"weight_source": row.get("weight_source") or "",
|
||
"filament_used_mm": float(row.get("filament_used_mm") or 0),
|
||
"filament_total_mm": float(row.get("filament_total_mm") or 0),
|
||
"progress_last": float(row.get("progress_last") or 0),
|
||
"estimated_s": float(row.get("estimated_s") or 0),
|
||
"filament_cost": row["filament_cost"],
|
||
"energy_cost": row["energy_cost"],
|
||
"deducted": row["deducted"],
|
||
"needs_spool": row["needs_spool"],
|
||
"verdict": row["verdict"],
|
||
"verdict_reason": row.get("verdict_reason") or "",
|
||
"pieces_ok": int(row.get("pieces_ok") or 0),
|
||
"pieces_bad": int(row.get("pieces_bad") or 0),
|
||
"order_item_id": row.get("order_item_id"),
|
||
"material_type": row["material_type"],
|
||
"chargen_nr": row.get("chargen_nr") or "",
|
||
"message": row.get("message") or "",
|
||
"objects": _json_list(row.get("objects")),
|
||
"cost_per_part": (
|
||
(float(row.get("filament_cost") or 0) + float(row.get("energy_cost") or 0))
|
||
/ int(row["pieces_ok"])
|
||
if int(row.get("pieces_ok") or 0) > 0
|
||
else None
|
||
),
|
||
}
|
||
|
||
|
||
def list_projects(conn):
|
||
rows = conn.execute(
|
||
"""
|
||
SELECT p.*, COUNT(f.id) AS file_count
|
||
FROM projects p
|
||
LEFT JOIN files f ON f.project_id = p.id
|
||
GROUP BY p.id
|
||
ORDER BY p.created_at DESC
|
||
"""
|
||
).fetchall()
|
||
return [project_out(row, file_count=row["file_count"]) for row in rows]
|
||
|
||
|
||
def get_project(conn, project_id):
|
||
row = conn.execute("SELECT * FROM projects WHERE id = %s", (project_id,)).fetchone()
|
||
if not row:
|
||
return None
|
||
files = conn.execute(
|
||
"SELECT * FROM files WHERE project_id = %s ORDER BY mtime DESC, name",
|
||
(project_id,),
|
||
).fetchall()
|
||
return project_out(row, files=[file_out(item) for item in files])
|
||
|
||
|
||
def create_project(conn, name, notes, status, folder=""):
|
||
row = conn.execute(
|
||
"""
|
||
INSERT INTO projects (name, notes, status, folder)
|
||
VALUES (%s, %s, %s, %s)
|
||
RETURNING *
|
||
""",
|
||
(name, notes, status, (folder or "").strip()),
|
||
).fetchone()
|
||
return project_out(row, files=[], file_count=0)
|
||
|
||
|
||
def update_project(conn, project_id, fields):
|
||
sets = []
|
||
values = []
|
||
for key in ("name", "notes", "status", "folder"):
|
||
if key in fields:
|
||
sets.append("%s = %%s" % key)
|
||
values.append(fields[key] if key != "folder" else (fields[key] or "").strip())
|
||
if "quote" in fields:
|
||
sets.append("quote = %s")
|
||
values.append(Json(fields["quote"]))
|
||
if not sets:
|
||
return get_project(conn, project_id)
|
||
values.append(project_id)
|
||
row = conn.execute(
|
||
"UPDATE projects SET %s WHERE id = %%s RETURNING id" % ", ".join(sets),
|
||
values,
|
||
).fetchone()
|
||
if not row:
|
||
return None
|
||
return get_project(conn, project_id)
|
||
|
||
|
||
def list_project_folders(conn):
|
||
"""id + folder für Sync (nur Projekte mit Ordner)."""
|
||
return conn.execute(
|
||
"""
|
||
SELECT id, folder FROM projects
|
||
WHERE coalesce(folder, '') <> ''
|
||
ORDER BY length(folder) DESC, folder
|
||
"""
|
||
).fetchall()
|
||
|
||
|
||
def set_file_project(conn, file_id, project_id):
|
||
conn.execute(
|
||
"UPDATE files SET project_id = %s WHERE id = %s",
|
||
(project_id, file_id),
|
||
)
|
||
|
||
|
||
def delete_project(conn, project_id):
|
||
row = conn.execute("DELETE FROM projects WHERE id = %s RETURNING id", (project_id,)).fetchone()
|
||
return bool(row)
|
||
|
||
|
||
def list_file_rows(conn):
|
||
"""Rohzeilen inkl. stored_path — für interne Pfad-Operationen."""
|
||
return conn.execute("SELECT * FROM files ORDER BY mtime DESC, name").fetchall()
|
||
|
||
|
||
def list_files(conn, watch_dir=None):
|
||
return [file_out(row, watch_dir=watch_dir) for row in list_file_rows(conn)]
|
||
|
||
|
||
def get_file_row(conn, file_id):
|
||
return conn.execute("SELECT * FROM files WHERE id = %s", (file_id,)).fetchone()
|
||
|
||
|
||
def upsert_scanned(conn, meta, force=False):
|
||
statement = """
|
||
INSERT INTO files (
|
||
name, stored_path, source, kind, printer_family, filaments, weight_g,
|
||
estimated_s, thumb_entry, plate, size_bytes, mtime, slice_meta
|
||
) VALUES (
|
||
%(name)s, %(stored_path)s, 'watch', %(kind)s, %(printer_family)s, %(filaments)s,
|
||
%(weight_g)s, %(estimated_s)s, %(thumb_entry)s, %(plate)s, %(size_bytes)s, %(mtime)s,
|
||
%(slice_meta)s
|
||
)
|
||
ON CONFLICT (source, stored_path) DO UPDATE SET
|
||
name = EXCLUDED.name,
|
||
kind = EXCLUDED.kind,
|
||
printer_family = EXCLUDED.printer_family,
|
||
filaments = EXCLUDED.filaments,
|
||
weight_g = EXCLUDED.weight_g,
|
||
estimated_s = EXCLUDED.estimated_s,
|
||
thumb_entry = EXCLUDED.thumb_entry,
|
||
plate = EXCLUDED.plate,
|
||
size_bytes = EXCLUDED.size_bytes,
|
||
mtime = EXCLUDED.mtime,
|
||
slice_meta = EXCLUDED.slice_meta
|
||
"""
|
||
if not force:
|
||
statement += """
|
||
WHERE files.size_bytes IS DISTINCT FROM EXCLUDED.size_bytes
|
||
OR files.mtime IS DISTINCT FROM EXCLUDED.mtime
|
||
"""
|
||
payload = dict(meta)
|
||
payload["filaments"] = Json(meta.get("filaments") or [])
|
||
payload["slice_meta"] = Json(meta.get("slice_meta") or {})
|
||
conn.execute(statement, payload)
|
||
|
||
|
||
def insert_upload(conn, meta):
|
||
row = conn.execute(
|
||
"""
|
||
INSERT INTO files (
|
||
name, stored_path, source, kind, printer_family, filaments, weight_g,
|
||
estimated_s, thumb_entry, plate, size_bytes, mtime, project_id, slice_meta
|
||
) VALUES (
|
||
%(name)s, %(stored_path)s, 'upload', %(kind)s, %(printer_family)s, %(filaments)s,
|
||
%(weight_g)s, %(estimated_s)s, %(thumb_entry)s, %(plate)s, %(size_bytes)s, %(mtime)s,
|
||
%(project_id)s, %(slice_meta)s
|
||
)
|
||
RETURNING *
|
||
""",
|
||
{
|
||
**meta,
|
||
"filaments": Json(meta.get("filaments") or []),
|
||
"slice_meta": Json(meta.get("slice_meta") or {}),
|
||
},
|
||
).fetchone()
|
||
return file_out(row)
|
||
|
||
|
||
def update_file(conn, file_id, fields, watch_dir=None):
|
||
sets = []
|
||
values = []
|
||
for key in ("project_id", "spool_id", "printer_family", "weight_g", "estimated_s", "thumb_entry", "kind", "plate"):
|
||
if key in fields:
|
||
sets.append("%s = %%s" % key)
|
||
values.append(fields[key])
|
||
if "filaments" in fields:
|
||
sets.append("filaments = %s")
|
||
values.append(Json(fields.get("filaments") or []))
|
||
if "slice_meta" in fields:
|
||
sets.append("slice_meta = %s")
|
||
values.append(Json(fields.get("slice_meta") or {}))
|
||
if not sets:
|
||
row = get_file_row(conn, file_id)
|
||
return file_out(row, watch_dir=watch_dir) if row else None
|
||
values.append(file_id)
|
||
row = conn.execute(
|
||
"UPDATE files SET %s WHERE id = %%s RETURNING *" % ", ".join(sets),
|
||
values,
|
||
).fetchone()
|
||
return file_out(row, watch_dir=watch_dir) if row else None
|
||
|
||
|
||
def update_file_location(conn, file_id, stored_path, name=None, size_bytes=None, mtime=None, source=None):
|
||
"""Pfad nach Verschieben auf der Platte — ID und Zuordnungen bleiben."""
|
||
sets = ["stored_path = %s"]
|
||
values = [stored_path]
|
||
if name is not None:
|
||
sets.append("name = %s")
|
||
values.append(name)
|
||
if size_bytes is not None:
|
||
sets.append("size_bytes = %s")
|
||
values.append(size_bytes)
|
||
if mtime is not None:
|
||
sets.append("mtime = %s")
|
||
values.append(mtime)
|
||
if source is not None:
|
||
sets.append("source = %s")
|
||
values.append(source)
|
||
values.append(file_id)
|
||
return conn.execute(
|
||
"UPDATE files SET %s WHERE id = %%s RETURNING *" % ", ".join(sets),
|
||
values,
|
||
).fetchone()
|
||
|
||
|
||
def delete_file(conn, file_id):
|
||
return conn.execute("DELETE FROM files WHERE id = %s RETURNING *", (file_id,)).fetchone()
|
||
|
||
|
||
def forget_missing_watch(conn, seen):
|
||
if seen:
|
||
conn.execute(
|
||
"DELETE FROM files WHERE source = 'watch' AND NOT (stored_path = ANY(%s))",
|
||
(seen,),
|
||
)
|
||
else:
|
||
conn.execute("DELETE FROM files WHERE source = 'watch'")
|
||
|
||
|
||
def list_spools(conn):
|
||
ensure_spool_codes(conn)
|
||
rows = conn.execute("SELECT * FROM spools ORDER BY name, id").fetchall()
|
||
return [spool_out(row) for row in rows]
|
||
|
||
|
||
def get_spool_row(conn, spool_id):
|
||
return conn.execute("SELECT * FROM spools WHERE id = %s", (spool_id,)).fetchone()
|
||
|
||
|
||
def get_spool_by_code(conn, code):
|
||
code = (code or "").strip()
|
||
if not code:
|
||
return None
|
||
ensure_spool_codes(conn)
|
||
row = conn.execute(
|
||
"""
|
||
SELECT * FROM spools
|
||
WHERE code = %s OR chargen_nr = %s
|
||
ORDER BY id
|
||
LIMIT 1
|
||
""",
|
||
(code, code),
|
||
).fetchone()
|
||
return spool_out(row) if row else None
|
||
|
||
|
||
def create_spool(conn, fields):
|
||
payload = dict(fields)
|
||
payload["colors"] = Json(payload.get("colors") or [])
|
||
payload.setdefault("ace_slot", None)
|
||
payload.setdefault("material_id", None)
|
||
payload.setdefault("hersteller_charge", "")
|
||
code = (payload.get("chargen_nr") or payload.get("code") or "").strip() or next_chargen(conn)
|
||
payload["chargen_nr"] = code
|
||
payload["code"] = code
|
||
row = conn.execute(
|
||
"""
|
||
INSERT INTO spools (
|
||
name, brand, material_type, color, colors, price_per_kg, density, volumetric,
|
||
remaining_g, printer_host, ace_slot, material_id, chargen_nr, hersteller_charge, code
|
||
) VALUES (
|
||
%(name)s, %(brand)s, %(material_type)s, %(color)s, %(colors)s, %(price_per_kg)s,
|
||
%(density)s, %(volumetric)s, %(remaining_g)s, %(printer_host)s, %(ace_slot)s, %(material_id)s,
|
||
%(chargen_nr)s, %(hersteller_charge)s, %(code)s
|
||
)
|
||
RETURNING *
|
||
""",
|
||
payload,
|
||
).fetchone()
|
||
return spool_out(row)
|
||
|
||
|
||
def update_spool(conn, spool_id, fields):
|
||
allowed = (
|
||
"name", "brand", "material_type", "color", "colors", "price_per_kg", "density",
|
||
"volumetric", "remaining_g", "printer_host", "ace_slot", "material_id",
|
||
"hersteller_charge",
|
||
)
|
||
sets = []
|
||
values = []
|
||
for key in allowed:
|
||
if key in fields:
|
||
sets.append("%s = %%s" % key)
|
||
values.append(Json(fields[key]) if key == "colors" else fields[key])
|
||
if "archived_at" in fields:
|
||
if fields["archived_at"] is None or fields["archived_at"] is False:
|
||
sets.append("archived_at = NULL")
|
||
elif fields["archived_at"] is True or fields["archived_at"] == "now":
|
||
sets.append("archived_at = now()")
|
||
else:
|
||
sets.append("archived_at = %s")
|
||
values.append(fields["archived_at"])
|
||
if not sets:
|
||
row = get_spool_row(conn, spool_id)
|
||
return spool_out(row) if row else None
|
||
values.append(spool_id)
|
||
row = conn.execute(
|
||
"UPDATE spools SET %s WHERE id = %%s RETURNING *" % ", ".join(sets),
|
||
values,
|
||
).fetchone()
|
||
return spool_out(row) if row else None
|
||
|
||
|
||
def mark_spool_empty(conn, spool_id):
|
||
"""Rest auf 0, vom Drucker ins Regal, archivieren."""
|
||
row = conn.execute(
|
||
"""
|
||
UPDATE spools
|
||
SET remaining_g = 0,
|
||
printer_host = '',
|
||
ace_slot = NULL,
|
||
archived_at = COALESCE(archived_at, now())
|
||
WHERE id = %s
|
||
RETURNING *
|
||
""",
|
||
(spool_id,),
|
||
).fetchone()
|
||
return spool_out(row) if row else None
|
||
|
||
|
||
def material_out(row):
|
||
s1 = row["volumetric_s1"]
|
||
return {
|
||
"id": row["id"],
|
||
"name": row["name"],
|
||
"brand": row["brand"],
|
||
"material_type": row["material_type"],
|
||
"density": row["density"],
|
||
"volumetric": row["volumetric"],
|
||
"volumetric_s1": s1,
|
||
"machines": row["machines"],
|
||
"machines_label": MACHINE_LABELS.get(row["machines"], row["machines"]),
|
||
"price_per_kg": row["price_per_kg"],
|
||
"note": row["note"],
|
||
}
|
||
|
||
|
||
def list_materials(conn):
|
||
rows = conn.execute("SELECT * FROM materials ORDER BY name").fetchall()
|
||
return [material_out(row) for row in rows]
|
||
|
||
|
||
def get_material(conn, material_id):
|
||
row = conn.execute("SELECT * FROM materials WHERE id = %s", (material_id,)).fetchone()
|
||
return material_out(row) if row else None
|
||
|
||
|
||
def create_material(conn, data):
|
||
row = conn.execute(
|
||
"""
|
||
INSERT INTO materials (
|
||
name, brand, material_type, density, volumetric, volumetric_s1,
|
||
machines, price_per_kg, note
|
||
) VALUES (
|
||
%(name)s, %(brand)s, %(material_type)s, %(density)s, %(volumetric)s,
|
||
%(volumetric_s1)s, %(machines)s, %(price_per_kg)s, %(note)s
|
||
)
|
||
RETURNING *
|
||
""",
|
||
data,
|
||
).fetchone()
|
||
return material_out(row)
|
||
|
||
|
||
def update_material(conn, material_id, fields):
|
||
allowed = (
|
||
"name", "brand", "material_type", "density", "volumetric", "volumetric_s1",
|
||
"machines", "price_per_kg", "note",
|
||
)
|
||
sets = []
|
||
values = {"id": material_id}
|
||
for key in allowed:
|
||
if key in fields:
|
||
sets.append("%s = %%(%s)s" % (key, key))
|
||
values[key] = fields[key]
|
||
if not sets:
|
||
return get_material(conn, material_id)
|
||
row = conn.execute(
|
||
"UPDATE materials SET %s WHERE id = %%(id)s RETURNING *" % ", ".join(sets),
|
||
values,
|
||
).fetchone()
|
||
return material_out(row) if row else None
|
||
|
||
|
||
def delete_material(conn, material_id):
|
||
used = conn.execute(
|
||
"SELECT id FROM spools WHERE material_id = %s LIMIT 1",
|
||
(material_id,),
|
||
).fetchone()
|
||
if used:
|
||
return "in_use"
|
||
row = conn.execute(
|
||
"DELETE FROM materials WHERE id = %s RETURNING id",
|
||
(material_id,),
|
||
).fetchone()
|
||
return "ok" if row else None
|
||
|
||
|
||
def seed_materials(conn):
|
||
for item in MATERIALS:
|
||
conn.execute(
|
||
"""
|
||
INSERT INTO materials (
|
||
name, brand, material_type, density, volumetric, volumetric_s1,
|
||
machines, price_per_kg, note
|
||
) VALUES (
|
||
%(name)s, %(brand)s, %(material_type)s, %(density)s, %(volumetric)s,
|
||
%(volumetric_s1)s, %(machines)s, %(price_per_kg)s, %(note)s
|
||
)
|
||
ON CONFLICT (name) DO UPDATE SET
|
||
brand = EXCLUDED.brand,
|
||
material_type = EXCLUDED.material_type,
|
||
density = EXCLUDED.density,
|
||
volumetric = EXCLUDED.volumetric,
|
||
volumetric_s1 = EXCLUDED.volumetric_s1,
|
||
machines = EXCLUDED.machines,
|
||
note = EXCLUDED.note
|
||
""",
|
||
item,
|
||
)
|
||
|
||
|
||
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()
|
||
return bool(row)
|
||
|
||
|
||
def spools_for_printer(conn, host):
|
||
return conn.execute(
|
||
"SELECT * FROM spools WHERE printer_host = %s ORDER BY id",
|
||
(host,),
|
||
).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):
|
||
base = (filename or "").replace("\\", "/").rsplit("/", 1)[-1]
|
||
if not base:
|
||
return None
|
||
return conn.execute(
|
||
"SELECT * FROM files WHERE name = %s ORDER BY created_at DESC LIMIT 1",
|
||
(base,),
|
||
).fetchone()
|
||
|
||
|
||
def add_send(conn, printer_host, filename, file_id, project_id, spool_id, ace_map=None, bed_leveling=False):
|
||
conn.execute(
|
||
"""
|
||
INSERT INTO sends (printer_host, filename, file_id, project_id, spool_id, ace_map, bed_leveling)
|
||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||
""",
|
||
(
|
||
printer_host,
|
||
filename,
|
||
file_id,
|
||
project_id,
|
||
spool_id,
|
||
Json(ace_map if isinstance(ace_map, list) else []),
|
||
bool(bed_leveling),
|
||
),
|
||
)
|
||
|
||
|
||
def latest_send(conn, host, filename):
|
||
base = (filename or "").replace("\\", "/").rsplit("/", 1)[-1]
|
||
return conn.execute(
|
||
"""
|
||
SELECT * FROM sends
|
||
WHERE printer_host = %s AND (filename = %s OR filename = %s)
|
||
ORDER BY created_at DESC
|
||
LIMIT 1
|
||
""",
|
||
(host, filename or "", base),
|
||
).fetchone()
|
||
|
||
|
||
def latest_send_for_file(conn, host, file_id):
|
||
"""Letzter Sende-Eintrag für Datei auf diesem Host (für „Nochmal“)."""
|
||
if not host or not file_id:
|
||
return None
|
||
row = conn.execute(
|
||
"""
|
||
SELECT * FROM sends
|
||
WHERE printer_host = %s AND file_id = %s
|
||
ORDER BY created_at DESC
|
||
LIMIT 1
|
||
""",
|
||
(host, file_id),
|
||
).fetchone()
|
||
if not row:
|
||
return None
|
||
return {
|
||
"id": row["id"],
|
||
"printer_host": row["printer_host"],
|
||
"filename": row["filename"],
|
||
"file_id": row["file_id"],
|
||
"project_id": row["project_id"],
|
||
"spool_id": row["spool_id"],
|
||
"ace_map": _json_list(row.get("ace_map")),
|
||
"bed_leveling": bool(row.get("bed_leveling")),
|
||
"created_at": iso(row["created_at"]),
|
||
}
|
||
|
||
|
||
def upsert_run(conn, item):
|
||
payload = {
|
||
"weight_planned_g": float(item.get("weight_planned_g") or item.get("weight_g") or 0),
|
||
"weight_used_g": float(item.get("weight_used_g") or 0),
|
||
"weight_source": item.get("weight_source") or "",
|
||
"filament_used_mm": float(item.get("filament_used_mm") or 0),
|
||
"filament_total_mm": float(item.get("filament_total_mm") or 0),
|
||
"progress_last": float(item.get("progress_last") or 0),
|
||
"estimated_s": float(item.get("estimated_s") or 0),
|
||
"message": item.get("message") or "",
|
||
**item,
|
||
}
|
||
payload["objects"] = Json(item.get("objects") if item.get("objects") is not None else [])
|
||
if "order_item_id" not in payload:
|
||
payload["order_item_id"] = item.get("order_item_id")
|
||
if "weight_g" not in payload or payload.get("weight_g") is None:
|
||
payload["weight_g"] = payload["weight_planned_g"]
|
||
return conn.execute(
|
||
"""
|
||
INSERT INTO runs (
|
||
printer_host, printer_name, filename, file_id, project_id, spool_id,
|
||
state, started_at, ended_at, duration_s, weight_g, energy_cost,
|
||
moonraker_job_id, needs_spool, material_type, order_item_id,
|
||
weight_planned_g, weight_used_g, weight_source,
|
||
filament_used_mm, filament_total_mm, progress_last, estimated_s,
|
||
message, objects
|
||
) VALUES (
|
||
%(printer_host)s, %(printer_name)s, %(filename)s, %(file_id)s, %(project_id)s, %(spool_id)s,
|
||
%(state)s, %(started_at)s, %(ended_at)s, %(duration_s)s, %(weight_g)s, %(energy_cost)s,
|
||
%(moonraker_job_id)s, %(needs_spool)s, %(material_type)s, %(order_item_id)s,
|
||
%(weight_planned_g)s, %(weight_used_g)s, %(weight_source)s,
|
||
%(filament_used_mm)s, %(filament_total_mm)s, %(progress_last)s, %(estimated_s)s,
|
||
%(message)s, %(objects)s
|
||
)
|
||
ON CONFLICT (printer_host, moonraker_job_id) DO UPDATE SET
|
||
printer_name = EXCLUDED.printer_name,
|
||
filename = EXCLUDED.filename,
|
||
file_id = COALESCE(runs.file_id, EXCLUDED.file_id),
|
||
project_id = COALESCE(runs.project_id, EXCLUDED.project_id),
|
||
spool_id = COALESCE(runs.spool_id, EXCLUDED.spool_id),
|
||
order_item_id = COALESCE(runs.order_item_id, EXCLUDED.order_item_id),
|
||
state = EXCLUDED.state,
|
||
started_at = COALESCE(runs.started_at, EXCLUDED.started_at),
|
||
ended_at = EXCLUDED.ended_at,
|
||
duration_s = EXCLUDED.duration_s,
|
||
weight_g = CASE WHEN EXCLUDED.weight_g > 0 THEN EXCLUDED.weight_g ELSE runs.weight_g END,
|
||
weight_planned_g = CASE
|
||
WHEN EXCLUDED.weight_planned_g > 0 THEN EXCLUDED.weight_planned_g
|
||
ELSE runs.weight_planned_g
|
||
END,
|
||
weight_used_g = CASE
|
||
WHEN EXCLUDED.weight_used_g > 0 THEN EXCLUDED.weight_used_g
|
||
ELSE runs.weight_used_g
|
||
END,
|
||
weight_source = CASE
|
||
WHEN EXCLUDED.weight_source <> '' THEN EXCLUDED.weight_source
|
||
ELSE runs.weight_source
|
||
END,
|
||
filament_used_mm = GREATEST(runs.filament_used_mm, EXCLUDED.filament_used_mm),
|
||
filament_total_mm = CASE
|
||
WHEN EXCLUDED.filament_total_mm > 0 THEN EXCLUDED.filament_total_mm
|
||
ELSE runs.filament_total_mm
|
||
END,
|
||
progress_last = GREATEST(runs.progress_last, EXCLUDED.progress_last),
|
||
estimated_s = CASE
|
||
WHEN EXCLUDED.estimated_s > 0 THEN EXCLUDED.estimated_s
|
||
ELSE runs.estimated_s
|
||
END,
|
||
message = CASE
|
||
WHEN EXCLUDED.message <> '' THEN EXCLUDED.message
|
||
ELSE runs.message
|
||
END,
|
||
objects = CASE
|
||
WHEN EXCLUDED.objects <> '[]'::jsonb THEN EXCLUDED.objects
|
||
ELSE runs.objects
|
||
END,
|
||
energy_cost = EXCLUDED.energy_cost,
|
||
needs_spool = CASE WHEN runs.deducted THEN false ELSE EXCLUDED.needs_spool END,
|
||
material_type = CASE
|
||
WHEN EXCLUDED.material_type <> '' THEN EXCLUDED.material_type
|
||
ELSE runs.material_type
|
||
END
|
||
RETURNING *
|
||
""",
|
||
payload,
|
||
).fetchone()
|
||
|
||
|
||
def mark_deducted(conn, run_id, spool_id, filament_cost, energy_cost, weight_used_g=None, weight_source=""):
|
||
return conn.execute(
|
||
"""
|
||
UPDATE runs
|
||
SET deducted = true, needs_spool = false, spool_id = %s,
|
||
filament_cost = %s, energy_cost = %s,
|
||
weight_used_g = COALESCE(%s, weight_used_g),
|
||
weight_g = COALESCE(%s, weight_g),
|
||
weight_source = CASE WHEN %s <> '' THEN %s ELSE weight_source END
|
||
WHERE id = %s AND deducted = false
|
||
RETURNING id
|
||
""",
|
||
(
|
||
spool_id,
|
||
filament_cost,
|
||
energy_cost,
|
||
weight_used_g,
|
||
weight_used_g,
|
||
weight_source or "",
|
||
weight_source or "",
|
||
run_id,
|
||
),
|
||
).fetchone()
|
||
|
||
|
||
def deduct_spool(conn, spool_id, grams):
|
||
return conn.execute(
|
||
"UPDATE spools SET remaining_g = remaining_g - %s WHERE id = %s RETURNING *",
|
||
(grams, spool_id),
|
||
).fetchone()
|
||
|
||
|
||
def list_runs(conn, limit=200, since=None, until=None, host="", material="", brand=""):
|
||
where = []
|
||
values = []
|
||
until_bound = exclusive_until(until)
|
||
if since:
|
||
where.append("r.started_at >= %s")
|
||
values.append(since)
|
||
if until_bound:
|
||
where.append("r.started_at < %s")
|
||
values.append(until_bound)
|
||
if host:
|
||
where.append("r.printer_host = %s")
|
||
values.append(host)
|
||
spool_join = ""
|
||
if material or brand:
|
||
spool_join = (
|
||
"JOIN run_spools rs0 ON rs0.run_id = r.id "
|
||
"JOIN spools s0 ON s0.id = rs0.spool_id"
|
||
)
|
||
if material:
|
||
where.append("s0.material_type ILIKE %s")
|
||
values.append(material)
|
||
if brand:
|
||
where.append("s0.brand ILIKE %s")
|
||
values.append(brand)
|
||
# Live-Zeile ausblenden, wenn derselbe Druck schon als History-Job offen ist
|
||
where.append(
|
||
"""
|
||
NOT (
|
||
r.moonraker_job_id LIKE 'live:%%'
|
||
AND r.state IN ('in_progress', 'printing', 'paused')
|
||
AND EXISTS (
|
||
SELECT 1 FROM runs h
|
||
WHERE h.printer_host = r.printer_host
|
||
AND h.filename = r.filename
|
||
AND h.moonraker_job_id NOT LIKE 'live:%%'
|
||
AND h.state IN ('in_progress', 'printing', 'paused')
|
||
)
|
||
)
|
||
"""
|
||
)
|
||
clause = (" WHERE " + " AND ".join(where)) if where else ""
|
||
values.append(int(limit))
|
||
rows = conn.execute(
|
||
"""
|
||
SELECT DISTINCT r.*
|
||
FROM runs r
|
||
%s
|
||
%s
|
||
ORDER BY r.started_at DESC NULLS LAST, r.id DESC
|
||
LIMIT %%s
|
||
""" % (spool_join, clause),
|
||
values,
|
||
).fetchall()
|
||
return [run_out(row) for row in rows]
|
||
|
||
|
||
def get_run_row(conn, run_id):
|
||
return conn.execute("SELECT * FROM runs WHERE id = %s", (run_id,)).fetchone()
|
||
|
||
|
||
def latest_run_for_host(conn, host):
|
||
"""Aktueller oder letzter Lauf mit Proben fürs Cockpit-Verlauf."""
|
||
host = (host or "").strip()
|
||
if not host:
|
||
return None
|
||
runs = list_runs(conn, limit=12, host=host)
|
||
if not runs:
|
||
return None
|
||
active = [r for r in runs if (r.get("state") or "") in ("in_progress", "printing", "paused")]
|
||
candidates = active + [r for r in runs if r not in active]
|
||
best = None
|
||
best_n = -1
|
||
for run in candidates:
|
||
row = conn.execute(
|
||
"SELECT count(*) AS n FROM run_samples WHERE run_id = %s",
|
||
(run["id"],),
|
||
).fetchone()
|
||
n = int(row["n"] or 0) if row else 0
|
||
if (run.get("state") or "") in ("in_progress", "printing", "paused"):
|
||
run["_sample_count"] = n
|
||
return run
|
||
if n >= 2 and n > best_n:
|
||
best = run
|
||
best_n = n
|
||
best["_sample_count"] = n
|
||
return best
|
||
|
||
|
||
def get_run_by_chargen(conn, code):
|
||
text = (code or "").strip()
|
||
if not text:
|
||
return None
|
||
row = conn.execute(
|
||
"SELECT * FROM runs WHERE chargen_nr = %s LIMIT 1",
|
||
(text,),
|
||
).fetchone()
|
||
if row:
|
||
return row
|
||
label = conn.execute(
|
||
"SELECT run_id FROM run_labels WHERE chargen_nr = %s LIMIT 1",
|
||
(text,),
|
||
).fetchone()
|
||
if not label:
|
||
return None
|
||
return get_run_row(conn, label["run_id"])
|
||
|
||
|
||
def set_verdict(conn, run_id, verdict, verdict_reason="", pieces_ok=0, pieces_bad=0):
|
||
from grotti import orders
|
||
|
||
before = conn.execute(
|
||
"SELECT order_item_id, pieces_ok, pieces_bad FROM runs WHERE id = %s",
|
||
(run_id,),
|
||
).fetchone()
|
||
row = conn.execute(
|
||
"""
|
||
UPDATE runs
|
||
SET verdict = %s, verdict_reason = %s, pieces_ok = %s, pieces_bad = %s
|
||
WHERE id = %s
|
||
RETURNING *
|
||
""",
|
||
(verdict, verdict_reason or "", int(pieces_ok or 0), int(pieces_bad or 0), run_id),
|
||
).fetchone()
|
||
if row and before and before.get("order_item_id"):
|
||
orders.apply_verdict_delta(
|
||
conn,
|
||
before["order_item_id"],
|
||
before.get("pieces_ok"),
|
||
before.get("pieces_bad"),
|
||
pieces_ok,
|
||
pieces_bad,
|
||
)
|
||
return run_out(row) if row else None
|
||
|
||
|
||
def run_for_abraeumen_verdict(conn, host):
|
||
"""Letzter abgeschlossener oder noch laufender Lauf des Hosts."""
|
||
host = (host or "").strip()
|
||
if not host:
|
||
return None
|
||
return conn.execute(
|
||
"""
|
||
SELECT * FROM runs
|
||
WHERE printer_host = %s
|
||
AND state IN ('completed', 'in_progress', 'printing', 'paused')
|
||
ORDER BY
|
||
CASE WHEN state IN ('in_progress', 'printing', 'paused') THEN 0 ELSE 1 END,
|
||
ended_at DESC NULLS LAST,
|
||
started_at DESC NULLS LAST,
|
||
id DESC
|
||
LIMIT 1
|
||
""",
|
||
(host,),
|
||
).fetchone()
|
||
|
||
|
||
def assign_run_spool(conn, run_id, spool_id):
|
||
return conn.execute(
|
||
"""
|
||
UPDATE runs
|
||
SET spool_id = %s
|
||
WHERE id = %s
|
||
RETURNING *
|
||
""",
|
||
(spool_id, run_id),
|
||
).fetchone()
|
||
|
||
|
||
def next_druck_chargen(conn):
|
||
"""Nächste Druck-Charge des Tages. Objekt-Etiketten (`D-…-01`) blockieren die Nummer."""
|
||
day = date.today().strftime("%Y%m%d")
|
||
prefix = "D-%s-" % day
|
||
batch_re = "^D-%s-[0-9]{4}$" % day
|
||
object_re = "^D-%s-[0-9]{4}-[0-9]{2}$" % day
|
||
conn.execute("SELECT pg_advisory_xact_lock(%s)", (870012,))
|
||
row = conn.execute(
|
||
"""
|
||
SELECT n FROM (
|
||
SELECT chargen_nr AS n FROM runs
|
||
WHERE chargen_nr ~ %s
|
||
UNION
|
||
SELECT CASE
|
||
WHEN chargen_nr ~ %s THEN regexp_replace(chargen_nr, '-[0-9]{2}$', '')
|
||
ELSE chargen_nr
|
||
END AS n
|
||
FROM run_labels
|
||
WHERE chargen_nr ~ %s OR chargen_nr ~ %s
|
||
) AS nums
|
||
ORDER BY n DESC
|
||
LIMIT 1
|
||
""",
|
||
(batch_re, object_re, batch_re, object_re),
|
||
).fetchone()
|
||
number = 1
|
||
if row and row["n"]:
|
||
try:
|
||
number = int(str(row["n"]).rsplit("-", 1)[-1]) + 1
|
||
except ValueError:
|
||
number = 1
|
||
return "%s%04d" % (prefix, number)
|
||
|
||
|
||
def ensure_run_chargen(conn, run_id, weight_used_g=None):
|
||
"""Vergibt D-… am laufenden oder beendeten Druck. Bestehende Nummer bleibt.
|
||
|
||
weight_used_g wird ignoriert (früher Gate); Parameter bleibt für Aufrufer.
|
||
"""
|
||
row = get_run_row(conn, run_id)
|
||
if not row:
|
||
return None
|
||
if row.get("chargen_nr"):
|
||
return row["chargen_nr"]
|
||
state = row.get("state") or ""
|
||
if state not in (
|
||
"in_progress", "printing", "paused",
|
||
"completed", "cancelled", "error", "interrupted",
|
||
):
|
||
return None
|
||
code = next_druck_chargen(conn)
|
||
updated = conn.execute(
|
||
"""
|
||
UPDATE runs SET chargen_nr = %s
|
||
WHERE id = %s AND (chargen_nr IS NULL OR chargen_nr = '')
|
||
RETURNING chargen_nr
|
||
""",
|
||
(code, run_id),
|
||
).fetchone()
|
||
return (updated or {}).get("chargen_nr") or code
|
||
|
||
|
||
def replace_run_spools(conn, run_id, entries):
|
||
conn.execute("DELETE FROM run_spools WHERE run_id = %s", (run_id,))
|
||
for item in entries:
|
||
conn.execute(
|
||
"""
|
||
INSERT INTO run_spools (
|
||
run_id, spool_id, ace_slot, weight_g, weight_planned_g, cost, is_estimate
|
||
) VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||
""",
|
||
(
|
||
run_id,
|
||
item["spool_id"],
|
||
item.get("ace_slot"),
|
||
float(item.get("weight_g") or 0),
|
||
float(item.get("weight_planned_g") or 0),
|
||
float(item.get("cost") or 0),
|
||
bool(item.get("is_estimate")),
|
||
),
|
||
)
|
||
|
||
|
||
def list_run_spools(conn, run_id):
|
||
rows = conn.execute(
|
||
"""
|
||
SELECT rs.*, s.name AS spool_name, s.chargen_nr AS spool_chargen,
|
||
s.material_type, s.brand, s.color, s.colors, s.hersteller_charge,
|
||
s.price_per_kg, s.remaining_g
|
||
FROM run_spools rs
|
||
JOIN spools s ON s.id = rs.spool_id
|
||
WHERE rs.run_id = %s
|
||
ORDER BY rs.ace_slot NULLS LAST, rs.id
|
||
""",
|
||
(run_id,),
|
||
).fetchall()
|
||
out = []
|
||
for row in rows:
|
||
out.append({
|
||
"spool_id": row["spool_id"],
|
||
"ace_slot": row["ace_slot"],
|
||
"weight_g": float(row["weight_g"] or 0),
|
||
"weight_planned_g": float(row["weight_planned_g"] or 0),
|
||
"cost": float(row["cost"] or 0),
|
||
"is_estimate": bool(row["is_estimate"]),
|
||
"name": row["spool_name"],
|
||
"chargen_nr": row["spool_chargen"] or "",
|
||
"material_type": row["material_type"] or "",
|
||
"brand": row["brand"] or "",
|
||
"color": row["color"] or "",
|
||
"colors": _json_list(row.get("colors")),
|
||
"hersteller_charge": row["hersteller_charge"] or "",
|
||
"remaining_g": float(row["remaining_g"] or 0),
|
||
})
|
||
return out
|
||
|
||
|
||
def _etikett_spool_line(row, ace_slot=None):
|
||
spool = spool_out(row)
|
||
return {
|
||
"spool_id": spool["id"],
|
||
"name": spool["name"],
|
||
"chargen_nr": spool["chargen_nr"],
|
||
"material_type": spool["material_type"],
|
||
"brand": spool["brand"],
|
||
"hersteller_charge": spool["hersteller_charge"],
|
||
"ace_slot": ace_slot if ace_slot is not None else spool.get("ace_slot"),
|
||
"color": spool.get("color") or "",
|
||
}
|
||
|
||
|
||
def etikett_spools_for_run(conn, run):
|
||
"""Material fürs Etikett: Abzug, sonst Send/ACE/Lauf-Spule, sonst Metadaten."""
|
||
existing = list_run_spools(conn, run["id"])
|
||
if existing:
|
||
return existing
|
||
out = []
|
||
seen = set()
|
||
|
||
def add_row(row, ace_slot=None):
|
||
if not row:
|
||
return
|
||
sid = row.get("id") if "id" in row else row.get("spool_id")
|
||
if sid is not None and sid in seen:
|
||
return
|
||
if sid is not None:
|
||
seen.add(sid)
|
||
out.append(_etikett_spool_line(row, ace_slot=ace_slot))
|
||
|
||
host = (run.get("printer_host") or "").strip()
|
||
sent = latest_send(conn, host, run.get("filename") or "") if host else None
|
||
ace_map = _json_list((sent or {}).get("ace_map")) if sent else []
|
||
if ace_map and host:
|
||
for item in ace_map:
|
||
try:
|
||
slot = int(item.get("ams_index")) + 1
|
||
except (TypeError, ValueError, AttributeError):
|
||
continue
|
||
row = conn.execute(
|
||
"""
|
||
SELECT * FROM spools
|
||
WHERE printer_host = %s AND ace_slot = %s
|
||
ORDER BY id LIMIT 1
|
||
""",
|
||
(host, slot),
|
||
).fetchone()
|
||
add_row(row, ace_slot=slot)
|
||
spool_id = run.get("spool_id") or (sent or {}).get("spool_id")
|
||
if spool_id:
|
||
add_row(get_spool_row(conn, spool_id))
|
||
if not out and host and not ace_map:
|
||
rows = conn.execute(
|
||
"""
|
||
SELECT * FROM spools
|
||
WHERE printer_host = %s AND ace_slot IS NULL
|
||
ORDER BY id
|
||
""",
|
||
(host,),
|
||
).fetchall()
|
||
for row in rows:
|
||
add_row(row)
|
||
if not out and (run.get("material_type") or "").strip():
|
||
out.append({
|
||
"spool_id": None,
|
||
"name": "",
|
||
"chargen_nr": "",
|
||
"material_type": (run.get("material_type") or "").strip(),
|
||
"brand": "",
|
||
"hersteller_charge": "",
|
||
"ace_slot": None,
|
||
"color": "",
|
||
})
|
||
return out
|
||
|
||
|
||
def list_spool_runs(conn, spool_id, limit=50):
|
||
rows = conn.execute(
|
||
"""
|
||
SELECT r.*, rs.weight_g AS spool_weight_g, rs.cost AS spool_cost,
|
||
rs.is_estimate AS spool_is_estimate
|
||
FROM run_spools rs
|
||
JOIN runs r ON r.id = rs.run_id
|
||
WHERE rs.spool_id = %s
|
||
ORDER BY r.started_at DESC NULLS LAST, r.id DESC
|
||
LIMIT %s
|
||
""",
|
||
(spool_id, int(limit)),
|
||
).fetchall()
|
||
out = []
|
||
for row in rows:
|
||
item = run_out(row)
|
||
item["spool_weight_g"] = float(row["spool_weight_g"] or 0)
|
||
item["spool_cost"] = float(row["spool_cost"] or 0)
|
||
item["spool_is_estimate"] = bool(row["spool_is_estimate"])
|
||
out.append(item)
|
||
return out
|
||
|
||
|
||
def spool_usage_stats(conn, spool_id, days=28):
|
||
row = conn.execute(
|
||
"""
|
||
SELECT
|
||
COALESCE(SUM(rs.weight_g), 0) AS total_g,
|
||
COALESCE(SUM(CASE WHEN r.verdict = 'ausschuss' THEN rs.weight_g ELSE 0 END), 0) AS scrap_g,
|
||
COALESCE(SUM(rs.cost), 0) AS total_cost,
|
||
COALESCE(SUM(CASE
|
||
WHEN r.started_at >= now() - make_interval(days => %s) THEN rs.weight_g
|
||
ELSE 0
|
||
END), 0) AS recent_g
|
||
FROM run_spools rs
|
||
JOIN runs r ON r.id = rs.run_id
|
||
WHERE rs.spool_id = %s
|
||
""",
|
||
(int(days), spool_id),
|
||
).fetchone()
|
||
recent = float((row or {}).get("recent_g") or 0)
|
||
per_day = recent / max(1, int(days))
|
||
spool = get_spool_row(conn, spool_id)
|
||
left = float((spool or {}).get("remaining_g") or 0)
|
||
empty_in_days = None
|
||
if per_day > 0.01 and left > 0:
|
||
empty_in_days = left / per_day
|
||
elif left <= 0:
|
||
empty_in_days = 0.0
|
||
return {
|
||
"total_g": float((row or {}).get("total_g") or 0),
|
||
"scrap_g": float((row or {}).get("scrap_g") or 0),
|
||
"total_cost": float((row or {}).get("total_cost") or 0),
|
||
"recent_g": recent,
|
||
"per_day_g": per_day,
|
||
"empty_in_days": empty_in_days,
|
||
"fill_pct": (left / 10.0) if left else (0.0 if left == 0 else left / 10.0),
|
||
"remaining_g": left,
|
||
}
|
||
|
||
|
||
def find_open_history_run(conn, host, filename=""):
|
||
"""Offener Moonraker-History-Job (nicht live:) für Host/Datei."""
|
||
host = (host or "").strip()
|
||
if not host:
|
||
return None
|
||
if filename:
|
||
row = conn.execute(
|
||
"""
|
||
SELECT * FROM runs
|
||
WHERE printer_host = %s
|
||
AND filename = %s
|
||
AND moonraker_job_id NOT LIKE 'live:%%'
|
||
AND state IN ('in_progress', 'printing', 'paused')
|
||
ORDER BY id DESC
|
||
LIMIT 1
|
||
""",
|
||
(host, filename),
|
||
).fetchone()
|
||
if row:
|
||
return row
|
||
return conn.execute(
|
||
"""
|
||
SELECT * FROM runs
|
||
WHERE printer_host = %s
|
||
AND moonraker_job_id NOT LIKE 'live:%%'
|
||
AND state IN ('in_progress', 'printing', 'paused')
|
||
ORDER BY id DESC
|
||
LIMIT 1
|
||
""",
|
||
(host,),
|
||
).fetchone()
|
||
|
||
|
||
def find_live_run(conn, host, filename=""):
|
||
"""Offener Live-Lauf für diesen Drucker (Watcher)."""
|
||
if filename:
|
||
row = conn.execute(
|
||
"""
|
||
SELECT * FROM runs
|
||
WHERE printer_host = %s
|
||
AND filename = %s
|
||
AND moonraker_job_id LIKE 'live:%%'
|
||
AND state IN ('in_progress', 'printing', 'paused')
|
||
ORDER BY id DESC
|
||
LIMIT 1
|
||
""",
|
||
(host, filename),
|
||
).fetchone()
|
||
if row:
|
||
return row
|
||
return conn.execute(
|
||
"""
|
||
SELECT * FROM runs
|
||
WHERE printer_host = %s
|
||
AND moonraker_job_id LIKE 'live:%%'
|
||
AND state IN ('in_progress', 'printing', 'paused')
|
||
ORDER BY id DESC
|
||
LIMIT 1
|
||
""",
|
||
(host,),
|
||
).fetchone()
|
||
|
||
|
||
def find_active_run(conn, host, filename=""):
|
||
"""Offener Lauf am Host: Live bevorzugt, sonst offener History-Job (Pause/Resume)."""
|
||
return find_live_run(conn, host, filename) or find_open_history_run(conn, host, filename)
|
||
|
||
|
||
def update_send_spool(conn, host, filename, spool_id):
|
||
"""Spule am letzten Send dieser Datei korrigieren (Rollenwechsel während Druck)."""
|
||
host = (host or "").strip()
|
||
if not host or not spool_id:
|
||
return None
|
||
sent = latest_send(conn, host, filename or "")
|
||
if not sent:
|
||
return None
|
||
if sent.get("spool_id") == spool_id:
|
||
return sent
|
||
return conn.execute(
|
||
"UPDATE sends SET spool_id = %s WHERE id = %s RETURNING *",
|
||
(spool_id, sent["id"]),
|
||
).fetchone()
|
||
|
||
|
||
def ensure_live_run(conn, host, printer_name, filename, spool_id=None):
|
||
"""Aktiven Lauf: History-Job bevorzugen, sonst Live — nie parallel beide offen."""
|
||
hist = find_open_history_run(conn, host, filename)
|
||
if hist:
|
||
live = find_live_run(conn, host, filename)
|
||
if live:
|
||
adopt_live_run(conn, host, filename or hist.get("filename") or "", hist["id"])
|
||
hist = get_run_row(conn, hist["id"]) or hist
|
||
if spool_id and hist.get("spool_id") != spool_id:
|
||
conn.execute("UPDATE runs SET spool_id = %s WHERE id = %s", (spool_id, hist["id"]))
|
||
hist = get_run_row(conn, hist["id"]) or hist
|
||
return hist
|
||
row = find_live_run(conn, host, filename)
|
||
if row:
|
||
if filename and row.get("filename") != filename:
|
||
conn.execute(
|
||
"UPDATE runs SET filename = %s WHERE id = %s",
|
||
(filename, row["id"]),
|
||
)
|
||
row = get_run_row(conn, row["id"])
|
||
if spool_id and row and row.get("spool_id") != spool_id:
|
||
conn.execute("UPDATE runs SET spool_id = %s WHERE id = %s", (spool_id, row["id"]))
|
||
row = get_run_row(conn, row["id"])
|
||
return row
|
||
job_id = "live:%s:%s" % (host, datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S"))
|
||
return upsert_run(conn, {
|
||
"printer_host": host,
|
||
"printer_name": printer_name or host,
|
||
"filename": filename or "",
|
||
"file_id": None,
|
||
"project_id": None,
|
||
"spool_id": spool_id,
|
||
"state": "in_progress",
|
||
"started_at": datetime.now(timezone.utc),
|
||
"ended_at": None,
|
||
"duration_s": 0,
|
||
"weight_g": 0,
|
||
"weight_planned_g": 0,
|
||
"weight_used_g": 0,
|
||
"weight_source": "",
|
||
"filament_used_mm": 0,
|
||
"filament_total_mm": 0,
|
||
"progress_last": 0,
|
||
"estimated_s": 0,
|
||
"energy_cost": 0,
|
||
"moonraker_job_id": job_id,
|
||
"needs_spool": False,
|
||
"material_type": "",
|
||
"message": "",
|
||
"objects": [],
|
||
})
|
||
|
||
|
||
def abandon_live_runs(conn, host, keep_id=None):
|
||
"""Offene Live-Läufe beenden (z. B. vor einem neuen Druckstart)."""
|
||
host = (host or "").strip()
|
||
if not host:
|
||
return 0
|
||
rows = conn.execute(
|
||
"""
|
||
UPDATE runs
|
||
SET state = 'interrupted',
|
||
ended_at = COALESCE(ended_at, now())
|
||
WHERE printer_host = %s
|
||
AND moonraker_job_id LIKE 'live:%%'
|
||
AND state IN ('in_progress', 'printing', 'paused')
|
||
AND (%s::bigint IS NULL OR id <> %s)
|
||
RETURNING id
|
||
""",
|
||
(host, keep_id, keep_id),
|
||
).fetchall()
|
||
return len(rows)
|
||
|
||
|
||
def complete_open_run(conn, run_id, state="completed"):
|
||
"""Offenen Lauf als beendet markieren (Abräumen / Druckende ohne History)."""
|
||
state = (state or "completed").strip() or "completed"
|
||
if state not in ("completed", "cancelled", "error", "interrupted"):
|
||
state = "completed"
|
||
return conn.execute(
|
||
"""
|
||
UPDATE runs
|
||
SET state = %s,
|
||
ended_at = COALESCE(ended_at, now()),
|
||
duration_s = CASE
|
||
WHEN COALESCE(duration_s, 0) > 0 THEN duration_s
|
||
WHEN started_at IS NOT NULL
|
||
THEN EXTRACT(EPOCH FROM (now() - started_at))
|
||
ELSE COALESCE(duration_s, 0)
|
||
END
|
||
WHERE id = %s
|
||
AND state IN ('in_progress', 'printing', 'paused')
|
||
RETURNING *
|
||
""",
|
||
(state, run_id),
|
||
).fetchone()
|
||
|
||
|
||
def adopt_live_run(conn, host, filename, real_run_id):
|
||
"""Proben/Segmente vom Live-Lauf auf den History-Lauf umhängen."""
|
||
live = find_live_run(conn, host, filename)
|
||
if not live or live["id"] == real_run_id:
|
||
return
|
||
real = get_run_row(conn, real_run_id)
|
||
if not real:
|
||
return
|
||
live_start = live.get("started_at")
|
||
real_end = real.get("ended_at")
|
||
# Gleiche Datei, neuer Druck: Live nicht an schon beendeten History-Job hängen.
|
||
if live_start and real_end and live_start > real_end:
|
||
return
|
||
real_state = (real.get("state") or "")
|
||
if real_state in ("completed", "cancelled", "error", "interrupted"):
|
||
if live_start and real.get("started_at") and live_start > real["started_at"]:
|
||
# Live begann nach dem History-Start und History ist schon durch — anderer Lauf.
|
||
if not real_end or live_start > real_end:
|
||
return
|
||
live_id = live["id"]
|
||
live_chargen = (live.get("chargen_nr") or "").strip()
|
||
for table in ("run_samples", "run_segments", "run_spools"):
|
||
conn.execute(
|
||
"UPDATE %s SET run_id = %%s WHERE run_id = %%s" % table,
|
||
(real_run_id, live_id),
|
||
)
|
||
conn.execute(
|
||
"UPDATE events SET run_id = %s WHERE run_id = %s",
|
||
(real_run_id, live_id),
|
||
)
|
||
conn.execute(
|
||
"""
|
||
UPDATE runs AS dest SET
|
||
progress_last = GREATEST(dest.progress_last, src.progress_last),
|
||
filament_used_mm = GREATEST(dest.filament_used_mm, src.filament_used_mm),
|
||
objects = CASE
|
||
WHEN dest.objects::text IN ('[]', 'null') THEN src.objects
|
||
ELSE dest.objects
|
||
END
|
||
FROM runs AS src
|
||
WHERE dest.id = %s AND src.id = %s
|
||
""",
|
||
(real_run_id, live_id),
|
||
)
|
||
if live_chargen and real_state in ("completed", "cancelled", "error", "interrupted"):
|
||
conn.execute("UPDATE runs SET chargen_nr = NULL WHERE id = %s", (live_id,))
|
||
conn.execute(
|
||
"""
|
||
UPDATE runs SET chargen_nr = %s
|
||
WHERE id = %s AND (chargen_nr IS NULL OR chargen_nr = '')
|
||
""",
|
||
(live_chargen, real_run_id),
|
||
)
|
||
else:
|
||
# Live-Charge war verfrüht — nicht übernehmen
|
||
conn.execute("UPDATE runs SET chargen_nr = NULL WHERE id = %s", (live_id,))
|
||
# Etiketten nur mit derselben Charge. Sonst bleiben sie am Live-Lauf und
|
||
# fallen beim Löschen weg, statt eine fremde Nummer zu blockieren.
|
||
real_after = get_run_row(conn, real_run_id)
|
||
real_chargen = ((real_after or {}).get("chargen_nr") or "").strip()
|
||
if live_chargen and real_chargen == live_chargen:
|
||
conn.execute(
|
||
"""
|
||
DELETE FROM run_labels AS src
|
||
WHERE src.run_id = %s
|
||
AND EXISTS (
|
||
SELECT 1 FROM run_labels AS dst
|
||
WHERE dst.run_id = %s AND dst.object_name = src.object_name
|
||
)
|
||
""",
|
||
(live_id, real_run_id),
|
||
)
|
||
conn.execute(
|
||
"UPDATE run_labels SET run_id = %s WHERE run_id = %s",
|
||
(real_run_id, live_id),
|
||
)
|
||
conn.execute("DELETE FROM runs WHERE id = %s", (live_id,))
|
||
|
||
|
||
def update_run_progress(conn, run_id, fields):
|
||
allowed = {
|
||
"state", "progress_last", "filament_used_mm", "filament_total_mm",
|
||
"weight_planned_g", "estimated_s", "message", "objects", "duration_s",
|
||
}
|
||
sets = []
|
||
values = []
|
||
for key, value in fields.items():
|
||
if key not in allowed:
|
||
continue
|
||
if key == "objects":
|
||
sets.append("objects = %s")
|
||
values.append(Json(value if value is not None else []))
|
||
else:
|
||
sets.append("%s = %%s" % key)
|
||
values.append(value)
|
||
if not sets:
|
||
return get_run_row(conn, run_id)
|
||
values.append(run_id)
|
||
return conn.execute(
|
||
"UPDATE runs SET %s WHERE id = %%s RETURNING *" % ", ".join(sets),
|
||
values,
|
||
).fetchone()
|
||
|
||
|
||
def add_run_sample(conn, run_id, sample):
|
||
return conn.execute(
|
||
"""
|
||
INSERT INTO run_samples (
|
||
run_id, sampled_at, nozzle, nozzle_target, bed, bed_target, chamber,
|
||
fan, speed_factor, extrude_factor, layer, progress,
|
||
filament_used_mm, mmu_tool, nozzle_power, bed_power
|
||
) VALUES (
|
||
%(run_id)s, COALESCE(%(sampled_at)s, now()), %(nozzle)s, %(nozzle_target)s,
|
||
%(bed)s, %(bed_target)s, %(chamber)s, %(fan)s, %(speed_factor)s,
|
||
%(extrude_factor)s, %(layer)s, %(progress)s, %(filament_used_mm)s, %(mmu_tool)s,
|
||
%(nozzle_power)s, %(bed_power)s
|
||
)
|
||
RETURNING id
|
||
""",
|
||
{
|
||
"run_id": run_id,
|
||
"sampled_at": sample.get("sampled_at"),
|
||
"nozzle": sample.get("nozzle"),
|
||
"nozzle_target": sample.get("nozzle_target"),
|
||
"bed": sample.get("bed"),
|
||
"bed_target": sample.get("bed_target"),
|
||
"chamber": sample.get("chamber"),
|
||
"fan": sample.get("fan"),
|
||
"speed_factor": sample.get("speed_factor"),
|
||
"extrude_factor": sample.get("extrude_factor"),
|
||
"layer": sample.get("layer"),
|
||
"progress": sample.get("progress"),
|
||
"filament_used_mm": sample.get("filament_used_mm"),
|
||
"mmu_tool": sample.get("mmu_tool"),
|
||
"nozzle_power": sample.get("nozzle_power"),
|
||
"bed_power": sample.get("bed_power"),
|
||
},
|
||
).fetchone()
|
||
|
||
|
||
def list_run_samples(conn, run_id, limit=2000):
|
||
rows = conn.execute(
|
||
"""
|
||
SELECT * FROM run_samples
|
||
WHERE run_id = %s
|
||
ORDER BY sampled_at ASC
|
||
LIMIT %s
|
||
""",
|
||
(run_id, int(limit)),
|
||
).fetchall()
|
||
out = []
|
||
for row in rows:
|
||
out.append({
|
||
"sampled_at": iso(row["sampled_at"]),
|
||
"nozzle": row["nozzle"],
|
||
"nozzle_target": row["nozzle_target"],
|
||
"bed": row["bed"],
|
||
"bed_target": row["bed_target"],
|
||
"chamber": row["chamber"],
|
||
"fan": row["fan"],
|
||
"speed_factor": row["speed_factor"],
|
||
"extrude_factor": row["extrude_factor"],
|
||
"layer": row["layer"],
|
||
"progress": row["progress"],
|
||
"filament_used_mm": row["filament_used_mm"],
|
||
"mmu_tool": row["mmu_tool"],
|
||
"nozzle_power": row.get("nozzle_power"),
|
||
"bed_power": row.get("bed_power"),
|
||
})
|
||
return out
|
||
|
||
|
||
def open_segment(conn, run_id, spool_id, ace_slot, mm_start):
|
||
return conn.execute(
|
||
"""
|
||
INSERT INTO run_segments (run_id, spool_id, ace_slot, mm_start)
|
||
VALUES (%s, %s, %s, %s)
|
||
RETURNING *
|
||
""",
|
||
(run_id, spool_id, ace_slot, float(mm_start or 0)),
|
||
).fetchone()
|
||
|
||
|
||
def close_open_segments(conn, run_id, ace_slot, mm_end):
|
||
"""Offene Abschnitte am Platz (oder alle bei ace_slot=None) schließen."""
|
||
if ace_slot is None:
|
||
return conn.execute(
|
||
"""
|
||
UPDATE run_segments
|
||
SET mm_end = %s, closed_at = now()
|
||
WHERE run_id = %s AND mm_end IS NULL
|
||
RETURNING *
|
||
""",
|
||
(float(mm_end or 0), run_id),
|
||
).fetchall()
|
||
return conn.execute(
|
||
"""
|
||
UPDATE run_segments
|
||
SET mm_end = %s, closed_at = now()
|
||
WHERE run_id = %s AND mm_end IS NULL
|
||
AND (ace_slot IS NOT DISTINCT FROM %s)
|
||
RETURNING *
|
||
""",
|
||
(float(mm_end or 0), run_id, ace_slot),
|
||
).fetchall()
|
||
|
||
|
||
def list_run_segments(conn, run_id):
|
||
rows = conn.execute(
|
||
"""
|
||
SELECT seg.*, s.name AS spool_name, s.chargen_nr AS spool_chargen
|
||
FROM run_segments seg
|
||
JOIN spools s ON s.id = seg.spool_id
|
||
WHERE seg.run_id = %s
|
||
ORDER BY seg.id
|
||
""",
|
||
(run_id,),
|
||
).fetchall()
|
||
return [
|
||
{
|
||
"id": row["id"],
|
||
"spool_id": row["spool_id"],
|
||
"ace_slot": row["ace_slot"],
|
||
"mm_start": float(row["mm_start"] or 0),
|
||
"mm_end": None if row["mm_end"] is None else float(row["mm_end"]),
|
||
"spool_name": row["spool_name"],
|
||
"spool_chargen": row["spool_chargen"] or "",
|
||
"created_at": iso(row["created_at"]),
|
||
"closed_at": iso(row["closed_at"]),
|
||
}
|
||
for row in rows
|
||
]
|
||
|
||
|
||
def _object_chargen(base, index):
|
||
return "%s-%02d" % (base, index)
|
||
|
||
|
||
def _label_owner(conn, code):
|
||
return conn.execute(
|
||
"""
|
||
SELECT l.id, l.run_id, l.chargen_nr, COALESCE(r.chargen_nr, '') AS run_chargen
|
||
FROM run_labels l
|
||
JOIN runs r ON r.id = l.run_id
|
||
WHERE l.chargen_nr = %s
|
||
""",
|
||
(code,),
|
||
).fetchone()
|
||
|
||
|
||
def _claim_object_chargen(conn, base, start, reserved, depth=0):
|
||
"""Freie Objekt-Charge ab start. Nummer einer anderen Lauf-Charge wird dorthin umgehängt."""
|
||
if depth > 8:
|
||
raise RuntimeError("Objekt-Charge lässt sich nicht vergeben")
|
||
number = max(1, int(start or 1))
|
||
while number < 1000:
|
||
code = _object_chargen(base, number)
|
||
if code in reserved:
|
||
number += 1
|
||
continue
|
||
owner = _label_owner(conn, code)
|
||
if not owner:
|
||
return code
|
||
owner_base = (owner["run_chargen"] or "").strip()
|
||
if owner_base == base:
|
||
number += 1
|
||
continue
|
||
if owner_base:
|
||
moved = _claim_object_chargen(conn, owner_base, 1, reserved | {code}, depth + 1)
|
||
conn.execute(
|
||
"UPDATE run_labels SET chargen_nr = %s WHERE id = %s AND chargen_nr = %s",
|
||
(moved, owner["id"], code),
|
||
)
|
||
if not _label_owner(conn, code):
|
||
return code
|
||
else:
|
||
conn.execute("DELETE FROM run_labels WHERE id = %s AND chargen_nr = %s", (owner["id"], code))
|
||
if not _label_owner(conn, code):
|
||
return code
|
||
number += 1
|
||
raise RuntimeError("Keine freie Objekt-Charge")
|
||
|
||
|
||
def _insert_run_label(conn, run_id, name, code):
|
||
from psycopg.errors import UniqueViolation
|
||
|
||
try:
|
||
with conn.transaction():
|
||
conn.execute(
|
||
"""
|
||
INSERT INTO run_labels (run_id, object_name, chargen_nr, excluded)
|
||
VALUES (%s, %s, %s, false)
|
||
""",
|
||
(run_id, name, code),
|
||
)
|
||
return ""
|
||
except UniqueViolation as exc:
|
||
return (exc.diag.constraint_name if exc.diag else "") or "unique"
|
||
|
||
|
||
def ensure_run_labels(conn, run_id, objects, qty=1):
|
||
"""Objekt- oder Stückzahl-Etiketten anlegen. Charge vom Lauf vorausgesetzt."""
|
||
run = get_run_row(conn, run_id)
|
||
if not run or not run.get("chargen_nr"):
|
||
return []
|
||
base = run["chargen_nr"]
|
||
existing = conn.execute(
|
||
"SELECT * FROM run_labels WHERE run_id = %s ORDER BY id",
|
||
(run_id,),
|
||
).fetchall()
|
||
known_names = {
|
||
(row["object_name"] or "").strip()
|
||
for row in existing
|
||
if (row["object_name"] or "").strip()
|
||
}
|
||
reserved = {(row["chargen_nr"] or "") for row in existing}
|
||
names = []
|
||
for item in objects or []:
|
||
if isinstance(item, dict):
|
||
name = (item.get("name") or "").strip()
|
||
excluded = bool(item.get("excluded"))
|
||
else:
|
||
name = str(item).strip()
|
||
excluded = False
|
||
if name:
|
||
names.append((name, excluded))
|
||
if not existing and not names:
|
||
count = max(1, int(qty or 1))
|
||
for index in range(count):
|
||
label_name = "" if count == 1 else "Teil %s" % (index + 1)
|
||
placed = False
|
||
for _try in range(5):
|
||
if count == 1 and _try == 0 and base not in reserved and not _label_owner(conn, base):
|
||
code = base
|
||
else:
|
||
code = _claim_object_chargen(conn, base, index + 1 + _try, reserved)
|
||
clash = _insert_run_label(conn, run_id, label_name, code)
|
||
if not clash:
|
||
reserved.add(code)
|
||
placed = True
|
||
break
|
||
reserved.add(code)
|
||
if "object_name" in clash:
|
||
break
|
||
if not placed and count == 1:
|
||
break
|
||
elif names:
|
||
index = len(known_names)
|
||
for name, excluded in names:
|
||
if excluded or name in known_names:
|
||
continue
|
||
index += 1
|
||
placed = False
|
||
for _try in range(5):
|
||
code = _claim_object_chargen(conn, base, index + _try, reserved)
|
||
clash = _insert_run_label(conn, run_id, name, code)
|
||
if not clash:
|
||
reserved.add(code)
|
||
known_names.add(name)
|
||
placed = True
|
||
break
|
||
reserved.add(code)
|
||
if "object_name" in clash:
|
||
known_names.add(name)
|
||
break
|
||
if not placed:
|
||
continue
|
||
rows = conn.execute(
|
||
"SELECT * FROM run_labels WHERE run_id = %s ORDER BY id",
|
||
(run_id,),
|
||
).fetchall()
|
||
return [
|
||
{
|
||
"object_name": row["object_name"],
|
||
"chargen_nr": row["chargen_nr"],
|
||
"excluded": bool(row["excluded"]),
|
||
}
|
||
for row in rows
|
||
]
|
||
|
||
|
||
def list_run_labels(conn, run_id):
|
||
rows = conn.execute(
|
||
"SELECT * FROM run_labels WHERE run_id = %s ORDER BY id",
|
||
(run_id,),
|
||
).fetchall()
|
||
return [
|
||
{
|
||
"object_name": row["object_name"],
|
||
"chargen_nr": row["chargen_nr"],
|
||
"excluded": bool(row["excluded"]),
|
||
}
|
||
for row in rows
|
||
]
|
||
|
||
|
||
def list_run_events(conn, run_id, limit=50):
|
||
rows = conn.execute(
|
||
"""
|
||
SELECT * FROM events
|
||
WHERE run_id = %s
|
||
ORDER BY created_at ASC, id ASC
|
||
LIMIT %s
|
||
""",
|
||
(run_id, int(limit)),
|
||
).fetchall()
|
||
return [event_out(row) for row in rows]
|
||
|
||
|
||
def exclusive_until(until):
|
||
"""Reines Datum YYYY-MM-DD zählt inklusiv: Grenze = Folgetag."""
|
||
if until is None or until == "":
|
||
return None
|
||
text = str(until).strip()
|
||
if len(text) == 10 and text[4] == "-" and text[7] == "-":
|
||
try:
|
||
day = date.fromisoformat(text)
|
||
except ValueError:
|
||
return until
|
||
return (day + timedelta(days=1)).isoformat()
|
||
return until
|
||
|
||
|
||
def statistics(conn, since=None, until=None, host="", material="", brand="", color="", project_id=None):
|
||
where = ["r.state IN ('completed', 'cancelled', 'error', 'interrupted')"]
|
||
values = []
|
||
until_bound = exclusive_until(until)
|
||
if since:
|
||
where.append("r.started_at >= %s")
|
||
values.append(since)
|
||
if until_bound:
|
||
where.append("r.started_at < %s")
|
||
values.append(until_bound)
|
||
if host:
|
||
where.append("r.printer_host = %s")
|
||
values.append(host)
|
||
if project_id:
|
||
where.append("r.project_id = %s")
|
||
values.append(project_id)
|
||
|
||
spool_join = ""
|
||
if material or brand or color:
|
||
spool_join = (
|
||
"JOIN run_spools rs0 ON rs0.run_id = r.id "
|
||
"JOIN spools s0 ON s0.id = rs0.spool_id"
|
||
)
|
||
if material:
|
||
where.append("s0.material_type ILIKE %s")
|
||
values.append(material)
|
||
if brand:
|
||
where.append("s0.brand ILIKE %s")
|
||
values.append(brand)
|
||
if color:
|
||
where.append("s0.color ILIKE %s")
|
||
values.append(color)
|
||
clause = " AND ".join(where)
|
||
|
||
overview = conn.execute(
|
||
"""
|
||
SELECT
|
||
COUNT(DISTINCT r.id) AS jobs,
|
||
COALESCE(SUM(DISTINCT r.duration_s), 0) AS duration_s,
|
||
COALESCE(SUM(DISTINCT COALESCE(NULLIF(r.weight_used_g, 0), r.weight_g)), 0) AS weight_g,
|
||
COALESCE(SUM(DISTINCT r.filament_cost + r.energy_cost), 0) AS cost,
|
||
COUNT(DISTINCT r.id) FILTER (WHERE r.state = 'completed') AS completed,
|
||
COUNT(DISTINCT r.id) FILTER (
|
||
WHERE r.state IN ('cancelled', 'error', 'interrupted')
|
||
) AS failed,
|
||
COUNT(DISTINCT r.id) FILTER (WHERE r.verdict = 'ausschuss') AS scrap,
|
||
COUNT(DISTINCT r.id) FILTER (WHERE r.verdict = 'gut') AS good
|
||
FROM runs r
|
||
%s
|
||
WHERE %s
|
||
""" % (spool_join, clause),
|
||
values,
|
||
).fetchone()
|
||
|
||
# Ohne Spulen-Join erneut (sonst doppelte Zeilen bei Mehrfarb)
|
||
run_where = ["r.state IN ('completed', 'cancelled', 'error', 'interrupted')"]
|
||
run_values = []
|
||
if since:
|
||
run_where.append("r.started_at >= %s")
|
||
run_values.append(since)
|
||
if until_bound:
|
||
run_where.append("r.started_at < %s")
|
||
run_values.append(until_bound)
|
||
if host:
|
||
run_where.append("r.printer_host = %s")
|
||
run_values.append(host)
|
||
if project_id:
|
||
run_where.append("r.project_id = %s")
|
||
run_values.append(project_id)
|
||
run_clause = " AND ".join(run_where)
|
||
pieces_row = conn.execute(
|
||
"""
|
||
SELECT
|
||
COALESCE(SUM(r.pieces_ok), 0) AS pieces_ok,
|
||
COALESCE(SUM(r.pieces_bad), 0) AS pieces_bad
|
||
FROM runs r
|
||
WHERE %s
|
||
""" % run_clause,
|
||
run_values,
|
||
).fetchone()
|
||
|
||
by_printer = conn.execute(
|
||
"""
|
||
SELECT r.printer_name, r.printer_host,
|
||
COUNT(*) AS jobs,
|
||
COALESCE(SUM(r.duration_s), 0) AS duration_s,
|
||
COALESCE(SUM(COALESCE(NULLIF(r.weight_used_g, 0), r.weight_g)), 0) AS weight_g,
|
||
COALESCE(SUM(r.filament_cost + r.energy_cost), 0) AS cost,
|
||
COUNT(*) FILTER (WHERE r.state = 'completed') AS completed
|
||
FROM runs r
|
||
WHERE %s
|
||
GROUP BY r.printer_name, r.printer_host
|
||
ORDER BY duration_s DESC
|
||
""" % run_clause,
|
||
run_values,
|
||
).fetchall()
|
||
|
||
mat_where = list(run_where)
|
||
mat_values = list(run_values)
|
||
if material:
|
||
mat_where.append("s.material_type ILIKE %s")
|
||
mat_values.append(material)
|
||
if brand:
|
||
mat_where.append("s.brand ILIKE %s")
|
||
mat_values.append(brand)
|
||
if color:
|
||
mat_where.append("s.color ILIKE %s")
|
||
mat_values.append(color)
|
||
mat_clause = " AND ".join(mat_where)
|
||
|
||
by_material = conn.execute(
|
||
"""
|
||
SELECT s.material_type, s.brand, s.color,
|
||
COALESCE(SUM(rs.weight_g), 0) AS weight_g,
|
||
COALESCE(SUM(rs.cost), 0) AS cost,
|
||
COUNT(DISTINCT rs.run_id) AS jobs
|
||
FROM run_spools rs
|
||
JOIN runs r ON r.id = rs.run_id
|
||
JOIN spools s ON s.id = rs.spool_id
|
||
WHERE %s
|
||
GROUP BY s.material_type, s.brand, s.color
|
||
ORDER BY weight_g DESC
|
||
""" % mat_clause,
|
||
mat_values,
|
||
).fetchall()
|
||
|
||
estimate = conn.execute(
|
||
"""
|
||
SELECT
|
||
percentile_cont(0.5) WITHIN GROUP (
|
||
ORDER BY r.duration_s / NULLIF(r.estimated_s, 0)
|
||
) AS median_ratio,
|
||
COUNT(*) FILTER (WHERE r.estimated_s > 0 AND r.duration_s > 0) AS counted
|
||
FROM runs r
|
||
WHERE %s AND r.state = 'completed' AND r.estimated_s > 0
|
||
""" % run_clause,
|
||
run_values,
|
||
).fetchone()
|
||
|
||
scrap_reasons = conn.execute(
|
||
"""
|
||
SELECT COALESCE(NULLIF(r.verdict_reason, ''), 'sonstiges') AS reason,
|
||
COUNT(*) AS n
|
||
FROM runs r
|
||
WHERE %s AND r.verdict = 'ausschuss'
|
||
GROUP BY 1
|
||
ORDER BY n DESC, reason
|
||
""" % run_clause,
|
||
run_values,
|
||
).fetchall()
|
||
|
||
ov = overview or {}
|
||
# Bei Spulen-Join können SUM(DISTINCT …) ungenau sein — Überblick ohne Join neu
|
||
if spool_join:
|
||
spool_filters = []
|
||
spool_vals = list(run_values)
|
||
if material:
|
||
spool_filters.append("AND s.material_type ILIKE %s")
|
||
spool_vals.append(material)
|
||
if brand:
|
||
spool_filters.append("AND s.brand ILIKE %s")
|
||
spool_vals.append(brand)
|
||
if color:
|
||
spool_filters.append("AND s.color ILIKE %s")
|
||
spool_vals.append(color)
|
||
overview2 = conn.execute(
|
||
"""
|
||
SELECT
|
||
COUNT(*) AS jobs,
|
||
COALESCE(SUM(r.duration_s), 0) AS duration_s,
|
||
COALESCE(SUM(COALESCE(NULLIF(r.weight_used_g, 0), r.weight_g)), 0) AS weight_g,
|
||
COALESCE(SUM(r.filament_cost + r.energy_cost), 0) AS cost,
|
||
COUNT(*) FILTER (WHERE r.state = 'completed') AS completed,
|
||
COUNT(*) FILTER (WHERE r.state IN ('cancelled', 'error', 'interrupted')) AS failed,
|
||
COUNT(*) FILTER (WHERE r.verdict = 'ausschuss') AS scrap,
|
||
COUNT(*) FILTER (WHERE r.verdict = 'gut') AS good
|
||
FROM runs r
|
||
WHERE %s
|
||
AND (
|
||
NOT EXISTS (SELECT 1 FROM run_spools rs WHERE rs.run_id = r.id)
|
||
OR EXISTS (
|
||
SELECT 1 FROM run_spools rs
|
||
JOIN spools s ON s.id = rs.spool_id
|
||
WHERE rs.run_id = r.id
|
||
%s
|
||
)
|
||
)
|
||
""" % (run_clause, " ".join(spool_filters)),
|
||
spool_vals,
|
||
).fetchone()
|
||
ov = overview2 or ov
|
||
|
||
pieces_ok = int((pieces_row or {}).get("pieces_ok") or 0)
|
||
pieces_bad = int((pieces_row or {}).get("pieces_bad") or 0)
|
||
pieces_total = pieces_ok + pieces_bad
|
||
return {
|
||
"overview": {
|
||
"jobs": int(ov.get("jobs") or 0),
|
||
"duration_s": float(ov.get("duration_s") or 0),
|
||
"weight_g": float(ov.get("weight_g") or 0),
|
||
"cost": float(ov.get("cost") or 0),
|
||
"completed": int(ov.get("completed") or 0),
|
||
"failed": int(ov.get("failed") or 0),
|
||
"scrap": int(ov.get("scrap") or 0),
|
||
"good": int(ov.get("good") or 0),
|
||
"pieces_ok": pieces_ok,
|
||
"pieces_bad": pieces_bad,
|
||
"yield_percent": (100.0 * pieces_ok / pieces_total) if pieces_total else None,
|
||
"cost_per_part": (float(ov.get("cost") or 0) / pieces_ok) if pieces_ok else None,
|
||
},
|
||
"by_printer": [
|
||
{
|
||
"printer_name": row["printer_name"],
|
||
"printer_host": row["printer_host"],
|
||
"jobs": int(row["jobs"] or 0),
|
||
"duration_s": float(row["duration_s"] or 0),
|
||
"weight_g": float(row["weight_g"] or 0),
|
||
"cost": float(row["cost"] or 0),
|
||
"completed": int(row["completed"] or 0),
|
||
}
|
||
for row in by_printer
|
||
],
|
||
"by_material": [
|
||
{
|
||
"material_type": row["material_type"] or "",
|
||
"brand": row["brand"] or "",
|
||
"color": row.get("color") or "",
|
||
"weight_g": float(row["weight_g"] or 0),
|
||
"cost": float(row["cost"] or 0),
|
||
"jobs": int(row["jobs"] or 0),
|
||
}
|
||
for row in by_material
|
||
],
|
||
"estimate_ratio": float((estimate or {}).get("median_ratio") or 0) or None,
|
||
"estimate_count": int((estimate or {}).get("counted") or 0),
|
||
"scrap_reasons": [
|
||
{"reason": row["reason"] or "sonstiges", "count": int(row["n"] or 0)}
|
||
for row in scrap_reasons
|
||
],
|
||
}
|
||
|
||
|
||
def idea_out(row):
|
||
return {
|
||
"id": row["id"],
|
||
"title": row["title"],
|
||
"body": row["body"],
|
||
"status": row["status"],
|
||
"created_at": iso(row["created_at"]),
|
||
}
|
||
|
||
|
||
def list_ideas(conn):
|
||
rows = conn.execute(
|
||
"""
|
||
SELECT * FROM ideas
|
||
ORDER BY CASE status WHEN 'offen' THEN 0 WHEN 'spaeter' THEN 1 ELSE 2 END, created_at DESC
|
||
"""
|
||
).fetchall()
|
||
return [idea_out(row) for row in rows]
|
||
|
||
|
||
def create_idea(conn, title, body, status):
|
||
row = conn.execute(
|
||
"""
|
||
INSERT INTO ideas (title, body, status)
|
||
VALUES (%s, %s, %s)
|
||
RETURNING *
|
||
""",
|
||
(title, body, status),
|
||
).fetchone()
|
||
return idea_out(row)
|
||
|
||
|
||
def update_idea(conn, idea_id, fields):
|
||
sets = []
|
||
values = []
|
||
for key in ("title", "body", "status"):
|
||
if key in fields:
|
||
sets.append("%s = %%s" % key)
|
||
values.append(fields[key])
|
||
if not sets:
|
||
row = conn.execute("SELECT * FROM ideas WHERE id = %s", (idea_id,)).fetchone()
|
||
return idea_out(row) if row else None
|
||
values.append(idea_id)
|
||
row = conn.execute(
|
||
"UPDATE ideas SET %s WHERE id = %%s RETURNING *" % ", ".join(sets),
|
||
values,
|
||
).fetchone()
|
||
return idea_out(row) if row else None
|
||
|
||
|
||
def delete_idea(conn, idea_id):
|
||
row = conn.execute("DELETE FROM ideas WHERE id = %s RETURNING id", (idea_id,)).fetchone()
|
||
return bool(row)
|
||
|
||
|
||
def printer_out(row):
|
||
family = row["family"] or ""
|
||
sw = row.get("start_window") or {}
|
||
if isinstance(sw, str):
|
||
try:
|
||
sw = json.loads(sw)
|
||
except json.JSONDecodeError:
|
||
sw = {}
|
||
if not isinstance(sw, dict):
|
||
sw = {}
|
||
return {
|
||
"id": str(row["id"]),
|
||
"name": row["name"],
|
||
"host": row["host"],
|
||
"family": family,
|
||
"family_label": FAMILY_LABELS.get(family, ""),
|
||
"locked": bool(row.get("locked")),
|
||
"start_window": sw,
|
||
}
|
||
|
||
|
||
def list_printers(conn):
|
||
rows = conn.execute("SELECT * FROM printers ORDER BY name").fetchall()
|
||
return [printer_out(row) for row in rows]
|
||
|
||
|
||
def printer_by_host(conn, host):
|
||
row = conn.execute("SELECT * FROM printers WHERE host = %s", (host,)).fetchone()
|
||
return printer_out(row) if row else None
|
||
|
||
|
||
def printer_by_id(conn, printer_id):
|
||
row = conn.execute("SELECT * FROM printers WHERE id = %s", (printer_id,)).fetchone()
|
||
return row
|
||
|
||
|
||
def create_printer(conn, printer_id, name, host, family):
|
||
row = conn.execute(
|
||
"""
|
||
INSERT INTO printers (id, name, host, family)
|
||
VALUES (%s, %s, %s, %s)
|
||
RETURNING *
|
||
""",
|
||
(printer_id, name, host, family),
|
||
).fetchone()
|
||
return printer_out(row)
|
||
|
||
|
||
def update_printer(conn, printer_id, name, host, family, locked=None, start_window=None):
|
||
current = printer_by_id(conn, printer_id)
|
||
if not current:
|
||
return None
|
||
if locked is None:
|
||
locked = bool(current.get("locked"))
|
||
if start_window is None:
|
||
sw = current.get("start_window") or {}
|
||
elif isinstance(start_window, dict):
|
||
sw = start_window
|
||
else:
|
||
sw = {}
|
||
if not isinstance(sw, dict):
|
||
sw = {}
|
||
row = conn.execute(
|
||
"""
|
||
UPDATE printers
|
||
SET name = %s, host = %s, family = %s, locked = %s, start_window = %s
|
||
WHERE id = %s
|
||
RETURNING *
|
||
""",
|
||
(name, host, family, bool(locked), Json(sw), printer_id),
|
||
).fetchone()
|
||
if current["host"] != host:
|
||
old = current["host"]
|
||
conn.execute("UPDATE spools SET printer_host = %s WHERE printer_host = %s", (host, old))
|
||
conn.execute("UPDATE sends SET printer_host = %s WHERE printer_host = %s", (host, old))
|
||
conn.execute(
|
||
"UPDATE runs SET printer_host = %s, printer_name = %s WHERE printer_host = %s",
|
||
(host, name, old),
|
||
)
|
||
conn.execute("UPDATE print_queue SET host = %s WHERE host = %s", (host, old))
|
||
conn.execute("UPDATE events SET host = %s, printer = %s WHERE host = %s", (host, name, old))
|
||
conn.execute(
|
||
"""
|
||
INSERT INTO bed_status (host, needs_clear, cleared_at, message)
|
||
SELECT %s, needs_clear, cleared_at, message FROM bed_status WHERE host = %s
|
||
ON CONFLICT (host) DO UPDATE SET
|
||
needs_clear = EXCLUDED.needs_clear,
|
||
cleared_at = EXCLUDED.cleared_at,
|
||
message = EXCLUDED.message
|
||
""",
|
||
(host, old),
|
||
)
|
||
conn.execute("DELETE FROM bed_status WHERE host = %s", (old,))
|
||
elif current["name"] != name:
|
||
conn.execute("UPDATE runs SET printer_name = %s WHERE printer_host = %s", (name, host))
|
||
conn.execute("UPDATE events SET printer = %s WHERE host = %s", (name, host))
|
||
return printer_out(row)
|
||
|
||
|
||
def delete_printer(conn, printer_id):
|
||
current = printer_by_id(conn, printer_id)
|
||
if not current:
|
||
return False
|
||
host = current["host"]
|
||
# Wartende Jobs dieses Hosts streichen; laufende bleiben nicht hängen.
|
||
conn.execute(
|
||
"UPDATE print_queue SET status = 'cancelled', message = 'Drucker entfernt.' WHERE host = %s AND status IN ('waiting', 'error')",
|
||
(host,),
|
||
)
|
||
conn.execute(
|
||
"UPDATE print_queue SET status = 'cancelled', message = 'Drucker entfernt.', phase = '' WHERE host = %s AND status = 'running'",
|
||
(host,),
|
||
)
|
||
conn.execute("UPDATE spools SET printer_host = '', ace_slot = NULL WHERE printer_host = %s", (host,))
|
||
conn.execute("DELETE FROM bed_status WHERE host = %s", (host,))
|
||
row = conn.execute("DELETE FROM printers WHERE id = %s RETURNING id", (printer_id,)).fetchone()
|
||
return bool(row)
|
||
|
||
|
||
def seed_printers(conn, path):
|
||
count = conn.execute("SELECT COUNT(*) AS n FROM printers").fetchone()["n"]
|
||
if count:
|
||
return 0
|
||
if not path.exists():
|
||
return 0
|
||
inserted = 0
|
||
for row in json.loads(path.read_text(encoding="utf-8")):
|
||
name = (row.get("name") or "").strip()
|
||
host = (row.get("host") or "").strip()
|
||
if not name or not host:
|
||
continue
|
||
family = row.get("family") or family_from_name(name)
|
||
create_printer(conn, uuid.uuid4(), name, host, family)
|
||
inserted += 1
|
||
return inserted
|
||
|
||
|
||
def event_out(row):
|
||
return {
|
||
"id": row["id"],
|
||
"host": row["host"] or "",
|
||
"printer": row["printer"] or "",
|
||
"kind": row["kind"],
|
||
"message": row["message"] or "",
|
||
"filename": row["filename"] or "",
|
||
"run_id": row.get("run_id"),
|
||
"created_at": iso(row["created_at"]),
|
||
"read_at": iso(row["read_at"]),
|
||
"unread": row["read_at"] is None,
|
||
}
|
||
|
||
|
||
# Protokoll-Ereignisse, die kein Alert sind: gleich als gelesen speichern.
|
||
QUIET_EVENT_KINDS = {"ablauf", "start", "fertig", "spule", "auth"}
|
||
# Was in der Sidebar als „Meldungen“ zählt.
|
||
ALERT_EVENT_KINDS = frozenset({
|
||
"fehler", "abbruch", "offline", "queue", "spule", "bedarf", "wartung", "auftrag",
|
||
})
|
||
|
||
|
||
def add_event(conn, host, printer, kind, message, filename="", run_id=None):
|
||
row = conn.execute(
|
||
"""
|
||
INSERT INTO events (host, printer, kind, message, filename, run_id, read_at)
|
||
VALUES (%s, %s, %s, %s, %s, %s, CASE WHEN %s THEN now() ELSE NULL END)
|
||
RETURNING *
|
||
""",
|
||
(
|
||
host or "",
|
||
printer or "",
|
||
kind,
|
||
message or "",
|
||
filename or "",
|
||
run_id,
|
||
kind in QUIET_EVENT_KINDS,
|
||
),
|
||
).fetchone()
|
||
return event_out(row)
|
||
|
||
|
||
def list_events(conn, limit=40, unread_only=False, host=None, kinds=None, since=None):
|
||
where = []
|
||
values = []
|
||
if unread_only:
|
||
where.append("read_at IS NULL")
|
||
if host:
|
||
where.append("host = %s")
|
||
values.append(host)
|
||
if kinds:
|
||
where.append("kind = ANY(%s)")
|
||
values.append(list(kinds))
|
||
if since:
|
||
where.append("created_at >= %s")
|
||
values.append(since)
|
||
values.append(int(limit))
|
||
rows = conn.execute(
|
||
"SELECT * FROM events %s ORDER BY created_at DESC, id DESC LIMIT %%s"
|
||
% ("WHERE " + " AND ".join(where) if where else ""),
|
||
values,
|
||
).fetchall()
|
||
return [event_out(row) for row in rows]
|
||
|
||
|
||
def unread_event_count(conn, kinds=None):
|
||
if kinds:
|
||
return conn.execute(
|
||
"SELECT COUNT(*) AS n FROM events WHERE read_at IS NULL AND kind = ANY(%s)",
|
||
(list(kinds),),
|
||
).fetchone()["n"]
|
||
return conn.execute("SELECT COUNT(*) AS n FROM events WHERE read_at IS NULL").fetchone()["n"]
|
||
|
||
|
||
def mark_events_read(conn, event_ids=None, kinds=None):
|
||
if event_ids:
|
||
conn.execute(
|
||
"UPDATE events SET read_at = now() WHERE read_at IS NULL AND id = ANY(%s)",
|
||
(list(event_ids),),
|
||
)
|
||
elif kinds:
|
||
conn.execute(
|
||
"UPDATE events SET read_at = now() WHERE read_at IS NULL AND kind = ANY(%s)",
|
||
(list(kinds),),
|
||
)
|
||
else:
|
||
conn.execute("UPDATE events SET read_at = now() WHERE read_at IS NULL")
|
||
return unread_event_count(conn, kinds=kinds or list(ALERT_EVENT_KINDS))
|
||
|
||
|
||
def recent_event_kind(conn, host, kind, within_s=60):
|
||
row = conn.execute(
|
||
"""
|
||
SELECT id FROM events
|
||
WHERE host = %s AND kind = %s AND created_at > now() - (%s || ' seconds')::interval
|
||
LIMIT 1
|
||
""",
|
||
(host or "", kind, str(int(within_s))),
|
||
).fetchone()
|
||
return bool(row)
|
||
|
||
|
||
def _json_list(value):
|
||
if isinstance(value, list):
|
||
return value
|
||
if isinstance(value, str):
|
||
try:
|
||
parsed = json.loads(value)
|
||
except json.JSONDecodeError:
|
||
return []
|
||
return parsed if isinstance(parsed, list) else []
|
||
return []
|
||
|
||
|
||
STEP_LABELS = {"print": "Drucken", "cool": "Abkühlen", "sweep": "Schieben", "check": "Tasten"}
|
||
|
||
|
||
def _step_label(step):
|
||
kind = (step or {}).get("type") if isinstance(step, dict) else ""
|
||
return STEP_LABELS.get(kind, kind or "")
|
||
|
||
|
||
def _is_legacy_mesh_check(step):
|
||
"""Altes 3×3-Prüfen (probe_count / mesh_*) — aus UI und Ablauf raus."""
|
||
if not isinstance(step, dict) or (step.get("type") or "").lower() != "check":
|
||
return False
|
||
return "probe_count" in step or "mesh_min_x" in step
|
||
|
||
|
||
def print_queue_out(row, file_name=None):
|
||
ace = _json_list(row.get("ace_map"))
|
||
steps = _json_list(row.get("steps"))
|
||
visible_idx = [i for i, step in enumerate(steps) if not _is_legacy_mesh_check(step)]
|
||
labels = [_step_label(steps[i]) for i in visible_idx]
|
||
raw_index = int(row.get("step_index") or 0)
|
||
display_index = 0
|
||
for vis_i, orig_i in enumerate(visible_idx):
|
||
if orig_i <= raw_index:
|
||
display_index = vis_i
|
||
family = row["family"] or ""
|
||
return {
|
||
"id": row["id"],
|
||
"file_id": row["file_id"],
|
||
"filename": file_name or row.get("filename") or "",
|
||
"host": row["host"] or "",
|
||
"printer_name": row.get("printer_name") or "",
|
||
"family": family,
|
||
"family_label": FAMILY_LABELS.get(family, ""),
|
||
"step_labels": labels,
|
||
"step_label_current": labels[display_index] if 0 <= display_index < len(labels) else "",
|
||
"priority": int(row["priority"] or 100),
|
||
"status": row["status"],
|
||
"spool_id": row["spool_id"],
|
||
"ace_map": ace,
|
||
"bed_leveling": bool(row["bed_leveling"]),
|
||
"override_machine": bool(row["override_machine"]),
|
||
"message": row["message"] or "",
|
||
"steps": steps,
|
||
"step_index": int(row.get("step_index") or 0),
|
||
"display_step_index": display_index,
|
||
"repeat_total": int(row.get("repeat_total") or 1),
|
||
"repeat_done": int(row.get("repeat_done") or 0),
|
||
"phase": row.get("phase") or "",
|
||
"phase_started_at": iso(row.get("phase_started_at")),
|
||
"is_ablauf": bool(steps),
|
||
"order_item_id": row.get("order_item_id"),
|
||
"retry_count": int(row.get("retry_count") or 0),
|
||
"wait_reason": row.get("wait_reason") or "",
|
||
"created_at": iso(row["created_at"]),
|
||
"updated_at": iso(row["updated_at"]),
|
||
}
|
||
|
||
|
||
def enqueue_print(
|
||
conn,
|
||
file_id,
|
||
host,
|
||
family,
|
||
priority,
|
||
spool_id,
|
||
ace_map,
|
||
bed_leveling,
|
||
override,
|
||
steps=None,
|
||
repeat_total=1,
|
||
order_item_id=None,
|
||
):
|
||
steps = steps if isinstance(steps, list) else []
|
||
try:
|
||
repeats = max(1, int(repeat_total if repeat_total is not None else 1))
|
||
except (TypeError, ValueError):
|
||
repeats = 1
|
||
row = conn.execute(
|
||
"""
|
||
INSERT INTO print_queue (
|
||
file_id, host, family, priority, spool_id, ace_map,
|
||
bed_leveling, override_machine, status, message,
|
||
steps, step_index, repeat_total, repeat_done, phase, order_item_id
|
||
)
|
||
VALUES (
|
||
%s, %s, %s, %s, %s, %s, %s, %s, 'waiting', %s,
|
||
%s, 0, %s, 0, '', %s
|
||
)
|
||
RETURNING *
|
||
""",
|
||
(
|
||
file_id,
|
||
host or "",
|
||
family or "",
|
||
int(priority if priority is not None else 100),
|
||
spool_id,
|
||
Json(ace_map if isinstance(ace_map, list) else []),
|
||
bool(bed_leveling),
|
||
bool(override),
|
||
"Ablauf wartet…" if steps else "Wartet auf freien Drucker…",
|
||
Json(steps),
|
||
repeats,
|
||
order_item_id,
|
||
),
|
||
).fetchone()
|
||
file_row = get_file_row(conn, file_id)
|
||
file_name = (file_row or {}).get("name") or ""
|
||
printer = printer_by_host(conn, host) if host else None
|
||
target = (printer or {}).get("name") or (
|
||
"nächster freier " + FAMILY_LABELS[family] if family in FAMILY_LABELS else "nächster freier Drucker"
|
||
)
|
||
what = "Ablauf (%d×)" % repeats if steps else "Druck"
|
||
add_event(
|
||
conn,
|
||
host or "",
|
||
(printer or {}).get("name") or "",
|
||
"ablauf",
|
||
"%s eingereiht: %s → %s" % (what, file_name or "Datei", target),
|
||
file_name,
|
||
)
|
||
return print_queue_out(row, file_name)
|
||
|
||
|
||
def list_print_queue(conn, limit=40):
|
||
rows = conn.execute(
|
||
"""
|
||
SELECT q.*, f.name AS filename, p.name AS printer_name
|
||
FROM print_queue q
|
||
LEFT JOIN files f ON f.id = q.file_id
|
||
LEFT JOIN printers p ON p.host = q.host AND q.host <> ''
|
||
ORDER BY
|
||
CASE q.status
|
||
WHEN 'running' THEN 0
|
||
WHEN 'waiting' THEN 1
|
||
WHEN 'error' THEN 2
|
||
ELSE 3
|
||
END,
|
||
CASE WHEN q.status IN ('waiting', 'running', 'error') THEN q.priority ELSE 0 END ASC,
|
||
CASE WHEN q.status IN ('waiting', 'running', 'error') THEN q.id ELSE 0 END ASC,
|
||
q.updated_at DESC NULLS LAST,
|
||
q.id DESC
|
||
LIMIT %s
|
||
""",
|
||
(int(limit),),
|
||
).fetchall()
|
||
return [print_queue_out(row, row.get("filename")) for row in rows]
|
||
|
||
|
||
def queue_by_host(conn, printers):
|
||
"""Laufender Queue-Eintrag und Zahl wartender Jobs je Drucker (für die Wand)."""
|
||
running = {}
|
||
for row in conn.execute(
|
||
"""
|
||
SELECT q.*, f.name AS filename, p.name AS printer_name
|
||
FROM print_queue q
|
||
LEFT JOIN files f ON f.id = q.file_id
|
||
LEFT JOIN printers p ON p.host = q.host
|
||
WHERE q.status = 'running' AND q.host <> ''
|
||
ORDER BY q.updated_at DESC
|
||
"""
|
||
).fetchall():
|
||
running.setdefault(row["host"], print_queue_out(row, row.get("filename")))
|
||
failed = {}
|
||
for row in conn.execute(
|
||
"""
|
||
SELECT q.*, f.name AS filename, p.name AS printer_name
|
||
FROM print_queue q
|
||
LEFT JOIN files f ON f.id = q.file_id
|
||
LEFT JOIN printers p ON p.host = q.host
|
||
WHERE q.status = 'error' AND q.host <> '' AND jsonb_array_length(q.steps) > 0
|
||
AND q.updated_at > now() - interval '24 hours'
|
||
ORDER BY q.updated_at DESC
|
||
"""
|
||
).fetchall():
|
||
failed.setdefault(row["host"], print_queue_out(row, row.get("filename")))
|
||
waiting_rows = conn.execute(
|
||
"""
|
||
SELECT q.host, q.family, f.name AS filename
|
||
FROM print_queue q
|
||
LEFT JOIN files f ON f.id = q.file_id
|
||
WHERE q.status = 'waiting'
|
||
ORDER BY q.priority ASC, q.id ASC
|
||
"""
|
||
).fetchall()
|
||
out = {}
|
||
for printer in printers:
|
||
host = printer.get("host") or ""
|
||
family = printer.get("family") or ""
|
||
mine = [
|
||
row for row in waiting_rows
|
||
if row["host"] == host or (not row["host"] and (not row["family"] or row["family"] == family))
|
||
]
|
||
out[host] = {
|
||
"running": running.get(host),
|
||
"failed": None if running.get(host) else failed.get(host),
|
||
"waiting": len(mine),
|
||
"waiting_next": (mine[0]["filename"] or "") if mine else "",
|
||
}
|
||
return out
|
||
|
||
|
||
def get_print_queue_row(conn, item_id):
|
||
row = conn.execute(
|
||
"""
|
||
SELECT q.*, f.name AS filename, p.name AS printer_name
|
||
FROM print_queue q
|
||
LEFT JOIN files f ON f.id = q.file_id
|
||
LEFT JOIN printers p ON p.host = q.host AND q.host <> ''
|
||
WHERE q.id = %s
|
||
""",
|
||
(item_id,),
|
||
).fetchone()
|
||
return row
|
||
|
||
|
||
def update_print_queue(conn, item_id, fields):
|
||
allowed = {
|
||
"priority": "priority",
|
||
"status": "status",
|
||
"message": "message",
|
||
"host": "host",
|
||
"step_index": "step_index",
|
||
"repeat_done": "repeat_done",
|
||
"phase": "phase",
|
||
"file_id": "file_id",
|
||
"spool_id": "spool_id",
|
||
"bed_leveling": "bed_leveling",
|
||
"override_machine": "override_machine",
|
||
"retry_count": "retry_count",
|
||
"wait_reason": "wait_reason",
|
||
}
|
||
sets = []
|
||
values = []
|
||
for key, column in allowed.items():
|
||
if key in fields:
|
||
sets.append("%s = %%s" % column)
|
||
values.append(fields[key])
|
||
if "ace_map" in fields:
|
||
sets.append("ace_map = %s")
|
||
values.append(Json(fields["ace_map"] if isinstance(fields["ace_map"], list) else []))
|
||
if "steps" in fields:
|
||
sets.append("steps = %s")
|
||
raw_steps = fields["steps"]
|
||
if isinstance(raw_steps, Json):
|
||
values.append(raw_steps)
|
||
else:
|
||
values.append(Json(raw_steps if isinstance(raw_steps, list) else []))
|
||
if "phase_started_at" in fields:
|
||
if fields["phase_started_at"] is None:
|
||
sets.append("phase_started_at = NULL")
|
||
elif fields["phase_started_at"] == "now":
|
||
sets.append("phase_started_at = now()")
|
||
else:
|
||
sets.append("phase_started_at = %s")
|
||
values.append(fields["phase_started_at"])
|
||
if not sets:
|
||
row = get_print_queue_row(conn, item_id)
|
||
return print_queue_out(row, (row or {}).get("filename")) if row else None
|
||
sets.append("updated_at = now()")
|
||
values.append(item_id)
|
||
row = conn.execute(
|
||
"UPDATE print_queue SET %s WHERE id = %%s RETURNING id" % ", ".join(sets),
|
||
values,
|
||
).fetchone()
|
||
if not row:
|
||
return None
|
||
fresh = get_print_queue_row(conn, row["id"])
|
||
return print_queue_out(fresh, fresh.get("filename")) if fresh else None
|
||
|
||
|
||
_PROFILE_VARIANTS = frozenset({"meta", "matte", "matt", "silk", "plus", "+", "hf", "cf"})
|
||
_PROFILE_TYPES = frozenset({"pla", "petg", "abs", "asa", "tpu", "pa", "pc", "hips", "pva"})
|
||
|
||
|
||
def _profile_tokens(profile):
|
||
return (profile or "").strip().lower().replace("_", " ").replace("-", " ").split()
|
||
|
||
|
||
def _profile_has_variant(profile):
|
||
return bool(_PROFILE_VARIANTS.intersection(_profile_tokens(profile)))
|
||
|
||
|
||
def _profile_base_type(profile):
|
||
"""Letzter Material-Typ im Profilnamen (Sunlu PLA → pla)."""
|
||
for token in reversed(_profile_tokens(profile)):
|
||
if token in _PROFILE_TYPES:
|
||
return token
|
||
return ""
|
||
|
||
|
||
def _profile_matches(need_profile, spool_name):
|
||
"""Sorte: exakter Katalogname (Meta ≠ Matte ≠ PLA). Zusatz nur hinter dem Namen."""
|
||
a = (need_profile or "").strip().lower()
|
||
b = (spool_name or "").strip().lower()
|
||
if not a:
|
||
return True
|
||
if not b:
|
||
return False
|
||
if a == b:
|
||
return True
|
||
# Spulen-Anzeigename: "Sunlu PLA Meta schwarz" — nicht "Sunlu PLA" ⊂ "Sunlu PLA Matte"
|
||
if b.startswith(a) and len(b) > len(a) and not b[len(a)].isalnum():
|
||
rest = b[len(a):].lstrip(" ·-–_|/")
|
||
# Nächstes Wort darf keine bekannte PLA-/PETG-Variante sein
|
||
first = (rest.split() or [""])[0]
|
||
if first in _PROFILE_VARIANTS:
|
||
return False
|
||
return True
|
||
return False
|
||
|
||
|
||
def _need_label(need):
|
||
profile = (need.get("profile") or "").strip()
|
||
if profile:
|
||
return profile
|
||
kind = (need.get("material_type") or "").strip()
|
||
color = (need.get("color") or "").strip()
|
||
return (kind + ((" " + color) if color else "")).strip() or "Material"
|
||
|
||
|
||
def _spool_material_set(conn, host):
|
||
rows = conn.execute(
|
||
"""
|
||
SELECT lower(coalesce(s.material_type, '')) AS mt,
|
||
lower(coalesce(s.color, '')) AS color,
|
||
coalesce(s.remaining_g, 0) AS remaining_g,
|
||
lower(coalesce(m.name, s.name, '')) AS material_name,
|
||
s.ace_slot AS ace_slot,
|
||
s.id AS spool_id
|
||
FROM spools s
|
||
LEFT JOIN materials m ON m.id = s.material_id
|
||
WHERE s.printer_host = %s
|
||
AND s.archived_at IS NULL
|
||
AND coalesce(s.remaining_g, 0) > 0.5
|
||
""",
|
||
(host or "",),
|
||
).fetchall()
|
||
return [
|
||
{
|
||
"material_type": (r["mt"] or "").strip(),
|
||
"color": (r["color"] or "").strip(),
|
||
"remaining_g": float(r["remaining_g"] or 0),
|
||
"material_name": (r["material_name"] or "").strip(),
|
||
"ace_slot": r["ace_slot"],
|
||
"spool_id": r["spool_id"],
|
||
}
|
||
for r in rows
|
||
]
|
||
|
||
|
||
def _file_needs(conn, file_id):
|
||
if not file_id:
|
||
return [], 0.0
|
||
row = conn.execute(
|
||
"SELECT filaments, weight_g FROM files WHERE id = %s",
|
||
(file_id,),
|
||
).fetchone()
|
||
if not row:
|
||
return [], 0.0
|
||
filaments = row.get("filaments") or []
|
||
if isinstance(filaments, str):
|
||
try:
|
||
import json
|
||
filaments = json.loads(filaments)
|
||
except Exception:
|
||
filaments = []
|
||
types = []
|
||
for index, item in enumerate(filaments if isinstance(filaments, list) else []):
|
||
if isinstance(item, dict):
|
||
t = (item.get("type") or item.get("material_type") or "").strip().lower()
|
||
c = (item.get("color") or "").strip().lower()
|
||
profile = (item.get("profile") or "").strip()
|
||
try:
|
||
paint = int(item.get("paint_index"))
|
||
except (TypeError, ValueError):
|
||
paint = index
|
||
if t or profile:
|
||
types.append({
|
||
"material_type": t,
|
||
"color": c,
|
||
"profile": profile,
|
||
"paint_index": paint,
|
||
})
|
||
elif isinstance(item, str) and item.strip():
|
||
types.append({
|
||
"material_type": item.strip().lower(),
|
||
"color": "",
|
||
"profile": "",
|
||
"paint_index": index,
|
||
})
|
||
return types, float(row.get("weight_g") or 0)
|
||
|
||
|
||
def _prefer_color(spools, need_color):
|
||
"""Farbe soft: Slice-Hex ignorieren; benannte Farbe bevorzugen, nie blockieren."""
|
||
from grotti.bedarf import _is_slicer_hex
|
||
|
||
need_c = (need_color or "").strip().lower()
|
||
if not need_c or _is_slicer_hex(need_c) or not spools:
|
||
return list(spools)
|
||
preferred = [
|
||
h for h in spools
|
||
if not (h.get("color") or "").strip() or (h.get("color") or "").strip().lower() == need_c
|
||
]
|
||
return preferred or list(spools)
|
||
|
||
|
||
def _match_spools_for_need(need, have, used_ids=None):
|
||
used = used_ids or set()
|
||
candidates = [h for h in have if h.get("spool_id") not in used]
|
||
profile = (need.get("profile") or "").strip()
|
||
|
||
def _by_type(kind):
|
||
kind = (kind or "").strip().lower()
|
||
return [
|
||
h for h in candidates
|
||
if not kind or h["material_type"] == kind
|
||
]
|
||
|
||
if profile:
|
||
by_profile = [h for h in candidates if _profile_matches(profile, h.get("material_name"))]
|
||
if by_profile:
|
||
return _prefer_color(by_profile, need.get("color"))
|
||
# Basis-Profil ohne Meta/Matte/Silk: Marke egal — Typ reicht
|
||
# (Sunlu PLA ↔ Eryone PLA Lite; Slice-Hex #26A69A blockiert nicht „Grau“).
|
||
if not _profile_has_variant(profile):
|
||
kind = _profile_base_type(profile) or (need.get("material_type") or "")
|
||
if kind:
|
||
typed = [
|
||
h for h in _by_type(kind)
|
||
if not _profile_has_variant(h.get("material_name") or "")
|
||
]
|
||
return _prefer_color(typed, need.get("color"))
|
||
return []
|
||
return _prefer_color(_by_type(need.get("material_type") or ""), need.get("color"))
|
||
|
||
|
||
def material_fit_score(conn, host, file_id):
|
||
"""Höher = besser. -1 = warten (falsche Sorte mit Profil, oder zu wenig Rest)."""
|
||
need, weight = _file_needs(conn, file_id)
|
||
have = _spool_material_set(conn, host)
|
||
if not need:
|
||
return 0, ""
|
||
if not have:
|
||
# Mit Profil: ohne Spule warten. Ohne Profil: soft (wie bisher).
|
||
if any((n.get("profile") or "").strip() for n in need):
|
||
return -1, "braucht %s" % _need_label(need[0])
|
||
return 0, ""
|
||
|
||
has_profiles = any((n.get("profile") or "").strip() for n in need)
|
||
if has_profiles:
|
||
score = 20
|
||
used = set()
|
||
for n in need:
|
||
matched = _match_spools_for_need(n, have, used)
|
||
if not matched:
|
||
return -1, "braucht %s" % _need_label(n)
|
||
best = max(matched, key=lambda h: h["remaining_g"])
|
||
used.add(best.get("spool_id"))
|
||
if n.get("color") and any(h["color"] == n["color"] for h in matched):
|
||
score += 5
|
||
if weight and best["remaining_g"] + 1e-6 >= weight:
|
||
score += 5
|
||
elif weight and all(h["remaining_g"] + 1e-6 < weight for h in matched):
|
||
return -1, "braucht %s (%s g fehlen)" % (
|
||
_need_label(n),
|
||
int(max(0, weight - max(h["remaining_g"] for h in matched))),
|
||
)
|
||
return score, ""
|
||
|
||
have_types = {h["material_type"] for h in have if h["material_type"]}
|
||
need_types = {n["material_type"] for n in need if n["material_type"]}
|
||
# Ohne Profil: falscher Typ hält den Job nicht fest (Sofort-Start warnt in der UI).
|
||
score = 0 if (need_types and have_types and need_types.isdisjoint(have_types)) else 10
|
||
for n in need:
|
||
matched = _match_spools_for_need(n, have)
|
||
if matched:
|
||
score += 5
|
||
if weight and any(h["remaining_g"] + 1e-6 >= weight for h in matched):
|
||
score += 5
|
||
elif weight and all(h["remaining_g"] + 1e-6 < weight for h in matched):
|
||
return -1, "braucht %s (%s g fehlen)" % (
|
||
_need_label(n),
|
||
int(max(0, weight - max(h["remaining_g"] for h in matched))),
|
||
)
|
||
return score, ""
|
||
|
||
|
||
def auto_ace_map(conn, host, file_id):
|
||
"""ACE-Map aus eingelegten Spulen: Sorte (Profil) zuerst, Farbe soft.
|
||
|
||
Rückgabe: (mapping, reason). reason gesetzt → nicht starten, warten.
|
||
Ohne ACE-Spulen am Host: leere Map und kein Grund (externe Rolle).
|
||
"""
|
||
need, _weight = _file_needs(conn, file_id)
|
||
have = [h for h in _spool_material_set(conn, host) if h.get("ace_slot")]
|
||
if not need:
|
||
return [], ""
|
||
if not have:
|
||
return [], ""
|
||
mapping = []
|
||
used = set()
|
||
for n in need:
|
||
matched = _match_spools_for_need(n, have, used)
|
||
# Nur ACE-Plätze
|
||
matched = [h for h in matched if h.get("ace_slot")]
|
||
if not matched:
|
||
return [], "braucht %s auf ACE" % _need_label(n)
|
||
# Farbe bevorzugen, dann Rest
|
||
matched.sort(
|
||
key=lambda h: (
|
||
0 if (n.get("color") and h["color"] == n["color"]) else 1,
|
||
-h["remaining_g"],
|
||
)
|
||
)
|
||
chosen = matched[0]
|
||
used.add(chosen.get("spool_id"))
|
||
try:
|
||
paint = int(n.get("paint_index"))
|
||
except (TypeError, ValueError):
|
||
paint = len(mapping)
|
||
ams = int(chosen["ace_slot"]) - 1
|
||
if ams < 0 or ams > 7:
|
||
return [], "ACE-Platz von %s ungültig" % _need_label(n)
|
||
mapping.append({"paint_index": paint, "ams_index": ams})
|
||
return mapping, ""
|
||
|
||
|
||
def next_waiting_print(conn, host=None, family=None):
|
||
"""Nächster wartender Druck: Termin (due_at) vor Priorität, Material-Match bevorzugt."""
|
||
if host:
|
||
rows = conn.execute(
|
||
"""
|
||
SELECT q.*, f.name AS filename, o.due_at AS order_due_at
|
||
FROM print_queue q
|
||
LEFT JOIN files f ON f.id = q.file_id
|
||
LEFT JOIN order_items oi ON oi.id = q.order_item_id
|
||
LEFT JOIN orders o ON o.id = oi.order_id
|
||
WHERE q.status = 'waiting'
|
||
AND (q.host = %s OR (q.host = '' AND (q.family = '' OR q.family = %s)))
|
||
ORDER BY
|
||
CASE WHEN o.due_at IS NULL THEN 1 ELSE 0 END,
|
||
o.due_at ASC NULLS LAST,
|
||
q.priority ASC,
|
||
q.id ASC
|
||
LIMIT 20
|
||
""",
|
||
(host, family or ""),
|
||
).fetchall()
|
||
else:
|
||
rows = conn.execute(
|
||
"""
|
||
SELECT q.*, f.name AS filename, o.due_at AS order_due_at
|
||
FROM print_queue q
|
||
LEFT JOIN files f ON f.id = q.file_id
|
||
LEFT JOIN order_items oi ON oi.id = q.order_item_id
|
||
LEFT JOIN orders o ON o.id = oi.order_id
|
||
WHERE q.status = 'waiting'
|
||
ORDER BY
|
||
CASE WHEN o.due_at IS NULL THEN 1 ELSE 0 END,
|
||
o.due_at ASC NULLS LAST,
|
||
q.priority ASC,
|
||
q.id ASC
|
||
LIMIT 20
|
||
""",
|
||
).fetchall()
|
||
if not rows:
|
||
return None
|
||
if not host:
|
||
return rows[0]
|
||
|
||
best = None
|
||
best_score = None
|
||
for row in rows:
|
||
score, reason = material_fit_score(conn, host, row.get("file_id"))
|
||
if score < 0:
|
||
# Wartegrund setzen, aber anderen Job bevorzugen
|
||
if (row.get("wait_reason") or "") != reason or (row.get("message") or "") != reason:
|
||
conn.execute(
|
||
"UPDATE print_queue SET wait_reason = %s, message = %s, updated_at = now() WHERE id = %s",
|
||
(reason, reason, row["id"]),
|
||
)
|
||
continue
|
||
if reason == "" and row.get("wait_reason"):
|
||
conn.execute(
|
||
"""
|
||
UPDATE print_queue
|
||
SET message = CASE WHEN message = wait_reason THEN '' ELSE message END,
|
||
wait_reason = '',
|
||
updated_at = now()
|
||
WHERE id = %s
|
||
""",
|
||
(row["id"],),
|
||
)
|
||
if best is None or score > best_score:
|
||
best = row
|
||
best_score = score
|
||
return best
|
||
|
||
|
||
def running_print_on_host(conn, host):
|
||
if not host:
|
||
return None
|
||
return conn.execute(
|
||
"""
|
||
SELECT q.*, f.name AS filename
|
||
FROM print_queue q
|
||
LEFT JOIN files f ON f.id = q.file_id
|
||
WHERE q.status = 'running' AND q.host = %s
|
||
ORDER BY q.updated_at DESC, q.id DESC
|
||
LIMIT 1
|
||
""",
|
||
(host,),
|
||
).fetchone()
|
||
|
||
|
||
def claim_print_queue(conn, item_id, host):
|
||
row = conn.execute(
|
||
"""
|
||
UPDATE print_queue
|
||
SET status = 'running', host = %s, message = 'Wird gestartet…',
|
||
step_index = 0, repeat_done = 0, phase = '', phase_started_at = NULL,
|
||
updated_at = now()
|
||
WHERE id = %s AND status = 'waiting'
|
||
RETURNING *
|
||
""",
|
||
(host, item_id),
|
||
).fetchone()
|
||
return row
|
||
|
||
|
||
def bed_needs_clear(conn, host):
|
||
row = conn.execute(
|
||
"SELECT needs_clear FROM bed_status WHERE host = %s",
|
||
(host or "",),
|
||
).fetchone()
|
||
return bool(row and row["needs_clear"])
|
||
|
||
|
||
def bed_clear_map(conn):
|
||
rows = conn.execute("SELECT host, needs_clear FROM bed_status").fetchall()
|
||
return {row["host"]: bool(row["needs_clear"]) for row in rows}
|
||
|
||
|
||
def set_bed_needs_clear(conn, host, needs, message=""):
|
||
host = (host or "").strip()
|
||
if not host:
|
||
return
|
||
needs = bool(needs)
|
||
conn.execute(
|
||
"""
|
||
INSERT INTO bed_status (host, needs_clear, cleared_at, message)
|
||
VALUES (%s, %s, CASE WHEN %s THEN NULL ELSE now() END, %s)
|
||
ON CONFLICT (host) DO UPDATE SET
|
||
needs_clear = EXCLUDED.needs_clear,
|
||
cleared_at = CASE WHEN EXCLUDED.needs_clear THEN NULL ELSE now() END,
|
||
message = EXCLUDED.message
|
||
""",
|
||
(host, needs, needs, message or ""),
|
||
)
|
||
|
||
|
||
def ensure_bed_after_print(conn, host, ended_at=None, message=""):
|
||
"""Platte belegen, wenn dieser Druck nach der letzten Quittierung endete.
|
||
|
||
Deckt verpasste Watcher-Übergänge ab (Neustart, Poll-Lücke): History und
|
||
erster Blick auf state=complete setzen needs_clear nur, wenn cleared_at
|
||
vor dem Druckende liegt oder noch fehlt. Ohne Endzeit und mit bestehender
|
||
Quittierung nicht neu belegen — sonst setzt ein Neustart bereits
|
||
Quittiertes wieder (cleared_at geht verloren). Live-Übergänge setzen
|
||
needs_clear direkt per set_bed_needs_clear.
|
||
"""
|
||
host = (host or "").strip()
|
||
if not host:
|
||
return False
|
||
row = conn.execute(
|
||
"SELECT needs_clear, cleared_at FROM bed_status WHERE host = %s",
|
||
(host,),
|
||
).fetchone()
|
||
if row and row["needs_clear"]:
|
||
return False
|
||
cleared = row["cleared_at"] if row else None
|
||
if ended_at is not None and cleared is not None and cleared >= ended_at:
|
||
return False
|
||
# Neustart / erster Blick ohne Endzeit: schon quittiert → stehen lassen
|
||
if ended_at is None and cleared is not None:
|
||
return False
|
||
set_bed_needs_clear(conn, host, True, message or "Druckende")
|
||
return True
|
||
|
||
|
||
def user_count(conn):
|
||
from grotti.db import cached_user_count
|
||
return cached_user_count(conn)
|
||
|
||
|
||
def list_users(conn):
|
||
return conn.execute(
|
||
"SELECT id, username, role, active, created_at, last_login FROM users ORDER BY username"
|
||
).fetchall()
|
||
|
||
|
||
def get_user(conn, user_id):
|
||
return conn.execute(
|
||
"SELECT * FROM users WHERE id = %s",
|
||
(user_id,),
|
||
).fetchone()
|
||
|
||
|
||
def get_user_by_name(conn, username):
|
||
return conn.execute(
|
||
"SELECT * FROM users WHERE lower(username) = lower(%s)",
|
||
(username,),
|
||
).fetchone()
|
||
|
||
|
||
def create_user(conn, username, password_hash, role="operator"):
|
||
return conn.execute(
|
||
"""
|
||
INSERT INTO users (username, password_hash, role)
|
||
VALUES (%s, %s, %s)
|
||
RETURNING id, username, role, active, created_at, last_login
|
||
""",
|
||
(username, password_hash, role),
|
||
).fetchone()
|
||
|
||
|
||
def update_user(conn, user_id, *, password_hash=None, role=None, active=None):
|
||
row = get_user(conn, user_id)
|
||
if not row:
|
||
return None
|
||
new_hash = password_hash if password_hash is not None else row["password_hash"]
|
||
new_role = role if role is not None else row["role"]
|
||
new_active = row["active"] if active is None else bool(active)
|
||
return conn.execute(
|
||
"""
|
||
UPDATE users
|
||
SET password_hash = %s, role = %s, active = %s
|
||
WHERE id = %s
|
||
RETURNING id, username, role, active, created_at, last_login
|
||
""",
|
||
(new_hash, new_role, new_active, user_id),
|
||
).fetchone()
|
||
|
||
|
||
def delete_user(conn, user_id):
|
||
conn.execute("DELETE FROM users WHERE id = %s", (user_id,))
|
||
|
||
|
||
def touch_user_login(conn, user_id):
|
||
conn.execute("UPDATE users SET last_login = now() WHERE id = %s", (user_id,))
|
||
|
||
|
||
def create_api_token(conn, user_id, name, token_hash, token_prefix, role, expires_at=None):
|
||
return conn.execute(
|
||
"""
|
||
INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, role, expires_at)
|
||
VALUES (%s, %s, %s, %s, %s, %s)
|
||
RETURNING id, user_id, name, token_prefix, role, created_at, last_used, expires_at, revoked_at
|
||
""",
|
||
(user_id, name or "", token_hash, token_prefix, role, expires_at),
|
||
).fetchone()
|
||
|
||
|
||
def list_api_tokens(conn, user_id=None, include_revoked=False):
|
||
sql = """
|
||
SELECT t.id, t.user_id, t.name, t.token_prefix, t.role,
|
||
t.created_at, t.last_used, t.expires_at, t.revoked_at,
|
||
u.username
|
||
FROM api_tokens t
|
||
JOIN users u ON u.id = t.user_id
|
||
WHERE 1=1
|
||
"""
|
||
args = []
|
||
if user_id is not None:
|
||
sql += " AND t.user_id = %s"
|
||
args.append(user_id)
|
||
if not include_revoked:
|
||
sql += " AND t.revoked_at IS NULL"
|
||
sql += " ORDER BY t.created_at DESC"
|
||
return conn.execute(sql, args).fetchall()
|
||
|
||
|
||
def get_api_token_by_hash(conn, token_hash):
|
||
return conn.execute(
|
||
"""
|
||
SELECT t.*, u.username, u.active AS user_active, u.role AS user_role
|
||
FROM api_tokens t
|
||
JOIN users u ON u.id = t.user_id
|
||
WHERE t.token_hash = %s
|
||
""",
|
||
(token_hash,),
|
||
).fetchone()
|
||
|
||
|
||
def touch_api_token(conn, token_id):
|
||
conn.execute("UPDATE api_tokens SET last_used = now() WHERE id = %s", (token_id,))
|
||
|
||
|
||
def revoke_api_token(conn, token_id, user_id=None):
|
||
if user_id is None:
|
||
return conn.execute(
|
||
"""
|
||
UPDATE api_tokens SET revoked_at = now()
|
||
WHERE id = %s AND revoked_at IS NULL
|
||
RETURNING id, user_id, name, token_prefix, role, created_at, last_used, expires_at, revoked_at
|
||
""",
|
||
(token_id,),
|
||
).fetchone()
|
||
return conn.execute(
|
||
"""
|
||
UPDATE api_tokens SET revoked_at = now()
|
||
WHERE id = %s AND user_id = %s AND revoked_at IS NULL
|
||
RETURNING id, user_id, name, token_prefix, role, created_at, last_used, expires_at, revoked_at
|
||
""",
|
||
(token_id, user_id),
|
||
).fetchone()
|
||
|
||
|
||
def global_search(conn, query, limit=20):
|
||
"""Drucker, Dateien, Spulen, Chargen, Aufträge für Ctrl+K."""
|
||
q = (query or "").strip()
|
||
if not q:
|
||
return []
|
||
like = "%" + q + "%"
|
||
items = []
|
||
for row in conn.execute(
|
||
"SELECT host, name FROM printers WHERE name ILIKE %s OR host ILIKE %s ORDER BY name LIMIT 5",
|
||
(like, like),
|
||
).fetchall():
|
||
items.append({
|
||
"kind": "drucker",
|
||
"title": row["name"] or row["host"],
|
||
"subtitle": row["host"],
|
||
"href": "/#" + row["host"],
|
||
})
|
||
for row in conn.execute(
|
||
"SELECT id, name FROM files WHERE name ILIKE %s ORDER BY mtime DESC NULLS LAST, id DESC LIMIT 6",
|
||
(like,),
|
||
).fetchall():
|
||
items.append({
|
||
"kind": "datei",
|
||
"title": row["name"],
|
||
"subtitle": "Datei",
|
||
"href": "/dateien?file=" + str(row["id"]),
|
||
})
|
||
for row in conn.execute(
|
||
"""
|
||
SELECT id, name, chargen_nr, code, material_type, color
|
||
FROM spools
|
||
WHERE name ILIKE %s OR chargen_nr ILIKE %s OR code ILIKE %s
|
||
OR material_type ILIKE %s OR color ILIKE %s
|
||
ORDER BY id DESC LIMIT 6
|
||
""",
|
||
(like, like, like, like, like),
|
||
).fetchall():
|
||
items.append({
|
||
"kind": "spule",
|
||
"title": row["name"],
|
||
"subtitle": " · ".join(
|
||
x for x in [row.get("chargen_nr") or row.get("code"), row.get("material_type"), row.get("color")] if x
|
||
),
|
||
"href": "/material?spool=" + str(row["id"]),
|
||
})
|
||
if q.upper().startswith("D-") or q.upper().startswith("S-"):
|
||
code = q.upper()
|
||
run = conn.execute(
|
||
"SELECT id, chargen_nr, filename FROM runs WHERE chargen_nr ILIKE %s ORDER BY id DESC LIMIT 3",
|
||
(code + "%",),
|
||
).fetchall()
|
||
for row in run:
|
||
items.append({
|
||
"kind": "charge",
|
||
"title": row["chargen_nr"],
|
||
"subtitle": row.get("filename") or "Druck",
|
||
"href": "/protokoll/" + str(row["id"]),
|
||
})
|
||
for row in conn.execute(
|
||
"SELECT id, title, status FROM orders WHERE title ILIKE %s ORDER BY updated_at DESC LIMIT 5",
|
||
(like,),
|
||
).fetchall():
|
||
items.append({
|
||
"kind": "auftrag",
|
||
"title": row["title"],
|
||
"subtitle": row["status"],
|
||
"href": "/auftraege",
|
||
})
|
||
return items[: int(limit)]
|
||
|