Files
provisioning-server/app.py
T
adminandClaude Sonnet 5 2ebecd1426 Admin-Dashboard: archivierte Geräte auch aus "letzte Aktivität" ausschließen
Live gefunden bei der Verifikation: die Geräteanzahl-Spalte filterte
schon archivierte Geräte heraus (FILTER WHERE archived_at IS NULL), die
"letzte Aktivität"-Spalte (MAX) nicht - eine Organisation mit 0 aktiven
Geräten konnte dadurch trotzdem ein Aktivitätsdatum zeigen (von einem
längst archivierten Gerät). Jetzt konsistent beide gefiltert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLk93dhDMrkPXefVdknMV9
2026-09-11 11:23:17 +02:00

5217 lines
201 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from pathlib import Path
from datetime import datetime, timedelta, timezone
import hashlib
import hmac
import json
import mimetypes
import os
import re
import secrets
import subprocess
import threading
import time
import unicodedata
import urllib.error
import urllib.request
from urllib.parse import urljoin, urlparse
from uuid import UUID, uuid4
import psycopg
from psycopg.types.json import Jsonb
from cryptography.fernet import Fernet, InvalidToken
from fastapi import FastAPI, Header, HTTPException, Request
from pydantic import BaseModel
from fastapi.responses import FileResponse
LOG_PATH = Path("activation.log")
DATABASE_URL = os.environ.get("TUXFLOTTE_DATABASE_URL")
# Standard 6h (26.08.2026-Planung) - Jitter kommt bewusst nicht von hier,
# sondern aus dem Agenten selbst (agent.py), der pro Geraet zufaellig
# streut, damit nicht alle Geraete exakt gleichzeitig einchecken.
AGENT_POLL_INTERVAL_SECONDS = int(os.environ.get("TUXFLOTTE_AGENT_POLL_INTERVAL", str(6 * 60 * 60)))
# Debug-Modus: kurzes Intervall zum Testen, faellt nach DEBUG_MODE_DURATION_HOURS
# automatisch auf AGENT_POLL_INTERVAL_SECONDS zurueck - rein durch Zeitvergleich
# bei jedem Checkin, kein Cron/Aufraeum-Job noetig (siehe agent_checkin()).
DEBUG_MODE_POLL_INTERVAL_SECONDS = 60
DEBUG_MODE_DURATION_HOURS = 6
ANSIBLE_CONTENT_REPO = os.environ.get("TUXFLOTTE_ANSIBLE_CONTENT_REPO", "")
ADMIN_TOKEN = os.environ.get("TUXFLOTTE_ADMIN_TOKEN", "")
KUNDENPLATTFORM_TOKEN = os.environ.get("TUXFLOTTE_KUNDENPLATTFORM_TOKEN", "")
AGENT_REPORTABLE_EVENT_TYPES = {"applied", "apply_failed", "removed", "remove_failed"}
# Name der pro Organisation automatisch angelegten Standard-OE (siehe
# Konfigurationsgruppen-Plan) - Landeplatz fuer neu aktivierte Geraete.
STANDARD_OE_NAME = "Neue Geräte"
# Phase 4 des Self-Service-ISO-Plans (siehe tuxflotte-installer/scripts/
# build_customer_iso.sh). TUXFLOTTE_INSTALLER_DIR ist ein separat auf anode
# geklontes Repo (nicht Teil von provisioning-server selbst), MINT_ISO_PATH
# das einmalig von der offiziellen Downloadseite geholte Quellabbild.
TUXFLOTTE_INSTALLER_DIR = os.environ.get("TUXFLOTTE_INSTALLER_DIR", "/root/tuxflotte-installer")
# Absolut aufloesen: run_iso_build() startet build_customer_iso.sh mit
# cwd=TUXFLOTTE_INSTALLER_DIR, ein relativer Pfad wuerde also dort statt
# relativ zum eigenen Arbeitsverzeichnis (WorkingDirectory des Service)
# gesucht - genau das hat den ersten Live-Test auf anode fehlschlagen lassen.
MINT_ISO_PATH = str(Path(os.environ.get("TUXFLOTTE_MINT_ISO_PATH", "data/upstream/linuxmint-22.3-cinnamon-64bit.iso")).resolve())
# Aus demselben Grund wie MINT_ISO_PATH absolut aufloesen: der als $2 an
# build_customer_iso.sh uebergebene Ausgabepfad wird von xorriso relativ
# zum Subprocess-cwd (TUXFLOTTE_INSTALLER_DIR) interpretiert, nicht relativ
# zum eigenen Arbeitsverzeichnis.
ISO_BUILD_OUTPUT_DIR = Path(os.environ.get("TUXFLOTTE_ISO_BUILD_OUTPUT_DIR", "data/iso-builds")).resolve()
# Golden Images (ADR-0024): bewusst unauthentifiziert, analog zu
# /installers/fedora-workstation/ks.cfg - werden von backend_launch()
# per curl geladen, bevor das Zielgeraet irgendein Token besitzt. Kein
# personalisierter/kundenspezifischer Inhalt (anders als ISO-Builds oben),
# nur ein generisches Basis-Root-Dateisystem pro Distribution.
GOLDEN_IMAGES_DIR = Path(os.environ.get("TUXFLOTTE_GOLDEN_IMAGES_DIR", "data/golden-images")).resolve()
# Webapp-Favicons (Phase 2): serverseitig beim Anlegen/Bearbeiten einer
# Webapp geholt und hier abgelegt (siehe fetch_favicon()), unauthentifiziert
# ausgeliefert wie die Golden Images - der Agent laedt sie vor jedem
# ansible-pull-Lauf, kennt aber (anders als Golden Images) keinen
# Aktivierungs-Kontext dafuer.
WEBAPP_ICONS_DIR = Path(os.environ.get("TUXFLOTTE_WEBAPP_ICONS_DIR", "data/webapp-icons")).resolve()
# At-Rest-Verschluesselung fuer Merkmal-Optionen (10.09.2026, Vorbereitung
# AD/OIDC/Fileserver-Merkmale mit echten Zugangsdaten) - gleiches Muster
# wie kundenplattform/encryption.py fuer den WLAN-PSK, aber eigener
# Schluessel/eigenes Modul: dort ist es ein einmaliger Transfer beim
# ISO-Bau, hier muss der Server bei JEDEM Agent-Checkin entschluesseln
# koennen, andere Betriebsanforderung. Erzeugen wie dort:
# python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
ENCRYPTION_KEY = os.environ.get("TUXFLOTTE_ENCRYPTION_KEY", "")
# Namenskonvention statt Allowlist pro Merkmal: jeder optionen-Key, der auf
# diese Endungen passt, wird transparent verschluesselt/entschluesselt -
# neue Merkmale mit einem Zugangsdaten-Feld muessen dafuer nur ihren
# optionen-Key entsprechend benennen (z.B. "ad_join_passwort"), keine
# Aenderung an dieser Stelle noetig.
SENSITIVE_OPTIONEN_SUFFIXES = ("_passwort", "_password", "_secret", "_token")
ISO_BUILD_TIMEOUT_SECONDS = 1800
# ISO-Ablauf nach 7 Tagen (Migration 0021): ergaenzt die bestehende
# "neuer Build ersetzt alten"-Aufraeumung um eine reine Zeitdimension.
ISO_BUILD_EXPIRY_DAYS = 7
ISO_BUILD_EXPIRY_SWEEP_INTERVAL_SECONDS = 60 * 60
app = FastAPI(title="Provisioning Activation Server", version="0.1.0")
@app.on_event("startup")
def start_iso_build_expiry_sweep() -> None:
threading.Thread(target=run_iso_build_expiry_sweep, daemon=True).start()
class ActivationRequest(BaseModel):
activation_code: str
device_fingerprint: str
hostname: str | None = None
machine_id: str | None = None
client_version: str | None = None
hardware: dict
class ResolveRequest(BaseModel):
device_id: str
class InstallationEventRequest(BaseModel):
"""
Meilenstein/Fehlschlag-Meldung vom Boot-Medium waehrend der Installation
(siehe device_installation_events, Migration 0025) - das Geraet hat zu
diesem Zeitpunkt noch kein agent_secret, deshalb device_fingerprint als
Nachweis statt eines Service-Tokens (gleiches Vertrauensniveau wie
/api/v1/activate selbst).
"""
device_fingerprint: str
event_type: str
detail: str | None = None
class AgentBootstrapRequest(BaseModel):
device_id: str
class AgentHealthPayload(BaseModel):
"""
Health-Metriken (26.08.2026-Planung) - alle optional, damit ein
fehlgeschlagener Einzelwert (z.B. load average auf ungewoehnlichen
Systemen) nicht den ganzen Checkin blockiert. Werte kommen direkt aus
Python-Stdlib-Aufrufen im Agenten (kein neues Paket noetig).
"""
disk_used_percent: float | None = None
ram_used_percent: float | None = None
uptime_seconds: int | None = None
load_1m: float | None = None
load_5m: float | None = None
load_15m: float | None = None
class AgentCheckinRequest(BaseModel):
device_id: str
health: AgentHealthPayload | None = None
class AgentReportEntry(BaseModel):
merkmal: str
event_type: str
detail: dict | None = None
class AgentReportRequest(BaseModel):
device_id: str
results: list[AgentReportEntry]
class AuftragSelectRequest(BaseModel):
optionen: dict = {}
class CreateOrganizationRequest(BaseModel):
name: str
class UpdateOrganizationRequest(BaseModel):
name: str
kontakt_name: str | None = None
adresse: str | None = None
telefon: str | None = None
kontakt_email: str | None = None
notizen: str | None = None
class CreateMerkmalRequest(BaseModel):
key: str
name: str
description: str | None = None
class UpdateMerkmalRequest(BaseModel):
name: str
description: str | None = None
kategorie_id: str | None = None
im_auftragskatalog: bool = False
class CreateKategorieRequest(BaseModel):
key: str
name: str
description: str | None = None
sort_order: int = 0
class CreateWorkspaceRequest(BaseModel):
key: str
name: str
description: str | None = None
organization_id: str | None = None
class CloneWorkspaceRequest(BaseModel):
organization_id: str
class CreateWebappRequest(BaseModel):
name: str
url: str
sort_order: int = 0
kategorie_id: str | None = None
kiosk_modus: bool = False
class UpdateWebappRequest(BaseModel):
name: str
url: str
sort_order: int = 0
kategorie_id: str | None = None
kiosk_modus: bool = False
class CreateWebappTemplateRequest(BaseModel):
name: str
url: str
description: str | None = None
class UpdateWebappTemplateRequest(BaseModel):
name: str
url: str
description: str | None = None
class CreateBereitstellungsvorlageRequest(BaseModel):
workspace_id: str
backend_id: str
label: str
is_default: bool = False
disk_encryption: bool = False
root_filesystem: str = "ext4"
secure_boot_required: bool = False
# Rang nach Plattengroesse (1 = groesste Platte) fuer Geraete mit
# mehreren internen Datentraegern - siehe backends/mint-image/backend.sh
# _mint_image_detect_target_disk() und platform-docs/roadmap/
# multi-distro-merkmal-workspace-creator.md, Phase 0 (08.09.2026).
target_disk_rank: int = 1
class CreateEnrollmentSessionRequest(BaseModel):
bereitstellungsvorlage_id: str
max_devices: int
expires_at: str
class RechargeEnrollmentSessionRequest(BaseModel):
additional_devices: int = 0
expires_at: str | None = None
class CreateIsoBuildRequest(BaseModel):
activation_code: str
wifi_ssid: str | None = None
wifi_psk: str | None = None
class CreateOrganisationseinheitRequest(BaseModel):
name: str
parent_id: str | None = None
class UpdateOrganisationseinheitRequest(BaseModel):
name: str
parent_id: str | None = None
class MoveDeviceOeRequest(BaseModel):
oe_id: str
class CreateGruppeRequest(BaseModel):
name: str
class UpdateGruppeRequest(BaseModel):
name: str
class SetGruppeWorkspaceRequest(BaseModel):
workspace_id: str
class SetHostnameKonventionRequest(BaseModel):
praefix: str | None = None
stellen: int = 4
nummer_modus: str = "sequenziell"
def write_log(entry: dict) -> None:
with LOG_PATH.open("a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
def get_database_connection():
if not DATABASE_URL:
raise RuntimeError("TUXFLOTTE_DATABASE_URL ist nicht gesetzt.")
return psycopg.connect(DATABASE_URL)
def load_or_create_device(
organization_id,
device_fingerprint: str,
hostname: str | None,
) -> tuple[dict, bool]:
"""
Lädt ein bekanntes Gerät oder legt es neu an.
Rückgabe:
(device, created)
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT
id,
organization_id,
device_fingerprint,
hostname,
created_at,
last_seen
FROM devices
WHERE device_fingerprint = %s
""",
(device_fingerprint,),
)
row = cur.fetchone()
if row is not None:
if str(row[1]) != str(organization_id):
raise RuntimeError(
"Das Gerät ist bereits einer anderen Organization zugeordnet."
)
# archived_at/deprovisioned_at explizit mit zuruecksetzen (nicht
# nur hostname/last_seen): live gefunden (02.09.2026) - ein per
# "Gerät löschen" archiviertes Geraet (archive_device(), Soft
# Delete) blieb nach einer frischen Neuinstallation derselben
# physischen Hardware (gleicher device_fingerprint) dauerhaft aus
# fetch_devices_for_organization() verschwunden, weil load_or_
# create_device() den bestehenden Datensatz zwar fand und
# aktualisierte, archived_at dabei aber unangetastet liess - ein
# erfolgreicher /api/v1/activate-Aufruf ist der eindeutigste
# mögliche Beweis, dass das Geraet wieder aktiv genutzt wird.
cur.execute(
"""
UPDATE devices
SET
hostname = %s,
last_seen = CURRENT_TIMESTAMP,
archived_at = NULL,
deprovisioned_at = NULL
WHERE id = %s
RETURNING
id,
organization_id,
device_fingerprint,
hostname,
created_at,
last_seen
""",
(hostname, row[0]),
)
row = cur.fetchone()
created = False
else:
device_id = uuid4()
cur.execute(
"SELECT id FROM organisationseinheiten WHERE organization_id = %s AND ist_standard",
(organization_id,),
)
standard_oe_row = cur.fetchone()
standard_oe_id = standard_oe_row[0] if standard_oe_row is not None else None
cur.execute(
"""
INSERT INTO devices (
id,
organization_id,
device_fingerprint,
hostname,
oe_id
)
VALUES (%s, %s, %s, %s, %s)
RETURNING
id,
organization_id,
device_fingerprint,
hostname,
created_at,
last_seen
""",
(
device_id,
organization_id,
device_fingerprint,
hostname,
standard_oe_id,
),
)
row = cur.fetchone()
created = True
device = {
"id": str(row[0]),
"organization_id": str(row[1]),
"device_fingerprint": row[2],
"hostname": row[3],
"created_at": row[4].isoformat(),
"last_seen": row[5].isoformat(),
}
return device, created
def store_hardware_snapshot(
device_id: str,
hardware: dict,
) -> str:
snapshot_id = uuid4()
identity = hardware.get("identity", {})
system = hardware.get("system", {})
mainboard = hardware.get("mainboard", {})
firmware = hardware.get("firmware", {})
security = hardware.get("security", {})
cpu = system.get("cpu", {})
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO hardware_snapshots (
id,
device_id,
architecture,
manufacturer,
product_name,
product_version,
system_uuid,
system_serial,
board_vendor,
board_name,
board_serial,
bios_vendor,
bios_version,
boot_mode,
secure_boot,
tpm_version,
cpu_model,
cpu_logical_count,
memory_bytes
)
VALUES (
%s, %s, %s, %s, %s,
%s, %s, %s, %s, %s,
%s, %s, %s, %s, %s,
%s, %s, %s, %s
)
""",
(
snapshot_id,
device_id,
system["architecture"],
system.get("manufacturer"),
system.get("product_name"),
system.get("product_version"),
identity.get("system_uuid"),
identity.get("system_serial"),
mainboard.get("vendor"),
mainboard.get("name"),
identity.get("board_serial"),
firmware.get("bios_vendor"),
firmware.get("bios_version"),
firmware.get("boot_mode"),
firmware.get("secure_boot"),
security.get("tpm_version"),
cpu.get("model"),
cpu.get("logical_count"),
system.get("memory_bytes"),
),
)
for interface in hardware.get("network_interfaces", []):
cur.execute(
"""
INSERT INTO network_interfaces (
id,
hardware_snapshot_id,
name,
type,
mac_address
)
VALUES (%s, %s, %s, %s, %s)
""",
(
uuid4(),
snapshot_id,
interface["name"],
interface["type"],
interface["mac"],
),
)
for storage_device in hardware.get("storage_devices", []):
cur.execute(
"""
INSERT INTO storage_devices (
id,
hardware_snapshot_id,
name,
model,
serial,
size_bytes,
transport
)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""",
(
uuid4(),
snapshot_id,
storage_device["name"],
storage_device.get("model"),
storage_device.get("serial"),
storage_device["size_bytes"],
storage_device.get("transport"),
),
)
return str(snapshot_id)
def find_activation_code(code: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT ac.organization_id, o.name
FROM activation_codes ac
JOIN organizations o ON o.id = ac.organization_id
WHERE ac.code = %s AND ac.active
""",
(code,),
)
row = cur.fetchone()
if row is None:
return None
return {"organization_id": str(row[0]), "organization_name": row[1]}
def find_enrollment_session(code: str):
"""
Enrollment Sessions haben kein eigenes code-Feld - ihre id dient direkt
als Bearer-Credential (unratbare UUID, siehe migrations/0012). Nur
gültige Sessions (nicht widerrufen, nicht abgelaufen, Kontingent nicht
erschöpft) werden zurückgegeben; jede andere Bedingung lässt /activate
denselben "invalid_activation_code"-Fehlschlag melden wie ein
ungültiger klassischer Aktivierungscode - das bricht den Installer
bereits beim Server-Handshake (Schritt 15) ab, lange vor jeder
destruktiven Aktion (siehe ADR-0008).
"""
try:
session_id = UUID(code)
except ValueError:
return None
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT es.id, es.organization_id, o.name, es.bereitstellungsvorlage_id,
es.max_devices, es.devices_used, es.expires_at, es.revoked_at
FROM enrollment_sessions es
JOIN organizations o ON o.id = es.organization_id
WHERE es.id = %s
""",
(session_id,),
)
row = cur.fetchone()
if row is None:
return None
(
session_id, organization_id, organization_name, bereitstellungsvorlage_id,
max_devices, devices_used, expires_at, revoked_at,
) = row
if revoked_at is not None:
return None
if expires_at <= datetime.now(timezone.utc):
return None
if devices_used >= max_devices:
return None
return {
"session_id": str(session_id),
"organization_id": str(organization_id),
"organization_name": organization_name,
"bereitstellungsvorlage_id": str(bereitstellungsvorlage_id),
}
def consume_enrollment_session(session_id: str) -> None:
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE enrollment_sessions SET devices_used = devices_used + 1 WHERE id = %s",
(session_id,),
)
def fetch_templates(organization_id: str) -> list[dict]:
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT
bv.id, bv.label, bv.is_default,
w.id, w.name,
b.id, b.name, b.version
FROM bereitstellungsvorlagen bv
JOIN workspaces w ON w.id = bv.workspace_id
JOIN backends b ON b.id = bv.backend_id
WHERE bv.organization_id = %s
ORDER BY bv.is_default DESC, bv.label
""",
(organization_id,),
)
rows = cur.fetchall()
return [
{
"id": str(row[0]),
"label": row[1],
"workspace": {"id": str(row[3]), "name": row[4]},
"backend": {"id": str(row[5]), "name": row[6], "version": row[7]},
"is_default": row[2],
}
for row in rows
]
def create_bereitstellungsvorlage(
organization_id: str,
workspace_id: str,
backend_id: str,
label: str,
is_default: bool,
disk_encryption: bool,
root_filesystem: str,
secure_boot_required: bool,
target_disk_rank: int = 1,
) -> dict:
"""
Bislang gab es dafuer keinen API-/UI-Weg - Bereitstellungsvorlagen kamen
nur per Seed-Migration (0005/0011) fuer die "Default Lab"-Organisation
zustande (siehe Phase 5 des Self-Service-ISO-Plans, dort als offene
Luecke notiert). partitioning bekommt bewusst nur eine einfache
"root_filesystem"-Wahl im single-scheme (siehe Migration 0015 fuer die
volle Form) - ein custom-Schema mit extra_partitions ist Sache der
direkten DB-Pflege, nicht dieses Formulars.
"""
vorlage_id = uuid4()
partitioning = {
"scheme": "single",
"root_filesystem": root_filesystem,
"target_disk_rank": target_disk_rank,
}
with get_database_connection() as conn:
with conn.cursor() as cur:
if is_default:
# Der Unique-Index bereitstellungsvorlagen_one_default_per_org
# erlaubt nur eine is_default=TRUE-Zeile je Organisation -
# bestehenden Default zuerst explizit zuruecksetzen (gleiches
# Zwei-Schritte-Muster wie Migration 0011).
cur.execute(
"UPDATE bereitstellungsvorlagen SET is_default = FALSE WHERE organization_id = %s AND is_default",
(organization_id,),
)
cur.execute(
"""
INSERT INTO bereitstellungsvorlagen
(id, organization_id, workspace_id, backend_id, label, is_default,
disk_encryption, partitioning, secure_boot_required)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
vorlage_id, organization_id, workspace_id, backend_id, label, is_default,
disk_encryption, Jsonb(partitioning), secure_boot_required,
),
)
return {"id": str(vorlage_id), "label": label, "is_default": is_default}
def require_service_token(authorization: str | None) -> bool:
"""
Akzeptiert entweder den Betreiber-Admin-Token oder das separate
Kundenplattform-Service-Token (siehe ADR-0011) - beide dürfen die
Auftragskatalog-Endpoints aufrufen, Kompromittierung/Rotation des einen
betrifft den anderen nicht.
"""
if authorization is None or not authorization.startswith("Bearer "):
return False
provided_token = authorization.removeprefix("Bearer ")
if ADMIN_TOKEN and hmac.compare_digest(provided_token, ADMIN_TOKEN):
return True
if KUNDENPLATTFORM_TOKEN and hmac.compare_digest(provided_token, KUNDENPLATTFORM_TOKEN):
return True
return False
def hash_agent_secret(secret: str) -> str:
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
def verify_agent_secret(device_id: str, provided_secret: str) -> bool:
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT agent_secret_hash FROM devices WHERE id = %s",
(device_id,),
)
row = cur.fetchone()
if row is None or row[0] is None:
return False
return hmac.compare_digest(row[0], hash_agent_secret(provided_secret))
FAVICON_FETCH_TIMEOUT_SECONDS = 5
# Grosszuegig genug fuer jedes realistische Favicon (ueblich: wenige KB bis
# niedrige zweistellige KB), verhindert aber, dass eine falsch verlinkte
# grosse Datei den Request lange blockiert oder viel Speicher belegt.
FAVICON_MAX_BYTES = 2 * 1024 * 1024
FAVICON_LINK_REGEX = re.compile(
r'<link[^>]+rel=["\'](?:shortcut icon|icon|apple-touch-icon)["\'][^>]*>',
re.IGNORECASE,
)
FAVICON_HREF_REGEX = re.compile(r'href=["\']([^"\']+)["\']', re.IGNORECASE)
def _fetch_url_bytes(url: str, max_bytes: int) -> tuple[bytes, str] | None:
"""Best-effort GET mit Groessen-/Zeitlimit. None bei jedem Fehlschlag -
Aufrufer entscheiden ueber Fallback, kein Exception-Handling noetig."""
request = urllib.request.Request(
url,
headers={"User-Agent": "Mozilla/5.0 (compatible; TuxflotteWebappIconFetcher/1.0)"},
)
try:
with urllib.request.urlopen(request, timeout=FAVICON_FETCH_TIMEOUT_SECONDS) as response:
content_type = response.headers.get_content_type()
data = response.read(max_bytes + 1)
if len(data) > max_bytes:
return None
return data, content_type
except (urllib.error.URLError, TimeoutError, ValueError):
return None
def fetch_favicon(page_url: str) -> tuple[bytes, str] | None:
"""Best-effort Favicon-Ermittlung fuer eine Webapp-URL (09.09.2026,
Phase 2): erst die Seite selbst holen und nach <link rel="icon"> (oder
"shortcut icon"/"apple-touch-icon") suchen, sonst /favicon.ico an der
Domain-Wurzel probieren. Kein HTML-Parser als Abhaengigkeit (bewusst
minimalistisch wie der Rest von provisioning-server) - einfacher
Regex reicht fuer den ueblichen Fall, deckt keine JS-generierten Icons
ab. Liefert None, wenn gar nichts gefunden wurde - der Aufrufer legt
dann kein icon_filename an, das Geraet nutzt sein Fallback-Icon."""
parsed = urlparse(page_url)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
return None
page = _fetch_url_bytes(page_url, FAVICON_MAX_BYTES)
if page is not None:
html = page[0].decode("utf-8", errors="ignore")
for link_tag in FAVICON_LINK_REGEX.findall(html):
href_match = FAVICON_HREF_REGEX.search(link_tag)
if href_match:
icon_url = urljoin(page_url, href_match.group(1))
fetched = _fetch_url_bytes(icon_url, FAVICON_MAX_BYTES)
if fetched is not None:
return fetched
fallback_url = f"{parsed.scheme}://{parsed.netloc}/favicon.ico"
return _fetch_url_bytes(fallback_url, FAVICON_MAX_BYTES)
# Phase 4 des GUI-Ausbaus (11.09.2026): Kategorie-Key -> echter Freedesktop-
# Categories=-Wert fuers .desktop-Menue einer Webapp (siehe kategorien-
# Tabelle). Unsere eigenen Kategorie-Namen sind keine gueltigen XDG-Menue-
# Kategorien, diese Abbildung uebersetzt dazwischen. Mehrfachwerte (durch
# Semikolon getrennt, mit abschliessendem Semikolon - Pflicht im
# .desktop-Format) helfen Menues, die eine Kategorie nicht kennen, noch
# eine plausible Alternative zu finden.
KATEGORIE_ZU_XDG_CATEGORIES = {
"bildung-mathe-naturwissenschaft": "Education;Science;",
"bildung-sprache-schreiben": "Education;",
"kreativ-grafik": "Graphics;",
"musik-audio": "AudioVideo;Audio;",
"buero-produktivitaet": "Office;",
"programmieren-making": "Development;",
"internet-kommunikation": "Network;",
"unterrichtswerkzeuge": "Education;",
"individuelle-systemkonfiguration": "Settings;",
"konfig-identitaet-netzwerk": "Settings;",
"konfig-system": "Settings;",
"haertung": "Settings;",
"app-store": "Network;",
}
# Unveraenderter alter Default (keine Kategorie gewaehlt) - landet wie
# bisher meist im "Internet"-Menueordner.
WEBAPP_XDG_CATEGORIES_DEFAULT = "Network;WebBrowser;"
# Kiosk-Modus (Phase 4): Chrome bevorzugt, Chromium Fallback - beide nur
# nutzbar, wenn das jeweilige Browser-Merkmal im Workspace aktiv ist
# (Nutzer-Entscheidung aus der Plan-Klaerungsrunde: "vermutlich Chrome,
# weil Chromium etwas abgespeckter ist"). Ohne einen der beiden faellt
# die .desktop-Vorlage auf den normalen xdg-open-Start zurueck.
KIOSK_BROWSER_PRIORITAET = ["google-chrome", "chromium"]
def _resolve_kiosk_browser(workspace_id: str) -> str | None:
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT m.key
FROM workspace_merkmale wm
JOIN merkmale m ON m.id = wm.merkmal_id
WHERE wm.workspace_id = %s AND m.key = ANY(%s)
""",
(workspace_id, KIOSK_BROWSER_PRIORITAET),
)
vorhandene = {row[0] for row in cur.fetchall()}
for browser in KIOSK_BROWSER_PRIORITAET:
if browser in vorhandene:
return browser
return None
def fetch_workspace_webapps(device_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT bv.workspace_id
FROM assignments a
JOIN bereitstellungsvorlagen bv ON bv.id = a.bereitstellungsvorlage_id
WHERE a.device_id = %s
""",
(device_id,),
)
assignment_row = cur.fetchone()
if assignment_row is None:
return []
(workspace_id,) = assignment_row
cur.execute(
"""
SELECT w.id, w.name, w.url, w.icon_filename, w.kiosk_modus, k.key
FROM workspace_webapps w
LEFT JOIN kategorien k ON k.id = w.kategorie_id
WHERE w.workspace_id = %s
ORDER BY w.sort_order, w.name
""",
(workspace_id,),
)
zeilen = cur.fetchall()
kiosk_browser = _resolve_kiosk_browser(workspace_id) if any(row[4] for row in zeilen) else None
return [
{
"id": str(webapp_id),
"name": name,
"url": url,
# Relativer Pfad, kein voller URL - der Server kennt
# seine eigene oeffentliche Adresse nirgends (siehe
# ANODE_URL in tuxflotte-installer/provisioning-agent,
# bewusst nur client-seitig bekannt, gleiches Muster wie
# bei GOLDEN_IMAGE_URL). Der Agent haengt ANODE_URL davor.
"icon_path": f"/webapp-icons/{icon_filename}" if icon_filename else None,
"categories": KATEGORIE_ZU_XDG_CATEGORIES.get(kategorie_key, WEBAPP_XDG_CATEGORIES_DEFAULT),
# None, wenn kein Kiosk-Modus gewuenscht ODER kein Kiosk-
# faehiger Browser im Workspace verfuegbar ist - die
# .desktop-Vorlage faellt in beiden Faellen auf den normalen
# xdg-open-Start zurueck (siehe webapp-shortcuts-Rolle).
"kiosk_browser": kiosk_browser if kiosk_modus else None,
}
for webapp_id, name, url, icon_filename, kiosk_modus, kategorie_key in zeilen
]
def fetch_assigned_blueprints(device_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT bv.workspace_id, bv.backend_id
FROM assignments a
JOIN bereitstellungsvorlagen bv ON bv.id = a.bereitstellungsvorlage_id
WHERE a.device_id = %s
""",
(device_id,),
)
assignment_row = cur.fetchone()
if assignment_row is None:
return []
workspace_id, backend_id = assignment_row
cur.execute(
"""
SELECT m.key, bp.ansible_role
FROM workspace_merkmale wm
JOIN merkmale m ON m.id = wm.merkmal_id
JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s
WHERE wm.workspace_id = %s
""",
(backend_id, workspace_id),
)
return [
{"merkmal": key, "ansible_role": ansible_role}
for key, ansible_role in cur.fetchall()
]
def _fernet() -> Fernet:
if not ENCRYPTION_KEY:
raise RuntimeError("TUXFLOTTE_ENCRYPTION_KEY ist nicht gesetzt.")
return Fernet(ENCRYPTION_KEY.encode("ascii"))
def encrypt_sensitive_optionen(optionen: dict) -> dict:
"""Verschlüsselt jeden optionen-Wert, dessen Key auf
SENSITIVE_OPTIONEN_SUFFIXES endet, bevor er in oe_merkmale/
gruppen_merkmale/device_merkmale.optionen landet - siehe
decrypt_sensitive_optionen() für die Gegenrichtung. Nur String-Werte
werden angefasst, alles andere bleibt unverändert (z.B. Booleans wie
bei brave/standardbrowser). Idempotent (bereits "enc:"-präfigierte
Werte werden nicht erneut verschlüsselt) - wichtig für
merge_optionen(), das teilweise schon verschlüsselte Altwerte mit
frischem Klartext aus einem Formular zusammenführt, bevor hier
verschlüsselt wird."""
ergebnis = dict(optionen)
for key, value in optionen.items():
if key.endswith(SENSITIVE_OPTIONEN_SUFFIXES) and isinstance(value, str) and value and not value.startswith("enc:"):
ergebnis[key] = "enc:" + _fernet().encrypt(value.encode("utf-8")).decode("ascii")
return ergebnis
def merge_optionen(bestehend: dict, neu: dict) -> dict:
"""Führt neu eingegebene optionen-Werte mit den bereits gespeicherten
zusammen - set_oe_auswahl()/set_gruppe_auswahl()/set_auftrag_selection()
ersetzen den kompletten optionen-Wert (kein natives JSONB-Merge im
UPSERT), ein leer gelassenes Passwort-Feld im Formular
(parse_optionen_form() in kundenplattform/optionen_felder.py lässt
solche Keys komplett weg) würde ohne dieses Merge ein zuvor gesetztes
Passwort stillschweigend löschen. Checkbox-Felder sind davon nicht
betroffen (die werden vom Formular immer explizit mitgeschickt)."""
ergebnis = dict(bestehend)
ergebnis.update(neu)
return ergebnis
def decrypt_sensitive_optionen(optionen: dict) -> dict:
"""Gegenrichtung zu encrypt_sensitive_optionen() - wird beim
Zusammenstellen der Checkin-Antwort aufgerufen, damit der Agent (und
darüber die Ansible-Rolle) den Klartext bekommt. Ein nicht entschlüssel-
barer Wert (z.B. nach Schlüsselwechsel) wird stillschweigend zu None -
lieber ein leeres Feld an die Rolle liefern als den ganzen Checkin mit
500 abbrechen, analog zum bestehenden Muster in
kundenplattform/encryption.py."""
ergebnis = dict(optionen)
for key, value in optionen.items():
if key.endswith(SENSITIVE_OPTIONEN_SUFFIXES) and isinstance(value, str) and value.startswith("enc:"):
try:
ergebnis[key] = _fernet().decrypt(value.removeprefix("enc:").encode("ascii")).decode("utf-8")
except InvalidToken:
ergebnis[key] = None
return ergebnis
def mask_sensitive_optionen(optionen: dict) -> dict:
"""Für Anzeige-Zwecke (Kundenportal-Katalog-Listing): sensible Werte
werden durch einen Boolean ersetzt ("ist ein Wert hinterlegt?"), nie im
Klartext oder auch nur verschlüsselt an den Browser ausgeliefert -
anders als decrypt_sensitive_optionen() für den Agenten, der den
Klartext wirklich braucht."""
ergebnis = dict(optionen)
for key, value in optionen.items():
if key.endswith(SENSITIVE_OPTIONEN_SUFFIXES):
ergebnis[key] = bool(value)
return ergebnis
def resolve_katalog_overrides(device_id: str) -> dict:
"""
Ermittelt für ein Gerät die OE-/Gruppen-Zwischenschicht des
Auftragskatalogs (siehe Konfigurationsgruppen-Plan): pro Merkmal-ID ein
(aktiv, quelle, quelle_id)-Tupel, falls die OE-Kette oder eine Gruppe eine
explizite Meinung dazu hat - fehlt der Merkmal-Key im Ergebnis, hat weder
OE-Kette noch eine Gruppe eine Meinung, der Aufrufer fällt dann auf den
Workspace-Default zurück. quelle_id ist die konkrete oe_id/gruppe_id, die
das Ergebnis geliefert hat (fürs Kundenportal, Sprung von der
Herkunfts-Anzeige zur verantwortlichen OE/Gruppe) - bei mehreren
zustimmenden Gruppen wird eine davon genannt, kein Anspruch auf
vollständige Attribution aller Beitragenden.
Reihenfolge: innerhalb der OE-Kette gewinnt die spezifischste OE
(klassische Vererbung, von der eigenen OE aus nach oben zur Wurzel
laufend - erste explizite Zeile pro Merkmal gewinnt). Das
OE-Kettenergebnis zählt danach als eine weitere Stimme neben den
Gruppen: sobald irgendeine zutreffende Stimme (OE-Kette oder eine
Gruppe) "aktiv" sagt, gewinnt aktiv ("mehr gewinnt").
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT organization_id, oe_id FROM devices WHERE id = %s",
(device_id,),
)
device_row = cur.fetchone()
if device_row is None:
return {}
organization_id, oe_id = device_row
# OE-Kette von der eigenen OE aus zur Wurzel laufen (parent_id
# in Python verfolgen statt rekursivem SQL - ein Baum von
# wenigen Dutzend OEs pro Schule passt komplett in den Speicher,
# siehe Plan).
oe_chain = []
if oe_id is not None:
cur.execute(
"SELECT id, parent_id FROM organisationseinheiten WHERE organization_id = %s",
(organization_id,),
)
parent_by_id = {row[0]: row[1] for row in cur.fetchall()}
current = oe_id
while current is not None:
oe_chain.append(current)
current = parent_by_id.get(current)
oe_aktiv_by_merkmal = {}
if oe_chain:
cur.execute(
"SELECT oe_id, merkmal_id, aktiv, optionen FROM oe_merkmale WHERE oe_id = ANY(%s)",
(oe_chain,),
)
rows_by_oe = {}
for row_oe_id, merkmal_id, aktiv, optionen in cur.fetchall():
rows_by_oe.setdefault(row_oe_id, {})[merkmal_id] = (aktiv, optionen)
for oe_id_in_chain in oe_chain:
for merkmal_id, (aktiv, optionen) in rows_by_oe.get(oe_id_in_chain, {}).items():
oe_aktiv_by_merkmal.setdefault(merkmal_id, (aktiv, oe_id_in_chain, optionen))
cur.execute(
"""
SELECT gm.merkmal_id, gm.gruppe_id, gm.aktiv, gm.optionen
FROM device_gruppen dg
JOIN gruppen_merkmale gm ON gm.gruppe_id = dg.gruppe_id
WHERE dg.device_id = %s
""",
(device_id,),
)
gruppen_stimmen_by_merkmal = {}
for merkmal_id, gruppe_id, aktiv, optionen in cur.fetchall():
gruppen_stimmen_by_merkmal.setdefault(merkmal_id, []).append((gruppe_id, aktiv, optionen))
# Fund 10.09.2026 (Vorbereitung AD/OIDC/Fileserver-Merkmale): optionen
# wurde hier bisher gar nicht mitgeführt, obwohl oe_merkmale/
# gruppen_merkmale die Spalte längst haben (set_oe_auswahl()/
# set_gruppe_auswahl() speichern sie) - nur device_merkmale.optionen kam
# je beim Gerät an. OE-/Gruppen-Ebene ist aber laut Entscheidung vom
# 09.09.2026 der eigentlich vorgesehene Ort für Merkmal-Konfiguration
# (nicht die Geräte-Einzel-Ebene) - ohne diesen Fix käme dort gesetzte
# Konfiguration (z.B. AD-Domäne) nie am Gerät an.
ergebnis = {}
for merkmal_id in set(oe_aktiv_by_merkmal) | set(gruppen_stimmen_by_merkmal):
oe_aktiv, oe_id_gewinner, oe_optionen = oe_aktiv_by_merkmal.get(merkmal_id, (None, None, None))
gruppen_stimmen = gruppen_stimmen_by_merkmal.get(merkmal_id, [])
aktive_gruppen = [(gruppe_id, optionen) for gruppe_id, aktiv, optionen in gruppen_stimmen if aktiv]
inaktive_gruppen = [gruppe_id for gruppe_id, aktiv, _optionen in gruppen_stimmen if not aktiv]
if oe_aktiv is True or aktive_gruppen:
quelle = "oe" if oe_aktiv is True else "gruppe"
quelle_id = oe_id_gewinner if oe_aktiv is True else aktive_gruppen[0][0]
optionen = oe_optionen if oe_aktiv is True else aktive_gruppen[0][1]
ergebnis[merkmal_id] = (True, quelle, quelle_id, optionen or {})
else:
quelle = "oe" if oe_aktiv is False else "gruppe"
quelle_id = oe_id_gewinner if oe_aktiv is False else inaktive_gruppen[0]
ergebnis[merkmal_id] = (False, quelle, quelle_id, {})
return ergebnis
def resolve_effective_workspace_id(cur, device_id: str, vorlage_workspace_id: str) -> str:
"""
Ermittelt den effektiv geltenden Workspace eines Geräts: normalerweise
der bei der Installation zugewiesene (über die Bereitstellungsvorlage),
außer eine Gerätegruppe des Geräts trägt einen eigenen Workspace - dann
gewinnt dieser (siehe set_gruppe_workspace(), Design-Runde 10.09.2026).
Das Backend/die Distro bleiben davon unberührt, die ändert sich nie
nachträglich - nur welcher Merkmal-Grundbestand gilt. Die
Exklusivitätsregel beim Zuweisen sorgt dafür, dass hier höchstens eine
Zeile zurückkommt.
"""
cur.execute(
"""
SELECT g.workspace_id
FROM device_gruppen dg
JOIN gruppen g ON g.id = dg.gruppe_id
WHERE dg.device_id = %s AND g.workspace_id IS NOT NULL
LIMIT 1
""",
(device_id,),
)
gruppen_row = cur.fetchone()
return gruppen_row[0] if gruppen_row is not None else vorlage_workspace_id
def fetch_auftragskatalog_state(device_id: str):
"""
Voller Soll-Zustand (present/absent) aller katalogfähigen Merkmale für
das Backend des Device (siehe ADR-0010, erweitert um OEs/Gruppen siehe
Konfigurationsgruppen-Plan). Zustandslos aus der aktuellen Auswahl
abgeleitet, keine Historie nötig.
Auflösungsreihenfolge: Workspace-Default (ggf. durch eine
workspace-tragende Gerätegruppe ersetzt, siehe
resolve_effective_workspace_id()) → OE-/Gruppen-Merkmal-Zwischenschicht
(resolve_katalog_overrides). Die frühere device_merkmale-Override-Ebene
(gewann immer) wurde am 10.09.2026 abgelöst - Einzelgerät-Ausnahmen
laufen jetzt über eine Ein-Geräte-Gruppe, also über genau dieselbe
OE-/Gruppen-Zwischenschicht wie jede andere Gruppe auch.
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT bv.workspace_id, bv.backend_id
FROM assignments a
JOIN bereitstellungsvorlagen bv ON bv.id = a.bereitstellungsvorlage_id
WHERE a.device_id = %s
""",
(device_id,),
)
assignment_row = cur.fetchone()
if assignment_row is None:
return []
vorlage_workspace_id, backend_id = assignment_row
workspace_id = resolve_effective_workspace_id(cur, device_id, vorlage_workspace_id)
cur.execute(
"""
SELECT m.id, m.key, bp.ansible_role,
(wm.merkmal_id IS NOT NULL) AS workspace_default
FROM merkmale m
JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s
LEFT JOIN workspace_merkmale wm
ON wm.merkmal_id = m.id AND wm.workspace_id = %s
WHERE m.im_auftragskatalog
""",
(backend_id, workspace_id),
)
rows = cur.fetchall()
overrides = resolve_katalog_overrides(device_id)
ergebnis = []
for merkmal_id, key, ansible_role, workspace_default in rows:
if merkmal_id in overrides:
aktiv, _quelle, _quelle_id, optionen = overrides[merkmal_id]
else:
aktiv = workspace_default
optionen = {}
ergebnis.append({
"merkmal": key,
"ansible_role": ansible_role,
"state": "present" if aktiv else "absent",
"optionen": decrypt_sensitive_optionen(optionen),
})
return ergebnis
def fetch_auftragskatalog_listing(device_id: str):
"""
Wie fetch_auftragskatalog_state, aber für die Auswahl-API angereichert um
Anzeige-relevante Felder (Name, Beschreibung, Kategorie, Herkunft der
Einstellung) statt nur die für den Agenten nötigen (Rollenname, State).
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT bv.workspace_id, bv.backend_id
FROM assignments a
JOIN bereitstellungsvorlagen bv ON bv.id = a.bereitstellungsvorlage_id
WHERE a.device_id = %s
""",
(device_id,),
)
assignment_row = cur.fetchone()
if assignment_row is None:
return None
vorlage_workspace_id, backend_id = assignment_row
workspace_id = resolve_effective_workspace_id(cur, device_id, vorlage_workspace_id)
cur.execute(
"""
SELECT m.id, m.key, m.name, m.description,
k.key, k.name, k.sort_order,
(wm.merkmal_id IS NOT NULL) AS workspace_default
FROM merkmale m
JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s
LEFT JOIN kategorien k ON k.id = m.kategorie_id
LEFT JOIN workspace_merkmale wm
ON wm.merkmal_id = m.id AND wm.workspace_id = %s
WHERE m.im_auftragskatalog
ORDER BY k.sort_order NULLS LAST, m.name
""",
(backend_id, workspace_id),
)
rows = cur.fetchall()
overrides = resolve_katalog_overrides(device_id)
ergebnis = []
for (
merkmal_id, key, name, description,
kat_key, kat_name, sort_order,
workspace_default,
) in rows:
if merkmal_id in overrides:
aktiv, quelle, quelle_id, optionen = overrides[merkmal_id]
else:
aktiv, quelle, quelle_id, optionen = workspace_default, "workspace", None, {}
ergebnis.append({
"merkmal": key,
"name": name,
"description": description,
"kategorie": (
{"key": kat_key, "name": kat_name, "sort_order": sort_order}
if kat_key is not None else None
),
"state": "present" if aktiv else "absent",
"quelle": quelle,
"quelle_id": str(quelle_id) if quelle_id is not None else None,
# Bewusst NICHT entschlüsselt (anders als fetch_auftragskatalog_state()
# für den Agenten) - für die Anzeige im Kundenportal reicht ein
# "ist gesetzt"-Hinweis, das Klartext-Passwort gehört nicht in
# eine HTML-Seite. mask_sensitive_optionen() ersetzt sensible
# Werte durch einen Boolean statt durch den (immer noch
# verschlüsselten) Rohwert.
"optionen": mask_sensitive_optionen(optionen),
})
return ergebnis
def fetch_hostname_konvention(organization_id: str) -> dict | None:
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT hostname_praefix, hostname_stellen, hostname_nummer_modus, hostname_naechste_sequenz "
"FROM organizations WHERE id = %s",
(organization_id,),
)
row = cur.fetchone()
if row is None:
return None
praefix, stellen, modus, naechste_sequenz = row
return {
"praefix": praefix,
"stellen": stellen,
"nummer_modus": modus,
"naechste_sequenz": naechste_sequenz,
}
def set_hostname_konvention(organization_id: str, praefix: str | None, stellen: int, nummer_modus: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE organizations
SET hostname_praefix = %s, hostname_stellen = %s, hostname_nummer_modus = %s
WHERE id = %s
RETURNING id
""",
(praefix or None, stellen, nummer_modus, organization_id),
)
return cur.fetchone() is not None
def generate_hostname_for_organization(cur, organization_id: str) -> str | None:
"""
Erzeugt einen Hostnamen nach der Organisations-Konvention (Design-Runde
10.09.2026, siehe ADR-0028/Roadmap) - None, wenn keine Konvention
konfiguriert ist (hostname_praefix leer), dann bleibt der vom Client
gemeldete Hostname wie bisher maßgeblich (siehe resolve_hostname_for_activation()).
"sequenziell": liest+erhöht hostname_naechste_sequenz innerhalb
derselben Transaktion wie der Aufrufer (SELECT ... FOR UPDATE sperrt
die Zeile bis zum Commit - reicht bei der heutigen Aktivierungsrate
locker aus, kein Bedarf für eine eigene DB-Sequence).
"zufaellig": wählt eine zufällige freie Nummer im Bereich
[0, 10^stellen) per secrets.randbelow() - prüft gegen bereits
vergebene Hostnamen der Organisation, bricht nach einer begrenzten
Anzahl Versuche ab und fällt dann auf die nächste freie sequenzielle
Nummer zurück (worst case bei fast vollem Nummernraum, kein
Endlos-Loop).
"""
cur.execute(
"""
SELECT hostname_praefix, hostname_stellen, hostname_nummer_modus, hostname_naechste_sequenz
FROM organizations WHERE id = %s FOR UPDATE
""",
(organization_id,),
)
row = cur.fetchone()
if row is None or not row[0]:
return None
praefix, stellen, modus, naechste_sequenz = row
obergrenze = 10 ** stellen
if modus == "zufaellig":
cur.execute(
"SELECT hostname FROM devices WHERE organization_id = %s AND hostname LIKE %s",
(organization_id, f"{praefix}-%"),
)
vergebene_nummern = set()
for (hostname,) in cur.fetchall():
suffix = hostname[len(praefix) + 1:]
if suffix.isdigit():
vergebene_nummern.add(int(suffix))
for _ in range(20):
kandidat = secrets.randbelow(obergrenze)
if kandidat not in vergebene_nummern:
return f"{praefix}-{kandidat:0{stellen}d}"
kandidat = 0
while kandidat in vergebene_nummern and kandidat < obergrenze:
kandidat += 1
return f"{praefix}-{kandidat:0{stellen}d}"
# sequenziell (Default)
cur.execute(
"UPDATE organizations SET hostname_naechste_sequenz = hostname_naechste_sequenz + 1 WHERE id = %s",
(organization_id,),
)
return f"{praefix}-{naechste_sequenz:0{stellen}d}"
def resolve_hostname_for_activation(organization_id: str, device_fingerprint: str, reported_hostname: str | None) -> str | None:
"""
Ermittelt den für die Aktivierung zu verwendenden Hostnamen: bei einem
bereits bekannten Gerät bleibt IMMER der vom Client gemeldete
maßgeblich (unverändertes Verhalten, siehe load_or_create_device() -
kein Überschreiben eines evtl. manuell umbenannten Bestandsgeräts bei
jeder Re-Aktivierung). Nur bei einem GENUIN NEUEN Gerät greift die
Organisations-Hostnamenkonvention, falls konfiguriert.
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute("SELECT 1 FROM devices WHERE device_fingerprint = %s", (device_fingerprint,))
ist_neues_geraet = cur.fetchone() is None
if not ist_neues_geraet:
return reported_hostname
generiert = generate_hostname_for_organization(cur, organization_id)
return generiert or reported_hostname
def fetch_organisationseinheiten(organization_id: str):
"""
Flache Liste aller OEs einer Organisation (inkl. parent_id) - der Baum
wird im Kundenportal-Template aus parent_id aufgebaut, nicht hier.
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, parent_id, name, ist_standard
FROM organisationseinheiten
WHERE organization_id = %s
ORDER BY name
""",
(organization_id,),
)
return [
{
"id": str(oe_id),
"parent_id": str(parent_id) if parent_id is not None else None,
"name": name,
"ist_standard": ist_standard,
}
for oe_id, parent_id, name, ist_standard in cur.fetchall()
]
def create_organisationseinheit(organization_id: str, name: str, parent_id: str | None):
"""
Legt eine neue OE an. Gibt None zurück, wenn parent_id gesetzt ist, aber
nicht zur selben Organisation gehört (verhindert Cross-Org-
Verschachtelung).
"""
oe_id = uuid4()
with get_database_connection() as conn:
with conn.cursor() as cur:
if parent_id is not None:
cur.execute(
"SELECT id FROM organisationseinheiten WHERE id = %s AND organization_id = %s",
(parent_id, organization_id),
)
if cur.fetchone() is None:
return None
cur.execute(
"""
INSERT INTO organisationseinheiten (id, organization_id, parent_id, name)
VALUES (%s, %s, %s, %s)
""",
(oe_id, organization_id, parent_id, name),
)
return {"id": str(oe_id), "parent_id": parent_id, "name": name, "ist_standard": False}
def update_organisationseinheit(oe_id: str, name: str, parent_id: str | None):
"""
Benennt um und/oder verschiebt eine OE. Gibt None zurück, wenn die OE
nicht existiert, parent_id nicht zur selben Organisation gehört, oder
die Verschiebung einen Zyklus im Baum erzeugen würde (OE würde zu ihrem
eigenen Nachfahren).
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT organization_id FROM organisationseinheiten WHERE id = %s",
(oe_id,),
)
row = cur.fetchone()
if row is None:
return None
organization_id = row[0]
if parent_id is not None:
if parent_id == oe_id:
return None
cur.execute(
"SELECT id, parent_id FROM organisationseinheiten WHERE organization_id = %s",
(organization_id,),
)
parent_by_id = {
str(r[0]): (str(r[1]) if r[1] is not None else None)
for r in cur.fetchall()
}
if parent_id not in parent_by_id:
return None
current = parent_id
while current is not None:
if current == oe_id:
return None
current = parent_by_id.get(current)
cur.execute(
"""
UPDATE organisationseinheiten
SET name = %s, parent_id = %s
WHERE id = %s
RETURNING id, parent_id, name, ist_standard
""",
(name, parent_id, oe_id),
)
updated = cur.fetchone()
return {
"id": str(updated[0]),
"parent_id": str(updated[1]) if updated[1] is not None else None,
"name": updated[2],
"ist_standard": updated[3],
}
def delete_organisationseinheit(oe_id: str):
"""
Löscht eine OE. Gibt (True, None) bei Erfolg zurück, sonst
(False, Fehlermeldung) - die Standard-OE, OEs mit Kind-OEs und OEs mit
zugeordneten Geräten sind geschützt (freundliche Fehlermeldung statt dem
rohen DB-RESTRICT-Fehler).
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT ist_standard FROM organisationseinheiten WHERE id = %s",
(oe_id,),
)
row = cur.fetchone()
if row is None:
return False, "Die OE wurde nicht gefunden."
if row[0]:
return False, "Die Standard-OE 'Neue Geräte' kann nicht gelöscht werden."
cur.execute("SELECT count(*) FROM organisationseinheiten WHERE parent_id = %s", (oe_id,))
if cur.fetchone()[0] > 0:
return False, "Die OE hat noch untergeordnete OEs - diese zuerst verschieben oder löschen."
cur.execute("SELECT count(*) FROM devices WHERE oe_id = %s", (oe_id,))
if cur.fetchone()[0] > 0:
return False, "Der OE sind noch Geräte zugeordnet - diese zuerst verschieben."
cur.execute("DELETE FROM organisationseinheiten WHERE id = %s", (oe_id,))
return True, None
def set_oe_auswahl(oe_id: str, merkmal_key: str, aktiv: bool, optionen: dict):
"""Setzt die OE-weite Auswahl eines katalogfähigen Merkmals (Upsert in oe_merkmale), analog zu set_auftrag_selection."""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT id FROM merkmale WHERE key = %s AND im_auftragskatalog",
(merkmal_key,),
)
merkmal_row = cur.fetchone()
if merkmal_row is None:
return False
merkmal_id = merkmal_row[0]
# Merge statt Ersetzen, siehe set_auftrag_selection() für die
# ausführliche Begründung (leeres Passwort-Feld != löschen).
cur.execute(
"SELECT optionen FROM oe_merkmale WHERE oe_id = %s AND merkmal_id = %s",
(oe_id, merkmal_id),
)
bestehend_row = cur.fetchone()
optionen = encrypt_sensitive_optionen(
merge_optionen(bestehend_row[0] if bestehend_row else {}, optionen)
)
cur.execute(
"""
INSERT INTO oe_merkmale (oe_id, merkmal_id, aktiv, optionen)
VALUES (%s, %s, %s, %s)
ON CONFLICT (oe_id, merkmal_id) DO UPDATE
SET aktiv = EXCLUDED.aktiv, optionen = EXCLUDED.optionen
""",
(oe_id, merkmal_id, aktiv, Jsonb(optionen)),
)
return True
def clear_oe_auswahl(oe_id: str, merkmal_key: str):
"""Entfernt eine explizite OE-Einstellung wieder komplett (fällt danach auf die nächste Schicht zurück, statt nur auf true/false zu wechseln)."""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT id FROM merkmale WHERE key = %s AND im_auftragskatalog",
(merkmal_key,),
)
merkmal_row = cur.fetchone()
if merkmal_row is None:
return False
cur.execute(
"DELETE FROM oe_merkmale WHERE oe_id = %s AND merkmal_id = %s",
(oe_id, merkmal_row[0]),
)
return True
def fetch_oe_merkmale_listing(oe_id: str):
"""
Für den OE-Merkmale-Editor im Kundenportal: alle katalogfähigen
Merkmale mit dem aktuellen, expliziten Stand dieser OE - aktiv (True),
inaktiv (False) oder nicht festgelegt (None, keine oe_merkmale-Zeile).
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT m.key, m.name, m.description,
k.key, k.name, k.sort_order,
om.aktiv, COALESCE(om.optionen, '{}'::jsonb)
FROM merkmale m
LEFT JOIN kategorien k ON k.id = m.kategorie_id
LEFT JOIN oe_merkmale om ON om.merkmal_id = m.id AND om.oe_id = %s
WHERE m.im_auftragskatalog
ORDER BY k.sort_order NULLS LAST, m.name
""",
(oe_id,),
)
return [
{
"merkmal": key,
"name": name,
"description": description,
"kategorie": (
{"key": kat_key, "name": kat_name, "sort_order": sort_order}
if kat_key is not None else None
),
"aktiv": aktiv,
# Wie im Auftragskatalog-Listing bewusst maskiert (siehe
# mask_sensitive_optionen()) - nie Klartext/verschlüsselten
# Rohwert an den Browser.
"optionen": mask_sensitive_optionen(optionen),
}
for key, name, description, kat_key, kat_name, sort_order, aktiv, optionen in cur.fetchall()
]
def move_device_to_oe(device_id: str, oe_id: str):
"""
Verschiebt ein Gerät in eine andere OE derselben Organisation. Gibt
False zurück, wenn Gerät/OE nicht existieren oder zu unterschiedlichen
Organisationen gehören.
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE devices
SET oe_id = %s
WHERE id = %s
AND organization_id = (SELECT organization_id FROM organisationseinheiten WHERE id = %s)
RETURNING id
""",
(oe_id, device_id, oe_id),
)
return cur.fetchone() is not None
def fetch_gruppen(organization_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT g.id, g.name, g.workspace_id, w.name
FROM gruppen g
LEFT JOIN workspaces w ON w.id = g.workspace_id
WHERE g.organization_id = %s
ORDER BY g.name
""",
(organization_id,),
)
return [
{
"id": str(gruppe_id),
"name": name,
"workspace_id": str(workspace_id) if workspace_id else None,
"workspace_name": workspace_name,
}
for gruppe_id, name, workspace_id, workspace_name in cur.fetchall()
]
def create_gruppe(organization_id: str, name: str):
gruppe_id = uuid4()
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO gruppen (id, organization_id, name) VALUES (%s, %s, %s)",
(gruppe_id, organization_id, name),
)
return {"id": str(gruppe_id), "name": name}
def update_gruppe(gruppe_id: str, name: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE gruppen SET name = %s WHERE id = %s RETURNING id, name",
(name, gruppe_id),
)
row = cur.fetchone()
if row is None:
return None
return {"id": str(row[0]), "name": row[1]}
def delete_gruppe(gruppe_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute("DELETE FROM gruppen WHERE id = %s RETURNING id", (gruppe_id,))
return cur.fetchone() is not None
def set_gruppe_workspace(gruppe_id: str, workspace_id: str):
"""
Weist einer Gerätegruppe einen alternativen Workspace zu (Design-Runde
10.09.2026, siehe project_phase2_app_store_merkmale-Memory) - ersetzt
für Mitgliedsgeräte die komplette Workspace-Baseline (nicht nur
einzelne Merkmale, dafür bleibt gruppen_merkmale zuständig). Ein
Gerät kann gleichzeitig nur in EINER workspace-tragenden Gerätegruppe
sein (bewusste Nutzer-Entscheidung, verhindert widersprüchliche
Workspace-Wahl statt einer Prioritätsregel) - deshalb hier zusätzlich
zur Organisationszugehörigkeit geprüft: kein Mitgliedsgerät der
Gruppe darf schon in einer ANDEREN workspace-tragenden Gruppe sein.
Rückgabe: (True, None) bei Erfolg, sonst (False, Fehlercode).
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT organization_id FROM gruppen WHERE id = %s",
(gruppe_id,),
)
gruppe_row = cur.fetchone()
if gruppe_row is None:
return False, "gruppe_not_found"
gruppe_org_id = gruppe_row[0]
cur.execute(
"SELECT organization_id FROM workspaces WHERE id = %s",
(workspace_id,),
)
workspace_row = cur.fetchone()
if workspace_row is None:
return False, "workspace_not_found"
# NULL organization_id = globaler Standard-Workspace (siehe
# workspaces-Schema), den jede Organisation nutzen darf. Ist er
# gesetzt, muss er zur eigenen Organisation gehören - ein
# fremder, privater Workspace einer anderen Organisation darf
# nicht ausgewählt werden können.
workspace_org_id = workspace_row[0]
if workspace_org_id is not None and workspace_org_id != gruppe_org_id:
return False, "workspace_wrong_organization"
cur.execute(
"""
SELECT DISTINCT d.id, d.hostname, g2.name
FROM device_gruppen dg
JOIN devices d ON d.id = dg.device_id
JOIN gruppen g2 ON g2.id = dg.gruppe_id
WHERE dg.device_id IN (
SELECT device_id FROM device_gruppen WHERE gruppe_id = %s
)
AND g2.id != %s
AND g2.workspace_id IS NOT NULL
""",
(gruppe_id, gruppe_id),
)
konflikt_row = cur.fetchone()
if konflikt_row is not None:
return False, "workspace_conflict"
cur.execute(
"UPDATE gruppen SET workspace_id = %s WHERE id = %s",
(workspace_id, gruppe_id),
)
return True, None
def clear_gruppe_workspace(gruppe_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE gruppen SET workspace_id = NULL WHERE id = %s RETURNING id",
(gruppe_id,),
)
return cur.fetchone() is not None
def set_gruppe_auswahl(gruppe_id: str, merkmal_key: str, aktiv: bool, optionen: dict):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT id FROM merkmale WHERE key = %s AND im_auftragskatalog",
(merkmal_key,),
)
merkmal_row = cur.fetchone()
if merkmal_row is None:
return False
merkmal_id = merkmal_row[0]
# Merge statt Ersetzen, siehe set_auftrag_selection() für die
# ausführliche Begründung (leeres Passwort-Feld != löschen).
cur.execute(
"SELECT optionen FROM gruppen_merkmale WHERE gruppe_id = %s AND merkmal_id = %s",
(gruppe_id, merkmal_id),
)
bestehend_row = cur.fetchone()
optionen = encrypt_sensitive_optionen(
merge_optionen(bestehend_row[0] if bestehend_row else {}, optionen)
)
cur.execute(
"""
INSERT INTO gruppen_merkmale (gruppe_id, merkmal_id, aktiv, optionen)
VALUES (%s, %s, %s, %s)
ON CONFLICT (gruppe_id, merkmal_id) DO UPDATE
SET aktiv = EXCLUDED.aktiv, optionen = EXCLUDED.optionen
""",
(gruppe_id, merkmal_id, aktiv, Jsonb(optionen)),
)
return True
def clear_gruppe_auswahl(gruppe_id: str, merkmal_key: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT id FROM merkmale WHERE key = %s AND im_auftragskatalog",
(merkmal_key,),
)
merkmal_row = cur.fetchone()
if merkmal_row is None:
return False
cur.execute(
"DELETE FROM gruppen_merkmale WHERE gruppe_id = %s AND merkmal_id = %s",
(gruppe_id, merkmal_row[0]),
)
return True
def fetch_gruppe_merkmale_listing(gruppe_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT m.key, m.name, m.description,
k.key, k.name, k.sort_order,
gm.aktiv, COALESCE(gm.optionen, '{}'::jsonb)
FROM merkmale m
LEFT JOIN kategorien k ON k.id = m.kategorie_id
LEFT JOIN gruppen_merkmale gm ON gm.merkmal_id = m.id AND gm.gruppe_id = %s
WHERE m.im_auftragskatalog
ORDER BY k.sort_order NULLS LAST, m.name
""",
(gruppe_id,),
)
return [
{
"merkmal": key,
"name": name,
"description": description,
"kategorie": (
{"key": kat_key, "name": kat_name, "sort_order": sort_order}
if kat_key is not None else None
),
"aktiv": aktiv,
"optionen": mask_sensitive_optionen(optionen),
}
for key, name, description, kat_key, kat_name, sort_order, aktiv, optionen in cur.fetchall()
]
def add_device_to_gruppe(device_id: str, gruppe_id: str):
"""
Fügt ein Gerät einer Gruppe hinzu (idempotent). Gibt False zurück, wenn
Gerät und Gruppe nicht zur selben Organisation gehören, oder "conflict",
wenn die Zielgruppe einen Workspace trägt und das Gerät bereits in einer
ANDEREN workspace-tragenden Gruppe ist (siehe set_gruppe_workspace() für
die Begründung der Exklusivitätsregel).
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT d.organization_id, g.organization_id, g.workspace_id
FROM devices d, gruppen g
WHERE d.id = %s AND g.id = %s
""",
(device_id, gruppe_id),
)
row = cur.fetchone()
if row is None or row[0] != row[1]:
return False
ziel_workspace_id = row[2]
if ziel_workspace_id is not None:
cur.execute(
"""
SELECT 1
FROM device_gruppen dg
JOIN gruppen g ON g.id = dg.gruppe_id
WHERE dg.device_id = %s AND g.workspace_id IS NOT NULL AND g.id != %s
""",
(device_id, gruppe_id),
)
if cur.fetchone() is not None:
return "conflict"
cur.execute(
"""
INSERT INTO device_gruppen (device_id, gruppe_id)
VALUES (%s, %s)
ON CONFLICT DO NOTHING
""",
(device_id, gruppe_id),
)
return True
def remove_device_from_gruppe(device_id: str, gruppe_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM device_gruppen WHERE device_id = %s AND gruppe_id = %s",
(device_id, gruppe_id),
)
return True
def fetch_device_gruppen(device_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT g.id, g.name
FROM device_gruppen dg
JOIN gruppen g ON g.id = dg.gruppe_id
WHERE dg.device_id = %s
ORDER BY g.name
""",
(device_id,),
)
return [{"id": str(gruppe_id), "name": name} for gruppe_id, name in cur.fetchall()]
# Debug-Modus + Health-Spalten (26.08.2026-Planung) - gemeinsam von
# fetch_devices_for_organization() und fetch_all_devices() gebraucht, daher
# als Konstante statt zweimal ausgeschrieben.
GERAETE_HEALTH_SPALTEN_SQL = """d.debug_mode_until, d.health_disk_used_percent, d.health_ram_used_percent,
d.health_uptime_seconds, d.health_load_1m, d.health_load_5m, d.health_load_15m,
d.health_collected_at"""
def _health_felder_aus_row(debug_mode_until, disk, ram, uptime, load1, load5, load15, collected_at) -> dict:
# Live gefunden (11.09.2026): debug_mode_until wird beim Ablaufen nie
# in der DB geloescht (agent_checkin() vergleicht nur zur Laufzeit
# gegen "jetzt", um das Poll-Intervall zu bestimmen - das bleibt
# korrekt) - aber die Kundenplattform-Anzeige (Auftragskatalog-Seite,
# Geraeteliste) pruefte bisher nur "ist das Feld gesetzt", nicht "liegt
# es noch in der Zukunft" - ein laengst abgelaufener Debug-Modus
# wurde dadurch dauerhaft als "noch aktiv" angezeigt. Hier an der
# Quelle gefiltert, damit kein Aufrufer das selbst wissen muss.
ist_noch_aktiv = debug_mode_until is not None and debug_mode_until > datetime.now(timezone.utc)
return {
"debug_mode_until": debug_mode_until.isoformat() if ist_noch_aktiv else None,
"health": {
"disk_used_percent": disk,
"ram_used_percent": ram,
"uptime_seconds": uptime,
"load_1m": load1,
"load_5m": load5,
"load_15m": load15,
"collected_at": collected_at.isoformat() if collected_at is not None else None,
} if collected_at is not None else None,
}
def fetch_devices_for_organization(organization_id: str):
"""
Für Kundenplattforms Geräteliste + Besitz-Validierung (siehe ADR-0011):
alle Geräte einer Organisation, unabhängig von Bereitstellungsvorlagen-
Zuweisung.
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
f"""
SELECT d.id, d.hostname, d.device_fingerprint, d.agent_last_checkin,
d.deprovisioned_at, d.oe_id, oe.name, w.name, {GERAETE_HEALTH_SPALTEN_SQL}
FROM devices d
LEFT JOIN organisationseinheiten oe ON oe.id = d.oe_id
LEFT JOIN assignments a ON a.device_id = d.id
LEFT JOIN bereitstellungsvorlagen bv ON bv.id = a.bereitstellungsvorlage_id
LEFT JOIN workspaces w ON w.id = bv.workspace_id
WHERE d.organization_id = %s AND d.archived_at IS NULL
ORDER BY d.hostname NULLS LAST, d.created_at
""",
(organization_id,),
)
return [
{
"id": str(device_id),
"hostname": hostname,
"device_fingerprint": fingerprint,
"agent_last_checkin": (
last_checkin.isoformat() if last_checkin is not None else None
),
"deprovisioned_at": (
deprovisioned_at.isoformat() if deprovisioned_at is not None else None
),
"oe_id": str(oe_id) if oe_id is not None else None,
"oe_name": oe_name,
# Install-time zugewiesener Workspace (GUI-Umbau
# 10.09.2026, Geräteliste-Spalte) - bei einer nachträglichen
# Gerätegruppen-Workspace-Übersteuerung (ADR-0028) weicht
# der tatsächlich wirksame Katalog davon ab, für den
# genauen Stand siehe der Auftragskatalog-Seite des Geräts.
"workspace_name": workspace_name,
**_health_felder_aus_row(*health_spalten),
}
for (
device_id, hostname, fingerprint, last_checkin, deprovisioned_at, oe_id, oe_name,
workspace_name,
*health_spalten,
) in cur.fetchall()
]
def fetch_all_devices():
"""
Organisationsübergreifende Geräteliste für den Kundenplattform-Admin-
Bereich (Verwaltung -> Geräte) - Pendant zu fetch_devices_for_organization,
ohne Org-Filter, mit Organisationsname gejoint.
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
f"""
SELECT d.id, d.hostname, d.device_fingerprint, d.agent_last_checkin,
d.organization_id, o.name, d.deprovisioned_at, d.oe_id, oe.name, w.name,
{GERAETE_HEALTH_SPALTEN_SQL}
FROM devices d
JOIN organizations o ON o.id = d.organization_id
LEFT JOIN organisationseinheiten oe ON oe.id = d.oe_id
LEFT JOIN assignments a ON a.device_id = d.id
LEFT JOIN bereitstellungsvorlagen bv ON bv.id = a.bereitstellungsvorlage_id
LEFT JOIN workspaces w ON w.id = bv.workspace_id
WHERE d.archived_at IS NULL
ORDER BY o.name, d.hostname NULLS LAST, d.created_at
"""
)
return [
{
"id": str(device_id),
"hostname": hostname,
"device_fingerprint": fingerprint,
"agent_last_checkin": (
last_checkin.isoformat() if last_checkin is not None else None
),
"organization_id": str(organization_id),
"organization_name": organization_name,
"deprovisioned_at": (
deprovisioned_at.isoformat() if deprovisioned_at is not None else None
),
"oe_id": str(oe_id) if oe_id is not None else None,
"oe_name": oe_name,
"workspace_name": workspace_name,
**_health_felder_aus_row(*health_spalten),
}
for (
device_id, hostname, fingerprint, last_checkin,
organization_id, organization_name, deprovisioned_at,
oe_id, oe_name, workspace_name,
*health_spalten,
) in cur.fetchall()
]
# Phase 5 des GUI-Ausbaus (11.09.2026, siehe
# project_kundenplattform_gui_umbau_2026-09-11_dashboard_workspace-Memory):
# Dashboard v1, ausschliesslich aus bereits vorhandenen Daten (kein neuer
# Agent-Datenerfassungsmechanismus - der Agent meldet schon länger Platten-/
# RAM-Auslastung + Load-Average bei jedem Check-in, siehe
# GERAETE_HEALTH_SPALTEN_SQL/agent_checkin() weiter oben). Fehlende
# Festplattenfehler-/SMART-Daten bleiben eine bekannte, bewusst hier NICHT
# geschlossene Lücke (Nutzer-Rückfrage-Runde, siehe Plan).
DASHBOARD_KEIN_CHECKIN_TAGE = 7
DASHBOARD_DISK_KRITISCH_PROZENT = 85
DASHBOARD_RAM_KRITISCH_PROZENT = 85
DASHBOARD_LETZTE_AKTIONEN_LIMIT = 10
def _dashboard_flotten_gesundheit(geraete_rows) -> dict:
"""
Gemeinsame Auswertung fuer Kunden- und Admin-Dashboard: geraete_rows ist
eine Liste von (hostname, agent_last_checkin, disk_prozent, ram_prozent)
-Tupeln, optional mit vorangestelltem organisationsnamen (siehe Aufrufer).
"""
schwelle = datetime.now(timezone.utc) - timedelta(days=DASHBOARD_KEIN_CHECKIN_TAGE)
ohne_checkin = []
disk_kritisch = []
ram_kritisch = []
for row in geraete_rows:
*praefix, hostname, last_checkin, disk, ram = row
anzeige_name = praefix[0] + " – " + (hostname or "unbenannt") if praefix else (hostname or "unbenannt")
if last_checkin is None or last_checkin < schwelle:
ohne_checkin.append({
"name": anzeige_name,
"agent_last_checkin": last_checkin.isoformat() if last_checkin else None,
})
if disk is not None and disk >= DASHBOARD_DISK_KRITISCH_PROZENT:
disk_kritisch.append({"name": anzeige_name, "wert": round(disk)})
if ram is not None and ram >= DASHBOARD_RAM_KRITISCH_PROZENT:
ram_kritisch.append({"name": anzeige_name, "wert": round(ram)})
return {
"geraete_gesamt": len(geraete_rows),
"geraete_ohne_checkin": sorted(ohne_checkin, key=lambda e: e["agent_last_checkin"] or ""),
"geraete_disk_kritisch": sorted(disk_kritisch, key=lambda e: -e["wert"]),
"geraete_ram_kritisch": sorted(ram_kritisch, key=lambda e: -e["wert"]),
}
def fetch_customer_dashboard(organization_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute("SELECT hostname_praefix FROM organizations WHERE id = %s", (organization_id,))
row = cur.fetchone()
hostname_praefix = row[0] if row else None
cur.execute("SELECT COUNT(*) FROM workspaces WHERE organization_id = %s", (organization_id,))
(anzahl_eigene_workspaces,) = cur.fetchone()
cur.execute(
"""
SELECT hostname, agent_last_checkin, health_disk_used_percent, health_ram_used_percent
FROM devices
WHERE organization_id = %s AND archived_at IS NULL
""",
(organization_id,),
)
geraete_rows = cur.fetchall()
cur.execute(
"""
SELECT COUNT(*) FROM devices
WHERE organization_id = %s AND archived_at IS NULL AND agent_last_checkin IS NOT NULL
""",
(organization_id,),
)
(anzahl_aktive_geraete,) = cur.fetchone()
cur.execute(
"""
SELECT e.occurred_at, d.hostname, m.name, e.event_type
FROM device_merkmal_events e
JOIN devices d ON d.id = e.device_id
JOIN merkmale m ON m.id = e.merkmal_id
WHERE d.organization_id = %s AND e.event_type IN ('applied', 'apply_failed')
ORDER BY e.occurred_at DESC
LIMIT %s
""",
(organization_id, DASHBOARD_LETZTE_AKTIONEN_LIMIT),
)
aktionen = cur.fetchall()
return {
"naechste_schritte": {
"hat_workspace": anzahl_eigene_workspaces > 0,
"hat_aktives_geraet": anzahl_aktive_geraete > 0,
"hostname_konvention_aktiv": bool(hostname_praefix),
},
"flotten_gesundheit": _dashboard_flotten_gesundheit(geraete_rows),
"letzte_aktionen": [
{
"occurred_at": occurred_at.isoformat(),
"hostname": hostname or "unbenannt",
"merkmal_name": merkmal_name,
"event_type": event_type,
}
for occurred_at, hostname, merkmal_name, event_type in aktionen
],
}
def fetch_admin_dashboard():
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT o.name, d.hostname, d.agent_last_checkin,
d.health_disk_used_percent, d.health_ram_used_percent
FROM devices d
JOIN organizations o ON o.id = d.organization_id
WHERE d.archived_at IS NULL
"""
)
geraete_rows = cur.fetchall()
cur.execute(
"""
SELECT o.id, o.name,
COUNT(d.id) FILTER (WHERE d.archived_at IS NULL) AS geraete,
MAX(d.agent_last_checkin) FILTER (WHERE d.archived_at IS NULL) AS letzte_aktivitaet
FROM organizations o
LEFT JOIN devices d ON d.organization_id = o.id
GROUP BY o.id, o.name
ORDER BY o.name
"""
)
organisationen = cur.fetchall()
cur.execute(
"""
SELECT e.occurred_at, o.name, d.hostname, m.name, e.event_type
FROM device_merkmal_events e
JOIN devices d ON d.id = e.device_id
JOIN organizations o ON o.id = d.organization_id
JOIN merkmale m ON m.id = e.merkmal_id
WHERE e.event_type IN ('applied', 'apply_failed')
ORDER BY e.occurred_at DESC
LIMIT %s
""",
(DASHBOARD_LETZTE_AKTIONEN_LIMIT,),
)
aktionen = cur.fetchall()
return {
"flotten_gesundheit": _dashboard_flotten_gesundheit(geraete_rows),
"organisationen": [
{
"id": str(org_id),
"name": name,
"geraete_anzahl": geraete_anzahl,
"letzte_aktivitaet": letzte_aktivitaet.isoformat() if letzte_aktivitaet else None,
}
for org_id, name, geraete_anzahl, letzte_aktivitaet in organisationen
],
"letzte_aktionen": [
{
"occurred_at": occurred_at.isoformat(),
"organization_name": organization_name,
"hostname": hostname or "unbenannt",
"merkmal_name": merkmal_name,
"event_type": event_type,
}
for occurred_at, organization_name, hostname, merkmal_name, event_type in aktionen
],
}
def fetch_latest_hardware_snapshot(device_id: str):
"""
Für die Kundenportal-Aktion "Hardware-Informationen": neuester
hardware_snapshots-Eintrag des Geräts (mehrere Snapshots über die Zeit
sind möglich, siehe store_hardware_snapshot() - hier interessiert nur der
aktuelle Stand) plus dessen Netzwerk-Interfaces und Storage-Devices.
None, falls das Gerät noch nie aktiviert wurde und somit noch keinen
Snapshot hat.
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, collected_at, architecture, manufacturer, product_name,
product_version, system_uuid, system_serial, board_vendor,
board_name, board_serial, bios_vendor, bios_version,
boot_mode, secure_boot, tpm_version, cpu_model,
cpu_logical_count, memory_bytes
FROM hardware_snapshots
WHERE device_id = %s
ORDER BY collected_at DESC
LIMIT 1
""",
(device_id,),
)
row = cur.fetchone()
if row is None:
return None
(
snapshot_id, collected_at, architecture, manufacturer, product_name,
product_version, system_uuid, system_serial, board_vendor,
board_name, board_serial, bios_vendor, bios_version,
boot_mode, secure_boot, tpm_version, cpu_model,
cpu_logical_count, memory_bytes,
) = row
cur.execute(
"SELECT name, type, mac_address FROM network_interfaces WHERE hardware_snapshot_id = %s ORDER BY name",
(snapshot_id,),
)
network_interfaces = [
{"name": name, "type": type_, "mac_address": mac_address}
for name, type_, mac_address in cur.fetchall()
]
cur.execute(
"SELECT name, model, serial, size_bytes, transport FROM storage_devices WHERE hardware_snapshot_id = %s ORDER BY name",
(snapshot_id,),
)
storage_devices = [
{
"name": name,
"model": model,
"serial": serial,
"size_bytes": size_bytes,
"transport": transport,
}
for name, model, serial, size_bytes, transport in cur.fetchall()
]
return {
"collected_at": collected_at.isoformat(),
"architecture": architecture,
"manufacturer": manufacturer,
"product_name": product_name,
"product_version": product_version,
"system_uuid": system_uuid,
"system_serial": system_serial,
"board_vendor": board_vendor,
"board_name": board_name,
"board_serial": board_serial,
"bios_vendor": bios_vendor,
"bios_version": bios_version,
"boot_mode": boot_mode,
"secure_boot": secure_boot,
"tpm_version": tpm_version,
"cpu_model": cpu_model,
"cpu_logical_count": cpu_logical_count,
"memory_bytes": memory_bytes,
"network_interfaces": network_interfaces,
"storage_devices": storage_devices,
}
def fetch_device_events(device_id: str):
"""
Für die Kundenportal-Aktion "Installations-/Aktions-Log": alle bisher
vom Agenten/Portal gemeldeten device_merkmal_events des Geräts, neueste
zuerst.
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT e.occurred_at, m.key, m.name, e.event_type, e.detail
FROM device_merkmal_events e
JOIN merkmale m ON m.id = e.merkmal_id
WHERE e.device_id = %s
ORDER BY e.occurred_at DESC
""",
(device_id,),
)
return [
{
"occurred_at": occurred_at.isoformat(),
"merkmal": merkmal_key,
"merkmal_name": merkmal_name,
"event_type": event_type,
"detail": detail,
}
for occurred_at, merkmal_key, merkmal_name, event_type, detail in cur.fetchall()
]
def device_fingerprint_matches(device_id: str, device_fingerprint: str) -> bool:
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT device_fingerprint FROM devices WHERE id = %s",
(device_id,),
)
row = cur.fetchone()
return row is not None and row[0] == device_fingerprint
def record_installation_event(device_id: str, event_type: str, detail: str | None) -> None:
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO device_installation_events (id, device_id, event_type, detail)
VALUES (%s, %s, %s, %s)
""",
(uuid4(), device_id, event_type, detail),
)
def fetch_installation_events(device_id: str):
"""
Meilensteine/Fehlschlaege waehrend der Installation (siehe
device_installation_events, Migration 0025) - separat von
fetch_device_events() (Merkmal-Anwendungen), neueste zuerst.
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT occurred_at, event_type, detail
FROM device_installation_events
WHERE device_id = %s
ORDER BY occurred_at DESC
""",
(device_id,),
)
return [
{
"occurred_at": occurred_at.isoformat(),
"event_type": event_type,
"detail": detail,
}
for occurred_at, event_type, detail in cur.fetchall()
]
def has_completed_installation(device_id: str) -> bool:
"""
Fuer /api/v1/activate: hat dieses Geraet jemals einen erfolgreichen
Installationsdurchlauf gemeldet (installation_completed, siehe
device_installation_events)? Grundlage fuer den Re-Install-Schutz in
25_installation_confirm.sh - ein erneut gestecktes Boot-Medium soll die
Zielplatte eines BEREITS fertig installierten Geraets nicht mehr
stillschweigend im Auto-Modus ueberschreiben.
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT 1 FROM device_installation_events
WHERE device_id = %s AND event_type = 'installation_completed'
LIMIT 1
""",
(device_id,),
)
return cur.fetchone() is not None
def deprovision_device(device_id: str) -> bool:
"""
Serverseitiger Entzug (siehe Kundenportal-Geräteaktionen-Plan): macht das
aktuelle Agent-Secret ungültig, ohne den Geräte-Datensatz oder dessen
Historie anzutasten. Der Agent auf dem Gerät selbst wird nicht aktiv
kontaktiert - er bekommt beim nächsten Check-in schlicht 401 und bleibt
bis zur Reprovisionierung untätig liegen (siehe agent.py Self-Heal).
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE devices
SET agent_secret_hash = NULL, agent_secret_issued_at = NULL,
deprovisioned_at = CURRENT_TIMESTAMP
WHERE id = %s AND archived_at IS NULL
RETURNING id
""",
(device_id,),
)
return cur.fetchone() is not None
def reprovision_device(device_id: str) -> bool:
"""
Hebt die Deprovisionierung auf. Erzeugt bewusst kein neues Agent-Secret -
das holt sich der Agent beim nächsten Poll-Zyklus selbst über den
bestehenden /api/v1/agent/bootstrap-Endpunkt (401 auf checkin loest im
Agenten einen Re-Bootstrap aus, siehe agent.py).
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE devices
SET deprovisioned_at = NULL
WHERE id = %s AND archived_at IS NULL
RETURNING id
""",
(device_id,),
)
return cur.fetchone() is not None
def archive_device(device_id: str) -> bool:
"""
Kundenportal-Aktion "Gerät löschen": Soft Delete. Das Gerät verschwindet
aus fetch_devices_for_organization()/fetch_all_devices(), Datensatz und
Historie (hardware_snapshots, device_merkmal_events) bleiben in der DB
erhalten.
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE devices
SET archived_at = CURRENT_TIMESTAMP
WHERE id = %s AND archived_at IS NULL
RETURNING id
""",
(device_id,),
)
return cur.fetchone() is not None
ORGANIZATION_KONTAKTFELDER = ("kontakt_name", "adresse", "telefon", "kontakt_email", "notizen")
def fetch_organizations():
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, name, created_at, kontakt_name, adresse, telefon, kontakt_email, notizen
FROM organizations ORDER BY name
"""
)
return [
{
"id": str(org_id),
"name": name,
"created_at": created_at.isoformat(),
"kontakt_name": kontakt_name,
"adresse": adresse,
"telefon": telefon,
"kontakt_email": kontakt_email,
"notizen": notizen,
}
for (
org_id, name, created_at,
kontakt_name, adresse, telefon, kontakt_email, notizen,
) in cur.fetchall()
]
def fetch_organization(organization_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, name, created_at, kontakt_name, adresse, telefon, kontakt_email, notizen
FROM organizations WHERE id = %s
""",
(organization_id,),
)
row = cur.fetchone()
if row is None:
return None
return {
"id": str(row[0]),
"name": row[1],
"created_at": row[2].isoformat(),
"kontakt_name": row[3],
"adresse": row[4],
"telefon": row[5],
"kontakt_email": row[6],
"notizen": row[7],
}
def create_organization(name: str):
organization_id = uuid4()
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO organizations (id, name) VALUES (%s, %s)",
(organization_id, name),
)
# Jede Organisation bekommt von Geburt an eine Standard-OE (siehe
# Konfigurationsgruppen-Plan) - Landeplatz fuer neu aktivierte
# Geraete, geschuetzt vor Loeschen (siehe delete_organisationseinheit).
cur.execute(
"""
INSERT INTO organisationseinheiten (id, organization_id, parent_id, name, ist_standard)
VALUES (%s, %s, NULL, %s, TRUE)
""",
(uuid4(), organization_id, STANDARD_OE_NAME),
)
return {"id": str(organization_id), "name": name}
def update_organization(organization_id: str, name: str, kontaktfelder: dict):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE organizations
SET name = %s, kontakt_name = %s, adresse = %s, telefon = %s,
kontakt_email = %s, notizen = %s
WHERE id = %s
RETURNING id, name
""",
(
name,
kontaktfelder.get("kontakt_name"),
kontaktfelder.get("adresse"),
kontaktfelder.get("telefon"),
kontaktfelder.get("kontakt_email"),
kontaktfelder.get("notizen"),
organization_id,
),
)
row = cur.fetchone()
if row is None:
return None
return {"id": str(row[0]), "name": row[1]}
def fetch_activation_codes(organization_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT code, active, created_at
FROM activation_codes
WHERE organization_id = %s
ORDER BY created_at DESC
""",
(organization_id,),
)
return [
{"code": code, "active": active, "created_at": created_at.isoformat()}
for code, active, created_at in cur.fetchall()
]
def create_activation_code(organization_id: str):
code = "-".join(
secrets.token_hex(3).upper()[i:i + 3] for i in range(0, 6, 3)
)
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO activation_codes (code, organization_id) VALUES (%s, %s)",
(code, organization_id),
)
return {"code": code, "active": True}
def fetch_merkmale():
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT m.id, m.key, m.name, m.description, m.im_auftragskatalog,
m.kategorie_id, k.name
FROM merkmale m
LEFT JOIN kategorien k ON k.id = m.kategorie_id
ORDER BY m.key
"""
)
return [
{
"id": str(mid),
"key": key,
"name": name,
"description": description,
"im_auftragskatalog": im_auftragskatalog,
"kategorie_id": str(kategorie_id) if kategorie_id else None,
"kategorie_name": kategorie_name,
}
for (
mid, key, name, description, im_auftragskatalog,
kategorie_id, kategorie_name,
) in cur.fetchall()
]
def create_merkmal(key: str, name: str, description: str | None):
merkmal_id = uuid4()
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO merkmale (id, key, name, description) VALUES (%s, %s, %s, %s)",
(merkmal_id, key, name, description),
)
return {"id": str(merkmal_id), "key": key, "name": name}
def update_merkmal(merkmal_id: str, name: str, description: str | None, kategorie_id: str | None, im_auftragskatalog: bool):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE merkmale
SET name = %s, description = %s, kategorie_id = %s, im_auftragskatalog = %s
WHERE id = %s
RETURNING id
""",
(name, description, kategorie_id, im_auftragskatalog, merkmal_id),
)
return cur.fetchone() is not None
def fetch_kategorien():
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT id, key, name, description, sort_order FROM kategorien ORDER BY sort_order, name"
)
return [
{
"id": str(kid),
"key": key,
"name": name,
"description": description,
"sort_order": sort_order,
}
for kid, key, name, description, sort_order in cur.fetchall()
]
def create_kategorie(key: str, name: str, description: str | None, sort_order: int):
kategorie_id = uuid4()
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO kategorien (id, key, name, description, sort_order)
VALUES (%s, %s, %s, %s, %s)
""",
(kategorie_id, key, name, description, sort_order),
)
return {"id": str(kategorie_id), "key": key, "name": name}
def fetch_workspaces():
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT w.id, w.key, w.name, w.description, w.organization_id, o.name
FROM workspaces w
LEFT JOIN organizations o ON o.id = w.organization_id
ORDER BY o.name NULLS FIRST, w.name
"""
)
return [
{
"id": str(wid),
"key": key,
"name": name,
"description": description,
"organization_id": str(organization_id) if organization_id else None,
"organization_name": organization_name,
}
for (
wid, key, name, description, organization_id, organization_name,
) in cur.fetchall()
]
def fetch_available_workspaces_for_organization(organization_id: str):
"""
Workspaces, die eine Organisation für ihre Gerätegruppen wählen darf:
globale Standard-Workspaces (organization_id IS NULL, von uns
gepflegt) plus die eigenen. Bewusst NICHT fetch_workspaces() - die
liefert workspaces aller Organisationen (nur für die Admin-Techniker-
Ansicht gedacht), das würde im Kundenportal fremde, private Workspace-
Namen preisgeben.
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, key, name, description, organization_id
FROM workspaces
WHERE organization_id IS NULL OR organization_id = %s
ORDER BY (organization_id IS NULL) DESC, name
""",
(organization_id,),
)
return [
{
"id": str(wid),
"key": key,
"name": name,
"description": description,
# GUI-Umbau Phase 3 (11.09.2026, Kunden-Workspace-Creator):
# ist_global steuert, ob der Kunde die Zusammensetzung
# bearbeiten darf (eigener Workspace) oder nur lesend
# sieht (globaler, schreibgeschützter Standard).
"ist_global": ws_org_id is None,
}
for wid, key, name, description, ws_org_id in cur.fetchall()
]
def create_workspace(key: str, name: str, description: str | None, organization_id: str | None):
workspace_id = uuid4()
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO workspaces (id, organization_id, key, name, description)
VALUES (%s, %s, %s, %s, %s)
""",
(workspace_id, organization_id, key, name, description),
)
return {"id": str(workspace_id), "key": key, "name": name}
def clone_workspace(source_workspace_id: str, organization_id: str):
"""
"Diesen Standard übernehmen" (GUI-Umbau Phase 3, Nachtrag 11.09.2026):
live gefundene Lücke - Webapps sind an Besitz gebunden (siehe
fetch_sichtbaren_workspace() in der Kundenplattform), ein global
schreibgeschützter Standard-Workspace hat also nie eigene Webapps,
und eine Organisation ohne einen einzigen eigenen Workspace hatte
dadurch gar keinen Weg mehr zu Webapps (anders als das alte, aber
unsichere Verhalten, wo jede Organisation, die einen geteilten
Workspace NUTZTE, dort auch Webapps eintragen konnte - das hätte
Webapps zwischen fremden Organisationen sichtbar gemacht, die
zufällig denselben globalen Standard verwenden).
Löst das auf, ohne den schreibgeschützten Standard selbst anzutasten:
kopiert die Merkmal-Zusammensetzung in einen neuen, der Organisation
gehörenden Workspace - der Standard bleibt für alle unverändert,
die Kopie ist von Anfang an voll editierbar (inkl. eigener Webapps).
"""
quelle = fetch_workspace_detail(source_workspace_id)
if quelle is None:
return None
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute("SELECT key FROM workspaces WHERE organization_id = %s", (organization_id,))
vergebene_keys = {row[0] for row in cur.fetchall()}
basis_key = f"{quelle['key']}-eigene-kopie"
neuer_key = basis_key
zaehler = 2
while neuer_key in vergebene_keys:
neuer_key = f"{basis_key}-{zaehler}"
zaehler += 1
neuer_workspace = create_workspace(
neuer_key, f"{quelle['name']} (eigene Kopie)", quelle["description"], organization_id
)
for merkmal in quelle["merkmale"]:
add_merkmal_to_workspace(neuer_workspace["id"], merkmal["id"])
return neuer_workspace
def fetch_workspace_detail(workspace_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT w.id, w.key, w.name, w.description, w.organization_id, o.name
FROM workspaces w
LEFT JOIN organizations o ON o.id = w.organization_id
WHERE w.id = %s
""",
(workspace_id,),
)
row = cur.fetchone()
if row is None:
return None
cur.execute(
"""
SELECT m.id, m.key, m.name
FROM workspace_merkmale wm
JOIN merkmale m ON m.id = wm.merkmal_id
WHERE wm.workspace_id = %s
ORDER BY m.key
""",
(workspace_id,),
)
merkmale = [
{"id": str(mid), "key": key, "name": name}
for mid, key, name in cur.fetchall()
]
return {
"id": str(row[0]),
"key": row[1],
"name": row[2],
"description": row[3],
"organization_id": str(row[4]) if row[4] else None,
"organization_name": row[5],
"merkmale": merkmale,
}
def add_merkmal_to_workspace(workspace_id: str, merkmal_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO workspace_merkmale (workspace_id, merkmal_id)
VALUES (%s, %s)
ON CONFLICT DO NOTHING
""",
(workspace_id, merkmal_id),
)
def _write_webapp_icon(webapp_id, icon_bytes: bytes, content_type: str) -> str:
extension = mimetypes.guess_extension(content_type) or ".ico"
# image/x-icon (klassisches favicon.ico) fehlt in manchen mimetypes-
# Installationen - .ico ist bei irrigem/leerem Ergebnis der sinnvollste
# Default, weit haeufigster Favicon-Fall.
if extension == ".jpe":
extension = ".jpg"
filename = f"{webapp_id}{extension}"
WEBAPP_ICONS_DIR.mkdir(parents=True, exist_ok=True)
(WEBAPP_ICONS_DIR / filename).write_bytes(icon_bytes)
return filename
def fetch_webapps_for_workspace(workspace_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT w.id, w.name, w.url, w.icon_filename, w.sort_order,
w.kategorie_id, k.name, w.kiosk_modus
FROM workspace_webapps w
LEFT JOIN kategorien k ON k.id = w.kategorie_id
WHERE w.workspace_id = %s
ORDER BY w.sort_order, w.name
""",
(workspace_id,),
)
return [
{
"id": str(webapp_id),
"name": name,
"url": url,
"icon_path": f"/webapp-icons/{icon_filename}" if icon_filename else None,
"sort_order": sort_order,
"kategorie_id": str(kategorie_id) if kategorie_id else None,
"kategorie_name": kategorie_name,
"kiosk_modus": kiosk_modus,
}
for webapp_id, name, url, icon_filename, sort_order, kategorie_id, kategorie_name, kiosk_modus in cur.fetchall()
]
def create_webapp(
workspace_id: str, name: str, url: str, sort_order: int,
kategorie_id: str | None = None, kiosk_modus: bool = False,
):
webapp_id = uuid4()
favicon = fetch_favicon(url)
icon_filename = _write_webapp_icon(webapp_id, favicon[0], favicon[1]) if favicon else None
icon_content_type = favicon[1] if favicon else None
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO workspace_webapps
(id, workspace_id, name, url, icon_filename, icon_content_type, sort_order, kategorie_id, kiosk_modus)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(webapp_id, workspace_id, name, url, icon_filename, icon_content_type, sort_order, kategorie_id, kiosk_modus),
)
return {"id": str(webapp_id), "name": name, "url": url, "icon_path": f"/webapp-icons/{icon_filename}" if icon_filename else None}
def update_webapp(
webapp_id: str, name: str, url: str, sort_order: int,
kategorie_id: str | None = None, kiosk_modus: bool = False,
):
# Favicon bei jeder Bearbeitung neu holen (einfacher als zu erkennen,
# ob sich nur die URL geaendert hat - Fetch ist billig, siehe
# fetch_favicon()) - alte Icon-Datei wird durch die neue ID-basierte
# Benennung nicht ueberschrieben, sondern bleibt als Datei-Leiche liegen,
# falls sich die Content-Type-Endung aendert; bewusst nicht aufgeraeumt
# (seltener Fall, kleine Dateien, kein Cleanup-Mechanismus fuer sowas
# aktuell vorhanden - analog zu anderen bekannten kleinen Aufraeum-
# Luecken im Projekt).
favicon = fetch_favicon(url)
icon_filename = _write_webapp_icon(webapp_id, favicon[0], favicon[1]) if favicon else None
icon_content_type = favicon[1] if favicon else None
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE workspace_webapps
SET name = %s, url = %s, sort_order = %s, icon_filename = %s,
icon_content_type = %s, kategorie_id = %s, kiosk_modus = %s, updated_at = now()
WHERE id = %s
""",
(name, url, sort_order, icon_filename, icon_content_type, kategorie_id, kiosk_modus, webapp_id),
)
return {"id": webapp_id, "name": name, "url": url, "icon_path": f"/webapp-icons/{icon_filename}" if icon_filename else None}
def delete_webapp(webapp_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT icon_filename FROM workspace_webapps WHERE id = %s",
(webapp_id,),
)
row = cur.fetchone()
cur.execute("DELETE FROM workspace_webapps WHERE id = %s", (webapp_id,))
if row and row[0]:
icon_path = WEBAPP_ICONS_DIR / row[0]
if icon_path.is_file():
icon_path.unlink()
# --- Webapp-Vorlagen (Phase 2, Nutzer-Entscheidung 09.09.2026): Admin legt
# global wiederverwendbare Vorlagen an, Kunden aktivieren sie pro Workspace
# (kopiert Name/URL/Icon in workspace_webapps, siehe activate_webapp_template()
# unten) - zusaetzlich zu weiterhin frei erstellbaren eigenen Eintraegen
# (create_webapp() oben, unveraendert).
def fetch_webapp_templates():
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT id, name, url, description, icon_filename FROM webapp_templates ORDER BY name"
)
return [
{
"id": str(template_id),
"name": name,
"url": url,
"description": description,
"icon_path": f"/webapp-icons/{icon_filename}" if icon_filename else None,
}
for template_id, name, url, description, icon_filename in cur.fetchall()
]
def fetch_available_templates_for_workspace(workspace_id: str):
"""Vorlagen, die fuer diesen Workspace noch NICHT aktiviert sind (per
workspace_webapps.template_id) - Grundlage fuer die
Aktivieren-Auswahl im Kunden-Self-Service."""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT t.id, t.name, t.url, t.description, t.icon_filename
FROM webapp_templates t
WHERE NOT EXISTS (
SELECT 1 FROM workspace_webapps w
WHERE w.workspace_id = %s AND w.template_id = t.id
)
ORDER BY t.name
""",
(workspace_id,),
)
return [
{
"id": str(template_id),
"name": name,
"url": url,
"description": description,
"icon_path": f"/webapp-icons/{icon_filename}" if icon_filename else None,
}
for template_id, name, url, description, icon_filename in cur.fetchall()
]
def create_webapp_template(name: str, url: str, description: str | None):
template_id = uuid4()
favicon = fetch_favicon(url)
icon_filename = _write_webapp_icon(template_id, favicon[0], favicon[1]) if favicon else None
icon_content_type = favicon[1] if favicon else None
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO webapp_templates (id, name, url, description, icon_filename, icon_content_type)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(template_id, name, url, description, icon_filename, icon_content_type),
)
return {"id": str(template_id), "name": name, "url": url, "description": description, "icon_path": f"/webapp-icons/{icon_filename}" if icon_filename else None}
def update_webapp_template(template_id: str, name: str, url: str, description: str | None):
favicon = fetch_favicon(url)
icon_filename = _write_webapp_icon(template_id, favicon[0], favicon[1]) if favicon else None
icon_content_type = favicon[1] if favicon else None
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE webapp_templates
SET name = %s, url = %s, description = %s, icon_filename = %s,
icon_content_type = %s, updated_at = now()
WHERE id = %s
""",
(name, url, description, icon_filename, icon_content_type, template_id),
)
return {"id": template_id, "name": name, "url": url, "description": description, "icon_path": f"/webapp-icons/{icon_filename}" if icon_filename else None}
def delete_webapp_template(template_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT icon_filename FROM webapp_templates WHERE id = %s",
(template_id,),
)
row = cur.fetchone()
cur.execute("DELETE FROM webapp_templates WHERE id = %s", (template_id,))
if row and row[0]:
icon_path = WEBAPP_ICONS_DIR / row[0]
if icon_path.is_file():
icon_path.unlink()
def activate_webapp_template(workspace_id: str, template_id: str):
"""Kopiert Name/URL/Icon der Vorlage in einen neuen workspace_webapps-
Eintrag (bewusst eine Kopie, keine laufende Referenz - eine spaetere
Vorlagenaenderung wirkt sich nicht rueckwirkend auf schon aktivierte
Eintraege aus). Liefert None, wenn die Vorlage nicht existiert oder fuer
diesen Workspace bereits aktiviert ist (Aufrufer meldet das als Fehler)."""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT name, url, icon_filename, icon_content_type FROM webapp_templates WHERE id = %s",
(template_id,),
)
template = cur.fetchone()
if template is None:
return None
name, url, icon_filename, icon_content_type = template
cur.execute(
"SELECT 1 FROM workspace_webapps WHERE workspace_id = %s AND template_id = %s",
(workspace_id, template_id),
)
if cur.fetchone() is not None:
return None
webapp_id = uuid4()
# Physische Kopie der Icon-Datei unter einem neuen, vom
# workspace_webapps-Eintrag selbst abgeleiteten Dateinamen -
# nicht denselben Dateinamen wie die Vorlage weiterverwenden.
# Sonst wuerde ein spaeteres Loeschen/Aktualisieren der Vorlage
# (delete_webapp_template()/update_webapp_template(), beide
# ersetzen bzw. entfernen die Icon-Datei unter dem
# Vorlagen-Dateinamen) die bereits aktivierte Kopie mit
# aufraeumen - widerspraeche "keine laufende Referenz" oben.
copied_icon_filename = icon_filename
if icon_filename:
extension = Path(icon_filename).suffix
copied_icon_filename = f"{webapp_id}{extension}"
source_bytes = (WEBAPP_ICONS_DIR / icon_filename).read_bytes()
(WEBAPP_ICONS_DIR / copied_icon_filename).write_bytes(source_bytes)
cur.execute(
"""
INSERT INTO workspace_webapps (id, workspace_id, name, url, icon_filename, icon_content_type, template_id)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""",
(webapp_id, workspace_id, name, url, copied_icon_filename, icon_content_type, template_id),
)
return {"id": str(webapp_id), "name": name, "url": url, "icon_path": f"/webapp-icons/{copied_icon_filename}" if copied_icon_filename else None}
def remove_merkmal_from_workspace(workspace_id: str, merkmal_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM workspace_merkmale WHERE workspace_id = %s AND merkmal_id = %s",
(workspace_id, merkmal_id),
)
def fetch_backends():
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute("SELECT id, key, name, installer_type, version FROM backends ORDER BY key")
return [
{"id": str(bid), "key": key, "name": name, "installer_type": installer_type, "version": version}
for bid, key, name, installer_type, version in cur.fetchall()
]
def fetch_blueprints():
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT m.key, b.key, bp.ansible_role
FROM blueprints bp
JOIN merkmale m ON m.id = bp.merkmal_id
JOIN backends b ON b.id = bp.backend_id
ORDER BY m.key, b.key
"""
)
return [
{"merkmal": merkmal_key, "backend": backend_key, "ansible_role": ansible_role}
for merkmal_key, backend_key, ansible_role in cur.fetchall()
]
def fetch_unassigned_devices(organization_id: str):
"""
Geräte einer Organisation ohne Bereitstellungsvorlagen-Zuweisung - die
Kandidaten für die Neugerät-Bestätigung (ADR-0008) im Flows-Bereich.
Live gefunden (11.09.2026): fehlte bisher der "archived_at IS NULL"-
Filter, den fetch_devices_for_organization()/fetch_all_devices() schon
hatten - ein per "Gerät löschen" bereits archiviertes Gerät ohne
Zuweisung erschien dadurch dauerhaft als offene "unbestätigt"-Aufgabe,
obwohl es gar nicht mehr existiert.
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT d.id, d.hostname, d.device_fingerprint, d.created_at
FROM devices d
LEFT JOIN assignments a ON a.device_id = d.id
WHERE d.organization_id = %s AND a.id IS NULL AND d.archived_at IS NULL
ORDER BY d.created_at
""",
(organization_id,),
)
return [
{
"id": str(device_id),
"hostname": hostname,
"device_fingerprint": fingerprint,
"created_at": created_at.isoformat(),
}
for device_id, hostname, fingerprint, created_at in cur.fetchall()
]
def fetch_enrollment_sessions(organization_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT es.id, bv.label, es.max_devices, es.devices_used,
es.expires_at, es.revoked_at, es.created_at
FROM enrollment_sessions es
JOIN bereitstellungsvorlagen bv ON bv.id = es.bereitstellungsvorlage_id
WHERE es.organization_id = %s
ORDER BY es.created_at DESC
""",
(organization_id,),
)
return [
{
"id": str(sid),
"bereitstellungsvorlage_label": label,
"max_devices": max_devices,
"devices_used": devices_used,
"expires_at": expires_at.isoformat(),
"revoked_at": revoked_at.isoformat() if revoked_at else None,
"created_at": created_at.isoformat(),
}
for (
sid, label, max_devices, devices_used,
expires_at, revoked_at, created_at,
) in cur.fetchall()
]
def create_enrollment_session(organization_id: str, bereitstellungsvorlage_id: str, max_devices: int, expires_at: str):
session_id = uuid4()
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO enrollment_sessions
(id, organization_id, bereitstellungsvorlage_id, max_devices, expires_at)
VALUES (%s, %s, %s, %s, %s)
""",
(session_id, organization_id, bereitstellungsvorlage_id, max_devices, expires_at),
)
return {"id": str(session_id)}
def revoke_enrollment_session(session_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE enrollment_sessions
SET revoked_at = CURRENT_TIMESTAMP
WHERE id = %s AND revoked_at IS NULL
RETURNING id
""",
(session_id,),
)
return cur.fetchone() is not None
def recharge_enrollment_session(session_id: str, additional_devices: int, expires_at: str | None):
"""
Phase 5 (Kundenplattform-Selfservice): eine bereits ausgeteilte,
personalisierte Kunden-ISO bettet die Enrollment-Session-Id fest als
Aktivierungscode ein (siehe build_customer_iso.sh) - Kontingent/
Gueltigkeit dieser Session zu erhoehen macht die ISO weiter nutzbar,
ohne dass eine neue gebaut/neu verteilt werden muss. additional_devices
wird auf max_devices addiert (nicht ersetzt - "aufladen", nicht
"neu setzen"), expires_at ersetzt den bisherigen Wert, wenn angegeben.
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE enrollment_sessions
SET max_devices = max_devices + %s,
expires_at = COALESCE(%s, expires_at)
WHERE id = %s AND revoked_at IS NULL
RETURNING id
""",
(additional_devices, expires_at, session_id),
)
return cur.fetchone() is not None
def iso_build_row_to_dict(row) -> dict:
(build_id, organization_id, status, output_filename, error_message,
created_at, started_at, finished_at, expires_at, sha256, size_bytes) = row
return {
"id": str(build_id),
"organization_id": str(organization_id),
"status": status,
"output_filename": output_filename,
"error_message": error_message,
"created_at": created_at.isoformat(),
"started_at": started_at.isoformat() if started_at else None,
"finished_at": finished_at.isoformat() if finished_at else None,
"expires_at": expires_at.isoformat() if expires_at else None,
"sha256": sha256,
"size_bytes": size_bytes,
}
def fetch_iso_build(build_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, organization_id, status, output_filename, error_message,
created_at, started_at, finished_at, expires_at, sha256, size_bytes
FROM iso_builds
WHERE id = %s
""",
(build_id,),
)
row = cur.fetchone()
return iso_build_row_to_dict(row) if row else None
def fetch_iso_builds(organization_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, organization_id, status, output_filename, error_message,
created_at, started_at, finished_at, expires_at, sha256, size_bytes
FROM iso_builds
WHERE organization_id = %s
ORDER BY created_at DESC
""",
(organization_id,),
)
return [iso_build_row_to_dict(row) for row in cur.fetchall()]
def create_iso_build(organization_id: str) -> dict:
build_id = uuid4()
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO iso_builds (id, organization_id) VALUES (%s, %s)",
(build_id, organization_id),
)
return {"id": str(build_id), "status": "pending"}
def update_iso_build(
build_id,
status: str,
output_filename: str | None = None,
error_message: str | None = None,
sha256: str | None = None,
size_bytes: int | None = None,
mark_started: bool = False,
mark_finished: bool = False,
):
# expires_at nur bei einem tatsaechlich erfolgreichen Abschluss setzen -
# ein "failed"-Build hat nichts zum Downloaden, das ablaufen koennte
# (dessen Zeile raeumt weiterhin ausschliesslich cleanup_old_iso_builds()
# beim naechsten Bau derselben Organisation auf).
setzt_expiry = mark_finished and status == "completed"
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
f"""
UPDATE iso_builds
SET status = %s,
output_filename = COALESCE(%s, output_filename),
error_message = %s,
sha256 = COALESCE(%s, sha256),
size_bytes = COALESCE(%s, size_bytes)
{", started_at = CURRENT_TIMESTAMP" if mark_started else ""}
{", finished_at = CURRENT_TIMESTAMP" if mark_finished else ""}
{f", expires_at = CURRENT_TIMESTAMP + INTERVAL '{ISO_BUILD_EXPIRY_DAYS} days'" if setzt_expiry else ""}
WHERE id = %s
""",
(status, output_filename, error_message, sha256, size_bytes, build_id),
)
def cleanup_old_iso_builds(organization_id: str, keep_build_id) -> None:
"""
Loescht Ausgabedateien + Zeilen abgeschlossener frueherer Baeulaeufe
derselben Organisation (Speicherplatz auf anode ist begrenzt) - eine neu
erzeugte Kunden-ISO ersetzt die vorherige, ein erneuter Download der
alten ergibt fuer den Kunden ohnehin keinen Sinn mehr.
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, output_filename FROM iso_builds
WHERE organization_id = %s AND id != %s
AND status IN ('completed', 'failed')
""",
(organization_id, keep_build_id),
)
old_builds = cur.fetchall()
for old_id, output_filename in old_builds:
if output_filename:
(ISO_BUILD_OUTPUT_DIR / output_filename).unlink(missing_ok=True)
cur.execute(
"""
DELETE FROM iso_builds
WHERE organization_id = %s AND id != %s
AND status IN ('completed', 'failed')
""",
(organization_id, keep_build_id),
)
def cleanup_expired_iso_builds() -> int:
"""
Zeitbasierte Ergaenzung zu cleanup_old_iso_builds(): loescht Datei +
Zeile jedes abgeschlossenen Builds, dessen expires_at in der
Vergangenheit liegt - unabhaengig davon, ob die Organisation je erneut
baut (die bisherige Logik greift nur beim naechsten Bau derselben Org).
Laeuft periodisch ueber run_iso_build_expiry_sweep(). Gibt die Anzahl
geloeschter Builds zurueck (fuer Logging/Tests).
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, output_filename FROM iso_builds
WHERE status = 'completed' AND expires_at IS NOT NULL AND expires_at < CURRENT_TIMESTAMP
"""
)
abgelaufene_builds = cur.fetchall()
for build_id, output_filename in abgelaufene_builds:
if output_filename:
(ISO_BUILD_OUTPUT_DIR / output_filename).unlink(missing_ok=True)
cur.execute(
"""
DELETE FROM iso_builds
WHERE status = 'completed' AND expires_at IS NOT NULL AND expires_at < CURRENT_TIMESTAMP
"""
)
return len(abgelaufene_builds)
def run_iso_build_expiry_sweep() -> None:
"""
Endlosschleife in einem Daemon-Thread (siehe on_startup unten) - stuendlich
abgelaufene ISO-Builds raeumen. Ein Fehler in einem Durchlauf (z.B.
kurzzeitig nicht erreichbare DB) darf die Schleife nicht beenden, sonst
liefe der Sweep fuer den Rest der Prozesslaufzeit gar nicht mehr.
"""
while True:
time.sleep(ISO_BUILD_EXPIRY_SWEEP_INTERVAL_SECONDS)
try:
anzahl = cleanup_expired_iso_builds()
if anzahl:
print(f"[iso-build-expiry] {anzahl} abgelaufene(n) ISO-Build(s) geloescht.")
except Exception as exc:
print(f"[iso-build-expiry] Sweep-Fehler (naechster Versuch in {ISO_BUILD_EXPIRY_SWEEP_INTERVAL_SECONDS}s): {exc}")
def compute_iso_volid(organization_name: str) -> str:
"""
ISO9660-Volume-ID fuer eine personalisierte Kunden-ISO: Organisationsname
+ Bau-Datum, damit ein gemountetes/im Dateimanager angezeigtes Medium
erkennbar ist, statt bei der festen generischen Bezeichnung zu bleiben.
ISO9660 erlaubt fuer die Primary-Volume-Descriptor-Volume-ID nur
Grossbuchstaben/Ziffern/Unterstrich und maximal 32 Zeichen - Umlaute
u.ae. werden per NFKD-Normalisierung auf ihr ASCII-Grundzeichen
reduziert (z.B. "Groesse" statt Wegfall), alles andere zu "_"
zusammengefasst. Faellt bei einem Namen ohne jedes ASCII-Zeichen (z.B.
rein kyrillisch/chinesisch) auf "ORG" zurueck statt eine leere
Volume-ID zu erzeugen.
"""
ascii_only = unicodedata.normalize("NFKD", organization_name).encode("ascii", "ignore").decode("ascii")
sanitized = re.sub(r"[^A-Za-z0-9]+", "_", ascii_only).strip("_").upper()
date_suffix = datetime.now(timezone.utc).strftime("%Y%m%d")
max_name_len = 32 - 1 - len(date_suffix) # 32 - "_" - "YYYYMMDD"
sanitized = sanitized[:max_name_len].rstrip("_") or "ORG"
return f"{sanitized}_{date_suffix}"
def compute_file_sha256(path) -> str:
"""
Streamt die Datei in 1-MB-Bloecken statt sie komplett in den Speicher zu
laden (ISOs sind mehrere GB gross) - laeuft im selben Background-Thread
wie der Bau selbst, blockiert also keinen HTTP-Request.
"""
hasher = hashlib.sha256()
with open(path, "rb") as f:
for block in iter(lambda: f.read(1024 * 1024), b""):
hasher.update(block)
return hasher.hexdigest()
def run_iso_build(build_id, organization_id: str, activation_code: str, wifi_ssid: str | None, wifi_psk: str | None) -> None:
output_path = ISO_BUILD_OUTPUT_DIR / f"{build_id}.iso"
update_iso_build(build_id, status="running", mark_started=True)
organization = fetch_organization(organization_id)
volid = compute_iso_volid(organization["name"] if organization else "TUXFLOTTE")
try:
# De-Root-Umzug (07.09.2026): der Dienst selbst laeuft nicht mehr als
# root, aber build_customer_iso.sh braucht es weiterhin zwingend
# (unsquashfs/mksquashfs, CAP_MKNOD - siehe Kommentar dort). Statt den
# ganzen Prozess wieder privilegiert laufen zu lassen, nur dieser eine
# Aufruf per eng gefasster NOPASSWD-sudo-Regel (siehe
# /etc/sudoers.d/provisioning-server-iso-build).
result = subprocess.run(
[
"sudo",
"bash",
f"{TUXFLOTTE_INSTALLER_DIR}/scripts/build_customer_iso.sh",
MINT_ISO_PATH,
str(output_path),
activation_code,
wifi_ssid or "",
wifi_psk or "",
volid,
],
cwd=TUXFLOTTE_INSTALLER_DIR,
capture_output=True,
text=True,
timeout=ISO_BUILD_TIMEOUT_SECONDS,
)
except subprocess.TimeoutExpired:
output_path.unlink(missing_ok=True)
update_iso_build(build_id, status="failed", error_message="Zeitüberschreitung beim ISO-Bau.", mark_finished=True)
return
except Exception as exc:
output_path.unlink(missing_ok=True)
update_iso_build(build_id, status="failed", error_message=str(exc)[:4000], mark_finished=True)
return
if result.returncode != 0 or not output_path.is_file():
output_path.unlink(missing_ok=True)
error_tail = (result.stderr or result.stdout or "unbekannter Fehler")[-4000:]
update_iso_build(build_id, status="failed", error_message=error_tail, mark_finished=True)
return
# Eigener try/except (08.09.2026 ergaenzt, echter Bug): ohne das blieb
# der Build-Datensatz bei JEDEM Fehler ab hier (z.B. output_path war
# nach dem De-Root-Umzug root-eigen und fuer diesen unprivilegierten
# Prozess unlesbar) fuer immer auf status="running" haengen - eine
# unbehandelte Exception in einem Python-Thread beendet nur den
# Thread, nicht den Prozess, und ruft nie update_iso_build() auf. Siehe
# auch den chown-Fix in build_customer_iso.sh fuer die konkrete Ursache
# dieses einen Falls - dieser try/except bleibt trotzdem sinnvoll als
# genereller Schutz gegen jeden aehnlichen kuenftigen Fehler hier.
try:
sha256 = compute_file_sha256(output_path)
size_bytes = output_path.stat().st_size
update_iso_build(
build_id, status="completed", output_filename=output_path.name, sha256=sha256,
size_bytes=size_bytes, mark_finished=True
)
cleanup_old_iso_builds(organization_id, build_id)
except Exception as exc:
output_path.unlink(missing_ok=True)
update_iso_build(build_id, status="failed", error_message=str(exc)[:4000], mark_finished=True)
@app.get("/health")
def health():
return {
"status": "ok",
"service": "provisioning-server",
"version": "0.1.0"
}
@app.post("/api/v1/activate")
def activate(payload: ActivationRequest, request: Request):
# Enrollment Sessions zuerst versuchen (Auto-Modus-ISOs, siehe
# tuxflotte-installer Phase 2/3) - ihre id ist selbst der Code, kollidiert
# nicht mit klassischen Aktivierungscodes (eigene, meist sprechende
# Strings wie "LAB-2026-START"). Fällt bei Nicht-UUID sofort auf den
# klassischen Pfad zurück.
enrollment_session = find_enrollment_session(payload.activation_code)
activation = None
if enrollment_session is not None:
organization_id = enrollment_session["organization_id"]
organization_name = enrollment_session["organization_name"]
success = True
else:
activation = find_activation_code(payload.activation_code)
success = activation is not None
if success:
organization_id = activation["organization_id"]
organization_name = activation["organization_name"]
device = None
device_created = False
hardware_snapshot_id = None
if success:
hostname = resolve_hostname_for_activation(
organization_id, payload.device_fingerprint, payload.hostname
)
device, device_created = load_or_create_device(
organization_id,
payload.device_fingerprint,
hostname,
)
hardware_snapshot_id = store_hardware_snapshot(
device["id"],
payload.hardware,
)
if enrollment_session is not None:
consume_enrollment_session(enrollment_session["session_id"])
write_log({
"timestamp": datetime.now(timezone.utc).isoformat(),
"event": "activate",
"success": success,
"remote_ip": request.client.host if request.client else None,
"activation_code": payload.activation_code,
"hostname": payload.hostname,
"machine_id": payload.machine_id,
"client_version": payload.client_version
})
if not success:
return {
"success": False,
"error": "invalid_activation_code",
"message": "Der Aktivierungscode ist ungültig."
}
if enrollment_session is not None:
# Die Bereitstellungsvorlage steht durch die Enrollment Session schon
# fest - nicht die volle Organisationsliste zurückgeben (20_profile_
# selection.sh würde im Auto-Modus sonst nichts Eindeutiges zum
# Auswählen haben), sondern nur diese eine, als is_default markiert
# (dieselbe Erkennung, die der Installer schon für den regulären
# Standard-Vorlagen-Fall benutzt - keine Installer-Änderung nötig).
templates = [
{**template, "is_default": True}
for template in fetch_templates(organization_id)
if template["id"] == enrollment_session["bereitstellungsvorlage_id"]
]
else:
templates = fetch_templates(organization_id)
return {
"success": True,
"device": {
"id": device["id"],
"fingerprint": device["device_fingerprint"],
"hostname": device["hostname"],
"registration_status": (
"registered"
if device_created
else "existing"
),
"hardware_snapshot_id": hardware_snapshot_id,
# Re-Install-Schutz (02.09.2026, Nutzer-Wunsch nach dem zweiten
# echten Hardware-Test): device_created=False bedeutet nur "das
# Geraet ist schon bekannt", NICHT "es ist schon fertig
# installiert" (koennte auch an einem frueheren Commit-Gate
# abgebrochen sein) - deshalb eigene Pruefung.
"previously_installed": (
False if device_created else has_completed_installation(device["id"])
),
},
"customer": {
"id": organization_id,
"organization_id": organization_id,
"name": organization_name,
},
"templates": templates,
}
@app.post("/api/v1/templates/{template_id}/resolve")
def resolve_template(template_id: str, payload: ResolveRequest):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT bv.workspace_id, bv.backend_id, w.key, b.key,
bv.disk_encryption, bv.partitioning, bv.secure_boot_required
FROM bereitstellungsvorlagen bv
JOIN workspaces w ON w.id = bv.workspace_id
JOIN backends b ON b.id = bv.backend_id
WHERE bv.id = %s
""",
(template_id,),
)
template_row = cur.fetchone()
if template_row is None:
return {
"success": False,
"error": "template_not_found",
"message": "Die angeforderte Bereitstellungsvorlage wurde nicht gefunden."
}
(
workspace_id, backend_id, workspace_key, backend_key,
disk_encryption, partitioning, secure_boot_required,
) = template_row
cur.execute(
"""
SELECT m.key, bp.ansible_role
FROM workspace_merkmale wm
JOIN merkmale m ON m.id = wm.merkmal_id
JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s
WHERE wm.workspace_id = %s
""",
(backend_id, workspace_id),
)
blueprints = [
{"merkmal": key, "ansible_role": ansible_role}
for key, ansible_role in cur.fetchall()
]
cur.execute(
"""
INSERT INTO assignments (id, device_id, bereitstellungsvorlage_id)
VALUES (%s, %s, %s)
ON CONFLICT (device_id) DO UPDATE
SET bereitstellungsvorlage_id = EXCLUDED.bereitstellungsvorlage_id
""",
(uuid4(), payload.device_id, template_id),
)
return {
"success": True,
"runtime_blueprint": {
"workspace_id": workspace_key,
"backend_id": backend_key,
"blueprints": blueprints,
"installation_directives": {
"disk_encryption": disk_encryption,
"partitioning": partitioning,
"secure_boot_required": secure_boot_required,
},
},
}
@app.get("/installers/fedora-workstation/ks.cfg")
def get_fedora_kickstart():
return FileResponse(
"installers/fedora-workstation/ks.cfg",
media_type="text/plain"
)
@app.get("/golden-images/{filename}")
def get_golden_image(filename: str):
# Path-Traversal-Schutz: Path(...).name kappt jeden "../"-Anteil, der
# Vergleich mit dem Original-Parameter erkennt den Versuch und lehnt ihn
# ab, statt ihn still auf einen anderen Dateinamen umzubiegen.
safe_name = Path(filename).name
if safe_name != filename:
raise HTTPException(status_code=400, detail="invalid_filename")
file_path = GOLDEN_IMAGES_DIR / safe_name
if not file_path.is_file():
raise HTTPException(status_code=404, detail="not_found")
return FileResponse(file_path, media_type="application/zstd")
@app.get("/webapp-icons/{filename}")
def get_webapp_icon(filename: str):
# Gleicher Path-Traversal-Schutz wie get_golden_image() oben,
# unauthentifiziert aus demselben Grund (Agent hat vor dem ersten
# ansible-pull keinen anderen Kontext als die URL selbst).
safe_name = Path(filename).name
if safe_name != filename:
raise HTTPException(status_code=400, detail="invalid_filename")
file_path = WEBAPP_ICONS_DIR / safe_name
if not file_path.is_file():
raise HTTPException(status_code=404, detail="not_found")
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT icon_content_type FROM workspace_webapps WHERE icon_filename = %s LIMIT 1",
(safe_name,),
)
row = cur.fetchone()
media_type = row[0] if row and row[0] else "application/octet-stream"
return FileResponse(file_path, media_type=media_type)
@app.post("/api/v1/agent/bootstrap")
def agent_bootstrap(payload: AgentBootstrapRequest):
agent_secret = secrets.token_urlsafe(32)
# deprovisioned_at wird hier bewusst mitgeprueft: der Agent ruft diesen
# Endpoint auch selbststaendig bei 401 auf checkin auf (siehe agent.py
# Self-Heal). Ohne dieses Gate wuerde ein per Kundenportal
# deprovisioniertes Geraet sich beim naechsten Poll-Zyklus einfach
# selbst ein neues Secret holen und die De-Provisionierung waere
# wirkungslos - das Gate macht reprovision_device() (Loeschen von
# deprovisioned_at) zur tatsaechlichen Voraussetzung fuer den Erfolg
# dieses Aufrufs.
existing = None
with get_database_connection() as conn:
with conn.cursor() as cur:
# Vor dem Update geprueft: war agent_secret_issued_at NOCH NIE
# gesetzt? Das unterscheidet den ALLERERSTEN Bootstrap (echte
# Neuinstallation, siehe postinstall.sh) von einem spaeteren
# Self-Heal-Re-Bootstrap (401 auf checkin, agent.py) - nur beim
# allerersten Mal wird gleich anschliessend automatisch der
# Debug-Modus aktiviert (Nutzer-Wunsch 02.09.2026: kurz nach dem
# ersten Boot des fertig installierten Systems soll ein frisch
# zugewiesenes Merkmal ohne stundenlange Wartezeit ankommen,
# ohne dass der Debug-Modus jedesmal bei jedem Re-Bootstrap
# erneut anspringt).
cur.execute(
"SELECT agent_secret_issued_at FROM devices WHERE id = %s",
(payload.device_id,),
)
vorher = cur.fetchone()
ist_erstinstallation = vorher is not None and vorher[0] is None
cur.execute(
"""
UPDATE devices
SET agent_secret_hash = %s, agent_secret_issued_at = %s
WHERE id = %s AND deprovisioned_at IS NULL
RETURNING id
""",
(
hash_agent_secret(agent_secret),
datetime.now(timezone.utc),
payload.device_id,
),
)
updated = cur.fetchone()
if updated is None:
cur.execute(
"SELECT deprovisioned_at FROM devices WHERE id = %s",
(payload.device_id,),
)
existing = cur.fetchone()
elif ist_erstinstallation:
cur.execute(
"UPDATE devices SET debug_mode_until = %s WHERE id = %s",
(
datetime.now(timezone.utc) + timedelta(hours=DEBUG_MODE_DURATION_HOURS),
payload.device_id,
),
)
if updated is None:
if existing is not None and existing[0] is not None:
return {
"success": False,
"error": "device_deprovisioned",
"message": "Das Gerät ist deprovisioniert und muss zuerst über das Kundenportal reprovisioniert werden.",
}
return {
"success": False,
"error": "device_not_found",
"message": "Das angegebene Gerät wurde nicht gefunden.",
}
return {"success": True, "agent_secret": agent_secret}
@app.post("/api/v1/agent/checkin")
def agent_checkin(
payload: AgentCheckinRequest,
authorization: str | None = Header(default=None),
):
if authorization is None or not authorization.startswith("Bearer "):
return {
"success": False,
"error": "unauthorized",
"message": "Fehlendes oder ungültiges Authorization-Header.",
}
provided_secret = authorization.removeprefix("Bearer ")
if not verify_agent_secret(payload.device_id, provided_secret):
return {
"success": False,
"error": "unauthorized",
"message": "Ungültiges Agent-Secret.",
}
health = payload.health
jetzt_ts = datetime.now(timezone.utc)
with get_database_connection() as conn:
with conn.cursor() as cur:
# Ein kombiniertes UPDATE ... RETURNING statt zwei Roundtrips -
# debug_mode_until wird direkt mitgeliefert, um das effektive
# Intervall unten zu bestimmen (siehe ADR: Debug-Modus faellt
# rein durch Zeitvergleich automatisch zurueck, kein Cron noetig).
# Die health_*-Spalten werden nur ueberschrieben, wenn dieser
# Checkin ueberhaupt ein health-Objekt mitschickt - fehlt es
# (z.B. Sammel-Fehler im Agenten), bleibt der letzte bekannte
# Stand stehen statt auf NULL zurueckzufallen.
cur.execute(
"""
UPDATE devices
SET agent_last_checkin = %(jetzt)s,
health_disk_used_percent = CASE WHEN %(hat_health)s THEN %(disk)s ELSE health_disk_used_percent END,
health_ram_used_percent = CASE WHEN %(hat_health)s THEN %(ram)s ELSE health_ram_used_percent END,
health_uptime_seconds = CASE WHEN %(hat_health)s THEN %(uptime)s ELSE health_uptime_seconds END,
health_load_1m = CASE WHEN %(hat_health)s THEN %(load1)s ELSE health_load_1m END,
health_load_5m = CASE WHEN %(hat_health)s THEN %(load5)s ELSE health_load_5m END,
health_load_15m = CASE WHEN %(hat_health)s THEN %(load15)s ELSE health_load_15m END,
health_collected_at = CASE WHEN %(hat_health)s THEN %(jetzt)s ELSE health_collected_at END
WHERE id = %(device_id)s
RETURNING debug_mode_until, hostname
""",
{
"jetzt": jetzt_ts,
"hat_health": health is not None,
"disk": health.disk_used_percent if health else None,
"ram": health.ram_used_percent if health else None,
"uptime": health.uptime_seconds if health else None,
"load1": health.load_1m if health else None,
"load5": health.load_5m if health else None,
"load15": health.load_15m if health else None,
"device_id": payload.device_id,
},
)
row = cur.fetchone()
debug_mode_until = row[0] if row else None
hostname = row[1] if row else None
im_debug_modus = debug_mode_until is not None and debug_mode_until > jetzt_ts
effektives_intervall = DEBUG_MODE_POLL_INTERVAL_SECONDS if im_debug_modus else AGENT_POLL_INTERVAL_SECONDS
return {
"success": True,
"blueprints": fetch_assigned_blueprints(payload.device_id),
"auftraege": fetch_auftragskatalog_state(payload.device_id),
"webapps": fetch_workspace_webapps(payload.device_id),
# Soll-Hostname (Design-Runde 10.09.2026, siehe ADR-0028/Roadmap) -
# None, wenn keine Organisations-Konvention konfiguriert ist und/
# oder das Gerät seinen eigenen (per Client gemeldeten) Hostnamen
# behalten hat. Die neue "hostname"-Ansible-Rolle setzt ihn nur,
# wenn er sich vom lokal tatsächlich gesetzten unterscheidet.
"hostname": hostname,
"ansible_repo": ANSIBLE_CONTENT_REPO,
"poll_interval_seconds": effektives_intervall,
}
@app.post("/api/v1/agent/report")
def agent_report(
payload: AgentReportRequest,
authorization: str | None = Header(default=None),
):
if authorization is None or not authorization.startswith("Bearer "):
return {
"success": False,
"error": "unauthorized",
"message": "Fehlendes oder ungültiges Authorization-Header.",
}
provided_secret = authorization.removeprefix("Bearer ")
if not verify_agent_secret(payload.device_id, provided_secret):
return {
"success": False,
"error": "unauthorized",
"message": "Ungültiges Agent-Secret.",
}
for entry in payload.results:
if entry.event_type not in AGENT_REPORTABLE_EVENT_TYPES:
return {
"success": False,
"error": "invalid_event_type",
"message": f"Kein vom Agenten meldbarer event_type: {entry.event_type}",
}
with get_database_connection() as conn:
with conn.cursor() as cur:
merkmal_ids = {}
for entry in payload.results:
if entry.merkmal in merkmal_ids:
continue
cur.execute(
"SELECT id FROM merkmale WHERE key = %s",
(entry.merkmal,),
)
merkmal_row = cur.fetchone()
if merkmal_row is None:
return {
"success": False,
"error": "unknown_merkmal",
"message": f"Unbekanntes Merkmal: {entry.merkmal}",
}
merkmal_ids[entry.merkmal] = merkmal_row[0]
for entry in payload.results:
cur.execute(
"""
INSERT INTO device_merkmal_events (
id, device_id, merkmal_id, event_type, detail
)
VALUES (%s, %s, %s, %s, %s)
""",
(
uuid4(),
payload.device_id,
merkmal_ids[entry.merkmal],
entry.event_type,
Jsonb(entry.detail) if entry.detail is not None else None,
),
)
return {"success": True}
@app.get("/api/v1/organizations/{organization_id}/devices")
def get_organization_devices(
organization_id: str,
authorization: str | None = Header(default=None),
):
if not require_service_token(authorization):
return {
"success": False,
"error": "unauthorized",
"message": "Fehlendes oder ungültiges Service-Token.",
}
return {"success": True, "devices": fetch_devices_for_organization(organization_id)}
@app.get("/api/v1/devices")
def list_all_devices(authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {
"success": False,
"error": "unauthorized",
"message": "Fehlendes oder ungültiges Service-Token.",
}
return {"success": True, "devices": fetch_all_devices()}
@app.get("/api/v1/devices/{device_id}/hardware")
def get_device_hardware(device_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {
"success": False,
"error": "unauthorized",
"message": "Fehlendes oder ungültiges Service-Token.",
}
snapshot = fetch_latest_hardware_snapshot(device_id)
if snapshot is None:
return {
"success": False,
"error": "no_snapshot",
"message": "Für dieses Gerät liegt noch kein Hardware-Snapshot vor.",
}
return {"success": True, "hardware": snapshot}
@app.get("/api/v1/devices/{device_id}/events")
def get_device_events(device_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {
"success": False,
"error": "unauthorized",
"message": "Fehlendes oder ungültiges Service-Token.",
}
return {"success": True, "events": fetch_device_events(device_id)}
@app.post("/api/v1/devices/{device_id}/installation-events")
def post_installation_event(device_id: str, payload: InstallationEventRequest):
"""
Vom Boot-Medium selbst waehrend der Installation aufgerufen (siehe
scripts/lib/reporting.sh in tuxflotte-installer) - das Geraet hat zu
diesem Zeitpunkt noch kein agent_secret, deshalb kein Service-Token-
Zwang wie bei den uebrigen Endpunkten. device_fingerprint dient als
Nachweis, gleiches Vertrauensniveau wie /api/v1/activate selbst -
bewusst kein neues Token-System fuer einen reinen Diagnose-Kanal.
Rein melde-basiert (best effort auf Aufruferseite) - ein Fehlschlag
hier darf die eigentliche Installation nie aufhalten.
"""
if not device_fingerprint_matches(device_id, payload.device_fingerprint):
return {
"success": False,
"error": "unauthorized",
"message": "Geräte-Kennung/Fingerprint stimmen nicht überein.",
}
record_installation_event(device_id, payload.event_type, payload.detail)
return {"success": True}
@app.get("/api/v1/devices/{device_id}/installation-events")
def get_installation_events(device_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {
"success": False,
"error": "unauthorized",
"message": "Fehlendes oder ungültiges Service-Token.",
}
return {"success": True, "events": fetch_installation_events(device_id)}
@app.post("/api/v1/devices/{device_id}/deprovision")
def deprovision(device_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {
"success": False,
"error": "unauthorized",
"message": "Fehlendes oder ungültiges Service-Token.",
}
if not deprovision_device(device_id):
return {
"success": False,
"error": "device_not_found",
"message": "Das angegebene Gerät wurde nicht gefunden.",
}
return {"success": True}
@app.post("/api/v1/devices/{device_id}/reprovision")
def reprovision(device_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {
"success": False,
"error": "unauthorized",
"message": "Fehlendes oder ungültiges Service-Token.",
}
if not reprovision_device(device_id):
return {
"success": False,
"error": "device_not_found",
"message": "Das angegebene Gerät wurde nicht gefunden.",
}
return {"success": True}
@app.post("/api/v1/devices/{device_id}/archive")
def archive(device_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {
"success": False,
"error": "unauthorized",
"message": "Fehlendes oder ungültiges Service-Token.",
}
if not archive_device(device_id):
return {
"success": False,
"error": "device_not_found",
"message": "Das angegebene Gerät wurde nicht gefunden.",
}
return {"success": True}
@app.get("/api/v1/organizations")
def list_organizations(authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {
"success": False,
"error": "unauthorized",
"message": "Fehlendes oder ungültiges Service-Token.",
}
return {"success": True, "organizations": fetch_organizations()}
@app.post("/api/v1/organizations")
def create_organization_endpoint(
payload: CreateOrganizationRequest,
authorization: str | None = Header(default=None),
):
if not require_service_token(authorization):
return {
"success": False,
"error": "unauthorized",
"message": "Fehlendes oder ungültiges Service-Token.",
}
return {"success": True, "organization": create_organization(payload.name)}
@app.get("/api/v1/organizations/{organization_id}")
def get_organization_endpoint(organization_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
organization = fetch_organization(organization_id)
if organization is None:
return {"success": False, "error": "organization_not_found", "message": "Die Organisation wurde nicht gefunden."}
return {"success": True, "organization": organization}
@app.get("/api/v1/organizations/{organization_id}/dashboard")
def get_customer_dashboard_endpoint(organization_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {"success": True, "dashboard": fetch_customer_dashboard(organization_id)}
@app.get("/api/v1/dashboard/admin")
def get_admin_dashboard_endpoint(authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {"success": True, "dashboard": fetch_admin_dashboard()}
@app.patch("/api/v1/organizations/{organization_id}")
def update_organization_endpoint(
organization_id: str,
payload: UpdateOrganizationRequest,
authorization: str | None = Header(default=None),
):
if not require_service_token(authorization):
return {
"success": False,
"error": "unauthorized",
"message": "Fehlendes oder ungültiges Service-Token.",
}
kontaktfelder = {feld: getattr(payload, feld) for feld in ORGANIZATION_KONTAKTFELDER}
organization = update_organization(organization_id, payload.name, kontaktfelder)
if organization is None:
return {
"success": False,
"error": "organization_not_found",
"message": "Die Organisation wurde nicht gefunden.",
}
return {"success": True, "organization": organization}
@app.get("/api/v1/organizations/{organization_id}/hostname-konvention")
def get_hostname_konvention(organization_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
konvention = fetch_hostname_konvention(organization_id)
if konvention is None:
return {"success": False, "error": "organization_not_found", "message": "Die Organisation wurde nicht gefunden."}
return {"success": True, "hostname_konvention": konvention}
@app.put("/api/v1/organizations/{organization_id}/hostname-konvention")
def set_hostname_konvention_endpoint(
organization_id: str,
payload: SetHostnameKonventionRequest,
authorization: str | None = Header(default=None),
):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
if payload.nummer_modus not in ("sequenziell", "zufaellig"):
return {
"success": False,
"error": "invalid_nummer_modus",
"message": "nummer_modus muss 'sequenziell' oder 'zufaellig' sein.",
}
if payload.stellen < 1 or payload.stellen > 9:
return {"success": False, "error": "invalid_stellen", "message": "stellen muss zwischen 1 und 9 liegen."}
if not set_hostname_konvention(organization_id, payload.praefix, payload.stellen, payload.nummer_modus):
return {"success": False, "error": "organization_not_found", "message": "Die Organisation wurde nicht gefunden."}
return {"success": True}
@app.get("/api/v1/organizations/{organization_id}/activation-codes")
def list_activation_codes(
organization_id: str,
authorization: str | None = Header(default=None),
):
if not require_service_token(authorization):
return {
"success": False,
"error": "unauthorized",
"message": "Fehlendes oder ungültiges Service-Token.",
}
return {"success": True, "activation_codes": fetch_activation_codes(organization_id)}
@app.post("/api/v1/organizations/{organization_id}/activation-codes")
def create_activation_code_endpoint(
organization_id: str,
authorization: str | None = Header(default=None),
):
if not require_service_token(authorization):
return {
"success": False,
"error": "unauthorized",
"message": "Fehlendes oder ungültiges Service-Token.",
}
return {"success": True, "activation_code": create_activation_code(organization_id)}
@app.get("/api/v1/merkmale")
def list_merkmale(authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {"success": True, "merkmale": fetch_merkmale()}
@app.post("/api/v1/merkmale")
def create_merkmal_endpoint(payload: CreateMerkmalRequest, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {"success": True, "merkmal": create_merkmal(payload.key, payload.name, payload.description)}
@app.patch("/api/v1/merkmale/{merkmal_id}")
def update_merkmal_endpoint(
merkmal_id: str, payload: UpdateMerkmalRequest, authorization: str | None = Header(default=None)
):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
updated = update_merkmal(
merkmal_id, payload.name, payload.description, payload.kategorie_id, payload.im_auftragskatalog
)
if not updated:
return {"success": False, "error": "not_found", "message": "Merkmal wurde nicht gefunden."}
return {"success": True}
@app.get("/api/v1/kategorien")
def list_kategorien(authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {"success": True, "kategorien": fetch_kategorien()}
@app.post("/api/v1/kategorien")
def create_kategorie_endpoint(payload: CreateKategorieRequest, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {
"success": True,
"kategorie": create_kategorie(payload.key, payload.name, payload.description, payload.sort_order),
}
@app.get("/api/v1/workspaces")
def list_workspaces(authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {"success": True, "workspaces": fetch_workspaces()}
@app.post("/api/v1/workspaces")
def create_workspace_endpoint(payload: CreateWorkspaceRequest, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {
"success": True,
"workspace": create_workspace(payload.key, payload.name, payload.description, payload.organization_id),
}
@app.post("/api/v1/workspaces/{workspace_id}/clone")
def clone_workspace_endpoint(
workspace_id: str, payload: CloneWorkspaceRequest, authorization: str | None = Header(default=None)
):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
neuer_workspace = clone_workspace(workspace_id, payload.organization_id)
if neuer_workspace is None:
return {"success": False, "error": "not_found", "message": "Workspace wurde nicht gefunden."}
return {"success": True, "workspace": neuer_workspace}
@app.get("/api/v1/workspaces/{workspace_id}")
def get_workspace(workspace_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
workspace = fetch_workspace_detail(workspace_id)
if workspace is None:
return {"success": False, "error": "not_found", "message": "Workspace wurde nicht gefunden."}
return {"success": True, "workspace": workspace}
@app.post("/api/v1/workspaces/{workspace_id}/merkmale/{merkmal_id}")
def add_workspace_merkmal(workspace_id: str, merkmal_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
add_merkmal_to_workspace(workspace_id, merkmal_id)
return {"success": True}
@app.delete("/api/v1/workspaces/{workspace_id}/merkmale/{merkmal_id}")
def remove_workspace_merkmal(workspace_id: str, merkmal_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
remove_merkmal_from_workspace(workspace_id, merkmal_id)
return {"success": True}
@app.get("/api/v1/workspaces/{workspace_id}/webapps")
def list_workspace_webapps(workspace_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {"success": True, "webapps": fetch_webapps_for_workspace(workspace_id)}
@app.post("/api/v1/workspaces/{workspace_id}/webapps")
def create_workspace_webapp(workspace_id: str, payload: CreateWebappRequest, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {
"success": True,
"webapp": create_webapp(
workspace_id, payload.name, payload.url, payload.sort_order,
payload.kategorie_id, payload.kiosk_modus,
),
}
@app.put("/api/v1/workspaces/{workspace_id}/webapps/{webapp_id}")
def update_workspace_webapp(workspace_id: str, webapp_id: str, payload: UpdateWebappRequest, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {
"success": True,
"webapp": update_webapp(
webapp_id, payload.name, payload.url, payload.sort_order,
payload.kategorie_id, payload.kiosk_modus,
),
}
@app.delete("/api/v1/workspaces/{workspace_id}/webapps/{webapp_id}")
def delete_workspace_webapp(workspace_id: str, webapp_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
delete_webapp(webapp_id)
return {"success": True}
@app.get("/api/v1/webapp-templates")
def list_webapp_templates(authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {"success": True, "webapp_templates": fetch_webapp_templates()}
@app.post("/api/v1/webapp-templates")
def create_webapp_template_endpoint(payload: CreateWebappTemplateRequest, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {"success": True, "webapp_template": create_webapp_template(payload.name, payload.url, payload.description)}
@app.put("/api/v1/webapp-templates/{template_id}")
def update_webapp_template_endpoint(template_id: str, payload: UpdateWebappTemplateRequest, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {"success": True, "webapp_template": update_webapp_template(template_id, payload.name, payload.url, payload.description)}
@app.delete("/api/v1/webapp-templates/{template_id}")
def delete_webapp_template_endpoint(template_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
delete_webapp_template(template_id)
return {"success": True}
@app.get("/api/v1/workspaces/{workspace_id}/webapp-templates/available")
def list_available_webapp_templates(workspace_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {"success": True, "webapp_templates": fetch_available_templates_for_workspace(workspace_id)}
@app.post("/api/v1/workspaces/{workspace_id}/webapp-templates/{template_id}/activate")
def activate_webapp_template_endpoint(workspace_id: str, template_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
webapp = activate_webapp_template(workspace_id, template_id)
if webapp is None:
return {"success": False, "error": "not_available", "message": "Vorlage wurde nicht gefunden oder ist für diesen Workspace bereits aktiviert."}
return {"success": True, "webapp": webapp}
@app.get("/api/v1/backends")
def list_backends(authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {"success": True, "backends": fetch_backends()}
@app.get("/api/v1/blueprints")
def list_blueprints(authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {"success": True, "blueprints": fetch_blueprints()}
@app.get("/api/v1/organizations/{organization_id}/bereitstellungsvorlagen")
def list_bereitstellungsvorlagen(organization_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {"success": True, "bereitstellungsvorlagen": fetch_templates(organization_id)}
@app.post("/api/v1/organizations/{organization_id}/bereitstellungsvorlagen")
def create_bereitstellungsvorlage_endpoint(
organization_id: str,
payload: CreateBereitstellungsvorlageRequest,
authorization: str | None = Header(default=None),
):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
if not payload.label.strip():
return {"success": False, "error": "invalid_request", "message": "label darf nicht leer sein."}
if payload.target_disk_rank < 1:
return {"success": False, "error": "invalid_request", "message": "target_disk_rank muss mindestens 1 sein."}
vorlage = create_bereitstellungsvorlage(
organization_id,
payload.workspace_id,
payload.backend_id,
payload.label,
payload.is_default,
payload.disk_encryption,
payload.root_filesystem,
payload.secure_boot_required,
payload.target_disk_rank,
)
return {"success": True, "bereitstellungsvorlage": vorlage}
@app.get("/api/v1/organizations/{organization_id}/devices/unassigned")
def list_unassigned_devices(organization_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {"success": True, "devices": fetch_unassigned_devices(organization_id)}
@app.get("/api/v1/organizations/{organization_id}/enrollment-sessions")
def list_enrollment_sessions(organization_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {"success": True, "enrollment_sessions": fetch_enrollment_sessions(organization_id)}
@app.post("/api/v1/organizations/{organization_id}/enrollment-sessions")
def create_enrollment_session_endpoint(
organization_id: str,
payload: CreateEnrollmentSessionRequest,
authorization: str | None = Header(default=None),
):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
session = create_enrollment_session(
organization_id, payload.bereitstellungsvorlage_id, payload.max_devices, payload.expires_at
)
return {"success": True, "enrollment_session": session}
@app.post("/api/v1/enrollment-sessions/{session_id}/revoke")
def revoke_enrollment_session_endpoint(session_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
revoked = revoke_enrollment_session(session_id)
if not revoked:
return {"success": False, "error": "not_found", "message": "Enrollment Session wurde nicht gefunden oder ist bereits widerrufen."}
return {"success": True}
@app.patch("/api/v1/enrollment-sessions/{session_id}")
def recharge_enrollment_session_endpoint(
session_id: str,
payload: RechargeEnrollmentSessionRequest,
authorization: str | None = Header(default=None),
):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
if payload.additional_devices < 0:
return {"success": False, "error": "invalid_request", "message": "additional_devices darf nicht negativ sein."}
recharged = recharge_enrollment_session(session_id, payload.additional_devices, payload.expires_at)
if not recharged:
return {"success": False, "error": "not_found", "message": "Enrollment Session wurde nicht gefunden oder ist widerrufen."}
return {"success": True}
@app.get("/api/v1/organizations/{organization_id}/iso-builds")
def list_iso_builds(organization_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {"success": True, "iso_builds": fetch_iso_builds(organization_id)}
@app.post("/api/v1/organizations/{organization_id}/iso-builds")
def create_iso_build_endpoint(
organization_id: str,
payload: CreateIsoBuildRequest,
authorization: str | None = Header(default=None),
):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
if not payload.activation_code.strip():
return {"success": False, "error": "invalid_request", "message": "activation_code darf nicht leer sein."}
ISO_BUILD_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
iso_build = create_iso_build(organization_id)
thread = threading.Thread(
target=run_iso_build,
args=(UUID(iso_build["id"]), organization_id, payload.activation_code, payload.wifi_ssid, payload.wifi_psk),
daemon=True,
)
thread.start()
return {"success": True, "iso_build": iso_build}
@app.get("/api/v1/iso-builds/{build_id}")
def get_iso_build_endpoint(build_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
iso_build = fetch_iso_build(build_id)
if iso_build is None:
return {"success": False, "error": "not_found", "message": "ISO-Build wurde nicht gefunden."}
return {"success": True, "iso_build": iso_build}
@app.get("/api/v1/iso-builds/{build_id}/download")
def download_iso_build(build_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
iso_build = fetch_iso_build(build_id)
if iso_build is None:
return {"success": False, "error": "not_found", "message": "ISO-Build wurde nicht gefunden."}
if iso_build["status"] != "completed" or not iso_build["output_filename"]:
return {"success": False, "error": "not_ready", "message": "Der ISO-Build ist noch nicht abgeschlossen."}
output_path = ISO_BUILD_OUTPUT_DIR / iso_build["output_filename"]
if not output_path.is_file():
return {"success": False, "error": "file_missing", "message": "Die ISO-Datei wurde nicht gefunden."}
return FileResponse(
output_path,
media_type="application/octet-stream",
filename="tuxflotte-installationsmedium.iso",
)
@app.get("/api/v1/devices/{device_id}/auftragskatalog")
def get_device_auftragskatalog(
device_id: str,
authorization: str | None = Header(default=None),
):
if not require_service_token(authorization):
return {
"success": False,
"error": "unauthorized",
"message": "Fehlendes oder ungültiges Admin-Token.",
}
katalog = fetch_auftragskatalog_listing(device_id)
if katalog is None:
return {
"success": False,
"error": "device_not_assigned",
"message": "Das Gerät hat keine aktive Bereitstellungsvorlagen-Zuweisung.",
}
return {"success": True, "auftragskatalog": katalog}
# Direkte Geräte-Einzel-Auswahl (select/deselect) wurde am 10.09.2026
# abgelöst - device_merkmale ist weg, Einzelgerät-Ausnahmen laufen jetzt
# über eine Ein-Geräte-Gruppe (siehe set_gruppe_workspace() und
# gruppen_merkmale-Endpunkte oben). get_device_auftragskatalog() bleibt als
# reine Ist-Zustand-Anzeige bestehen.
# --- Organisationseinheiten (OEs) ---
@app.get("/api/v1/organizations/{organization_id}/organisationseinheiten")
def list_organisationseinheiten(organization_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {"success": True, "organisationseinheiten": fetch_organisationseinheiten(organization_id)}
@app.post("/api/v1/organizations/{organization_id}/organisationseinheiten")
def create_organisationseinheit_endpoint(
organization_id: str,
payload: CreateOrganisationseinheitRequest,
authorization: str | None = Header(default=None),
):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
oe = create_organisationseinheit(organization_id, payload.name, payload.parent_id)
if oe is None:
return {
"success": False,
"error": "invalid_parent",
"message": "Die übergeordnete OE wurde nicht gefunden oder gehört zu einer anderen Organisation.",
}
return {"success": True, "organisationseinheit": oe}
@app.patch("/api/v1/organisationseinheiten/{oe_id}")
def update_organisationseinheit_endpoint(
oe_id: str,
payload: UpdateOrganisationseinheitRequest,
authorization: str | None = Header(default=None),
):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
oe = update_organisationseinheit(oe_id, payload.name, payload.parent_id)
if oe is None:
return {
"success": False,
"error": "invalid_move",
"message": "OE nicht gefunden, übergeordnete OE gehört zu einer anderen Organisation, oder das würde einen Zyklus erzeugen.",
}
return {"success": True, "organisationseinheit": oe}
@app.delete("/api/v1/organisationseinheiten/{oe_id}")
def delete_organisationseinheit_endpoint(oe_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
deleted, fehler = delete_organisationseinheit(oe_id)
if not deleted:
return {"success": False, "error": "delete_blocked", "message": fehler}
return {"success": True}
@app.post("/api/v1/organisationseinheiten/{oe_id}/merkmale/{merkmal_key}/select")
def select_oe_merkmal(
oe_id: str,
merkmal_key: str,
payload: AuftragSelectRequest = AuftragSelectRequest(),
authorization: str | None = Header(default=None),
):
# Seit 10.09.2026 mit optionen-Payload (vorher hartcodiert {}) - siehe
# AD/OIDC/Fileserver-Merkmale, die auf OE-Ebene echte Konfigurationswerte
# brauchen (Domäne, Server-Adresse etc.), nicht nur aktiv/inaktiv.
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
if not set_oe_auswahl(oe_id, merkmal_key, True, payload.optionen):
return {"success": False, "error": "unknown_merkmal", "message": f"Kein katalogfähiges Merkmal mit Key: {merkmal_key}"}
return {"success": True}
@app.post("/api/v1/organisationseinheiten/{oe_id}/merkmale/{merkmal_key}/deselect")
def deselect_oe_merkmal(oe_id: str, merkmal_key: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
if not set_oe_auswahl(oe_id, merkmal_key, False, {}):
return {"success": False, "error": "unknown_merkmal", "message": f"Kein katalogfähiges Merkmal mit Key: {merkmal_key}"}
return {"success": True}
@app.post("/api/v1/organisationseinheiten/{oe_id}/merkmale/{merkmal_key}/unset")
def unset_oe_merkmal(oe_id: str, merkmal_key: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
if not clear_oe_auswahl(oe_id, merkmal_key):
return {"success": False, "error": "unknown_merkmal", "message": f"Kein katalogfähiges Merkmal mit Key: {merkmal_key}"}
return {"success": True}
@app.get("/api/v1/organisationseinheiten/{oe_id}/merkmale")
def get_oe_merkmale(oe_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {"success": True, "merkmale": fetch_oe_merkmale_listing(oe_id)}
@app.post("/api/v1/devices/{device_id}/oe")
def move_device_to_oe_endpoint(device_id: str, payload: MoveDeviceOeRequest, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
if not move_device_to_oe(device_id, payload.oe_id):
return {
"success": False,
"error": "invalid_move",
"message": "Gerät oder OE nicht gefunden, oder sie gehören zu unterschiedlichen Organisationen.",
}
return {"success": True}
# --- Gruppen ---
@app.get("/api/v1/organizations/{organization_id}/gruppen")
def list_gruppen(organization_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {"success": True, "gruppen": fetch_gruppen(organization_id)}
@app.post("/api/v1/organizations/{organization_id}/gruppen")
def create_gruppe_endpoint(organization_id: str, payload: CreateGruppeRequest, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {"success": True, "gruppe": create_gruppe(organization_id, payload.name)}
@app.patch("/api/v1/gruppen/{gruppe_id}")
def update_gruppe_endpoint(gruppe_id: str, payload: UpdateGruppeRequest, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
gruppe = update_gruppe(gruppe_id, payload.name)
if gruppe is None:
return {"success": False, "error": "gruppe_not_found", "message": "Die Gruppe wurde nicht gefunden."}
return {"success": True, "gruppe": gruppe}
@app.delete("/api/v1/gruppen/{gruppe_id}")
def delete_gruppe_endpoint(gruppe_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
if not delete_gruppe(gruppe_id):
return {"success": False, "error": "gruppe_not_found", "message": "Die Gruppe wurde nicht gefunden."}
return {"success": True}
@app.post("/api/v1/gruppen/{gruppe_id}/merkmale/{merkmal_key}/select")
def select_gruppe_merkmal(
gruppe_id: str,
merkmal_key: str,
payload: AuftragSelectRequest = AuftragSelectRequest(),
authorization: str | None = Header(default=None),
):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
if not set_gruppe_auswahl(gruppe_id, merkmal_key, True, payload.optionen):
return {"success": False, "error": "unknown_merkmal", "message": f"Kein katalogfähiges Merkmal mit Key: {merkmal_key}"}
return {"success": True}
@app.post("/api/v1/gruppen/{gruppe_id}/merkmale/{merkmal_key}/deselect")
def deselect_gruppe_merkmal(gruppe_id: str, merkmal_key: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
if not set_gruppe_auswahl(gruppe_id, merkmal_key, False, {}):
return {"success": False, "error": "unknown_merkmal", "message": f"Kein katalogfähiges Merkmal mit Key: {merkmal_key}"}
return {"success": True}
@app.post("/api/v1/gruppen/{gruppe_id}/merkmale/{merkmal_key}/unset")
def unset_gruppe_merkmal(gruppe_id: str, merkmal_key: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
if not clear_gruppe_auswahl(gruppe_id, merkmal_key):
return {"success": False, "error": "unknown_merkmal", "message": f"Kein katalogfähiges Merkmal mit Key: {merkmal_key}"}
return {"success": True}
@app.get("/api/v1/organizations/{organization_id}/workspaces")
def list_available_workspaces(organization_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {"success": True, "workspaces": fetch_available_workspaces_for_organization(organization_id)}
@app.post("/api/v1/gruppen/{gruppe_id}/workspace")
def set_gruppe_workspace_endpoint(
gruppe_id: str,
payload: SetGruppeWorkspaceRequest,
authorization: str | None = Header(default=None),
):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
erfolg, fehlercode = set_gruppe_workspace(gruppe_id, payload.workspace_id)
if not erfolg:
nachrichten = {
"gruppe_not_found": "Die Gruppe wurde nicht gefunden.",
"workspace_not_found": "Der Workspace wurde nicht gefunden.",
"workspace_wrong_organization": "Dieser Workspace gehört einer anderen Organisation.",
"workspace_conflict": "Mindestens ein Mitgliedsgerät ist bereits in einer anderen "
"workspace-tragenden Gerätegruppe.",
}
return {
"success": False,
"error": fehlercode,
"message": nachrichten.get(fehlercode, "Workspace konnte nicht zugewiesen werden."),
}
return {"success": True}
@app.delete("/api/v1/gruppen/{gruppe_id}/workspace")
def clear_gruppe_workspace_endpoint(gruppe_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
if not clear_gruppe_workspace(gruppe_id):
return {"success": False, "error": "gruppe_not_found", "message": "Die Gruppe wurde nicht gefunden."}
return {"success": True}
@app.get("/api/v1/gruppen/{gruppe_id}/merkmale")
def get_gruppe_merkmale(gruppe_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {"success": True, "merkmale": fetch_gruppe_merkmale_listing(gruppe_id)}
@app.get("/api/v1/devices/{device_id}/gruppen")
def get_device_gruppen(device_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {"success": True, "gruppen": fetch_device_gruppen(device_id)}
@app.post("/api/v1/devices/{device_id}/gruppen/{gruppe_id}")
def add_device_gruppe_endpoint(device_id: str, gruppe_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
ergebnis = add_device_to_gruppe(device_id, gruppe_id)
if ergebnis == "conflict":
return {
"success": False,
"error": "workspace_conflict",
"message": "Das Gerät ist bereits in einer anderen Gerätegruppe mit eigenem Workspace. "
"Ein Gerät kann nur in einer workspace-tragenden Gerätegruppe gleichzeitig sein.",
}
if not ergebnis:
return {
"success": False,
"error": "invalid_membership",
"message": "Gerät oder Gruppe nicht gefunden, oder sie gehören zu unterschiedlichen Organisationen.",
}
return {"success": True}
@app.delete("/api/v1/devices/{device_id}/gruppen/{gruppe_id}")
def remove_device_gruppe_endpoint(device_id: str, gruppe_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
remove_device_from_gruppe(device_id, gruppe_id)
return {"success": True}
@app.post("/api/v1/devices/{device_id}/debug-mode")
def aktiviere_debug_modus(device_id: str, authorization: str | None = Header(default=None)):
"""
Debug-Modus (26.08.2026-Planung): setzt debug_mode_until auf jetzt+6h -
agent_checkin() vergleicht das bei jedem Checkin und liefert solange das
kurze Debug-Intervall statt des Standardintervalls, faellt danach von
selbst zurueck (kein Cron noetig). Org-Zugehoerigkeitspruefung ist wie
ueberall Aufgabe der aufrufenden Kundenplattform (find_device_in_
organization()), nicht dieses Endpunkts.
"""
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
debug_bis = datetime.now(timezone.utc) + timedelta(hours=DEBUG_MODE_DURATION_HOURS)
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE devices SET debug_mode_until = %s WHERE id = %s",
(debug_bis, device_id),
)
return {"success": True, "debug_mode_until": debug_bis.isoformat()}
@app.delete("/api/v1/devices/{device_id}/debug-mode")
def deaktiviere_debug_modus(device_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute("UPDATE devices SET debug_mode_until = NULL WHERE id = %s", (device_id,))
return {"success": True}