Files

9689 lines
399 KiB
Python
Raw Permalink 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 shutil
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
from typing import Literal
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, Response
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", "")
# Gestuftes Ausrollen (13.10.2026, Migration 0059, ADR-0043): Organisationen
# mit Ausrollstufe "stabil" bekommen im Check-in die Adresse des
# freigegebenen Repos, "vorab" die des Entwicklungsstands. Zwei Repos statt
# zwei Zweige, weil der Agent immer den Standardzweig der gelieferten Adresse
# zieht und sich auf installierten Geräten nicht aktualisiert. Ohne eigene
# Vorgabe gilt die Namenskonvention <repo>-stabil.
ANSIBLE_CONTENT_REPO_STABIL = os.environ.get("TUXFLOTTE_ANSIBLE_CONTENT_REPO_STABIL", "") or (
re.sub(r"(\.git)?$", r"-stabil\1", ANSIBLE_CONTENT_REPO, count=1) if ANSIBLE_CONTENT_REPO else ""
)
# Seit 25.10.2026 (Migration 0073) heißt die freigegebene Stufe "testing"
# (Testschulen); Spiegel und Repo heißen weiter "stabil". Eine Stufe
# "stabil" für zahlende Kunden, die selbst über das Einspielen entscheiden,
# kommt später (platform-docs roadmap/einstieg-vereinfachen.md).
AUSROLLSTUFEN = ("testing", "vorab")
# Spiegel für Geräte (ADR-0046, 20.10.2026): anode hält Spiegel von
# ansible-content (je Ausrollstufe) und provisioning-agent, erzeugt je Stand
# ein Archiv und liefert beides nur gegen das Agent-Secret aus. Damit hängen
# die Geräte nicht mehr an einem öffentlich erreichbaren Gitea. Im Übergang
# schickt der Check-in weiter auch "ansible_repo" (Rückfall per Git).
AGENT_REPO = os.environ.get("TUXFLOTTE_AGENT_REPO", "") or (
re.sub(r"ansible-content(\.git)?$", r"provisioning-agent\1", ANSIBLE_CONTENT_REPO, count=1)
if ANSIBLE_CONTENT_REPO else ""
)
SPIEGEL_DIR = Path(os.environ.get("TUXFLOTTE_SPIEGEL_DIR", "data/spiegel")).resolve()
SPIEGEL_INTERVALL_SECONDS = 60
SPIEGEL_GIT_TIMEOUT_SECONDS = 120
# Je Stufe ausgelieferte Stände: der aktuelle und zwei vorige - ein Gerät,
# das seinen Stand kurz vor einer Freigabe erfahren hat, kann ihn noch holen.
SPIEGEL_STAENDE_JE_STUFE = 3
# Meldung des Agenten, wie er den Stand bezogen hat (Check-in "ansible_weg").
ANSIBLE_WEGE = ("archiv", "git", "git-rueckfall")
# Dateien, die Agent-Wechsel und Neuinstallation aus provisioning-agent holen.
AGENT_FASSUNG_DATEIEN = ("agent.py", "firstboot.sh", "tuxflotte-agent.service", "tuxflotte-firstboot.service")
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"
# Hostnamen vergibt immer der Server. Organisationen ohne eigenen Präfix
# bekommen diesen (13.10.2026) - vorher galt dann der vom Client gemeldete
# Name, und der Client ist das Live-System des Boot-Mediums: jedes Gerät
# hieß "debian" (auch das erste Kundengerät).
STANDARD_HOSTNAME_PRAEFIX = "tuxflotte"
# 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), BOOT_MEDIUM_PATH
# das eigene, distributionsunabhaengige Boot-Medium (build_boot_medium.sh +
# publish_boot_medium.sh), aus dem JEDE Kunden-ISO gebaut wird - egal ob Mint,
# Debian oder Ubuntu. Hiess frueher MINT_ISO_PATH (aus der Zeit, als die
# Mint-Live-ISO gepatcht wurde); die alte Variable gilt als Rueckfallebene.
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.
BOOT_MEDIUM_PATH = str(Path(
os.environ.get("TUXFLOTTE_BOOT_MEDIUM_PATH")
or os.environ.get("TUXFLOTTE_MINT_ISO_PATH")
or "data/upstream/tuxflotte-boot-medium.iso"
).resolve())
# Aus demselben Grund wie BOOT_MEDIUM_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 - 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()
# Merkmal-Icons (Phase 6, 11.09.2026) - eigenes Verzeichnis statt
# WEBAPP_ICONS_DIR mitzunutzen, obwohl der Mechanismus fast identisch ist
# (fetch_merkmal_icon()/_write_merkmal_icon() unten): Merkmale sind
# Katalog-Stammdaten (per Migration angelegt), Webapps sind kundeneigene
# Laufzeitdaten - getrennte Verzeichnisse halten das auseinander.
MERKMAL_ICONS_DIR = Path(os.environ.get("TUXFLOTTE_MERKMAL_ICONS_DIR", "data/merkmal-icons")).resolve()
SIMPLE_ICONS_CDN_URL = "https://cdn.simpleicons.org"
# 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
# Keine automatische API-Doku (18.10.2026): /docs, /redoc und /openapi.json
# waren über anode.tuxflotte.de ohne Anmeldung erreichbar und beschrieben
# alle Endpunkte. Die Kundenplattform hat das schon seit 25.08. abgeschaltet.
app = FastAPI(
title="Provisioning Activation Server", version="0.1.0",
docs_url=None, redoc_url=None, openapi_url=None,
)
@app.on_event("startup")
def start_iso_build_expiry_sweep() -> None:
threading.Thread(target=run_iso_build_expiry_sweep, daemon=True).start()
@app.on_event("startup")
def start_spiegel() -> None:
spiegel_stand_laden()
threading.Thread(target=run_spiegel_schleife, 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 CreateActivationCodeRequest(BaseModel):
bezeichnung: str | None = None
gueltig_tage: int | None = None
class ActivationCodeStatusRequest(BaseModel):
active: bool
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
# SHA-256 der agent.py (14.10.2026, ADR-0044) - ältere Agenten schicken
# das Feld nicht, dann bleibt der gespeicherte Wert unverändert. Bewusst
# ohne Musterprüfung im Modell: ein ungültiger Wert darf den Check-in
# nicht ablehnen, er wird in agent_checkin() nur verworfen.
agent_sha256: str | None = None
# Weg zum Ansible-Stand (ADR-0046): "archiv", "git" oder "git-rueckfall".
# Wie agent_sha256 ohne Prüfung im Modell, unbekannte Werte verwirft
# agent_checkin().
ansible_weg: str | 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
kundengruppe_id: str | None = None
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
art: Literal["app", "richtlinie"] = "app"
class WorkspaceAktualisierenRequest(BaseModel):
name: str
description: str | None = None
class WorkspaceVeroeffentlichenRequest(BaseModel):
notiz: str | None = None
class WorkspaceVorlageRequest(BaseModel):
ist_vorlage: bool
sichtbarkeit: Literal["global", "kundengruppe", "organisation"] = "global"
kundengruppe_ids: list[str] = []
organization_ids: list[str] = []
class WorkspaceKopieRequest(BaseModel):
name: str
backend_id: str
organization_id: str | None = None
class CreateWorkspaceAssistentRequest(BaseModel):
"""Workspace-Creator neu, Schritt D: alles, was der Assistent sammelt."""
name: str
description: str | None = None
organization_id: str | None = None
backend_id: str
merkmal_ids: list[str] = []
webapp_template_ids: list[str] = []
class CreateWorkspaceRequest(BaseModel):
key: str
name: str
description: str | None = None
organization_id: str | None = None
backend_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
auf_desktop: bool = False
class WebappDesktopRequest(BaseModel):
auf_desktop: bool
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
kategorie_id: str | None = None
class UpdateWebappTemplateRequest(BaseModel):
name: str
url: str
description: str | None = None
kategorie_id: str | None = None
class ExtraPartitionRequest(BaseModel):
mountpoint: str
percent: int
filesystem: str
class CreateBereitstellungsvorlageRequest(BaseModel):
workspace_id: str
# Seit ADR-0038 optional: ohne Angabe gilt die Distro des Workspace.
backend_id: str | None = None
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
# scheme:"custom" + extra_partitions (siehe platform-docs/roadmap/
# e2e-testrunde-lokale-adressierung.md Phase 3, 19.09.2026) - loest die
# bisherige Einschraenkung ab, dass ein custom-Schema nur per direkter
# DB-Pflege moeglich war (siehe Kommentar in create_bereitstellungsvorlage()).
scheme: str = "single"
extra_partitions: list[ExtraPartitionRequest] = []
class CreateEnrollmentSessionRequest(BaseModel):
# Ohne Angabe wählt der Server die Installation passend zum Workspace
# der Ziel-OE (Einstieg vereinfachen, Schritt 3).
bereitstellungsvorlage_id: str | None = None
max_devices: int
expires_at: str
neuinstallation_ohne_rueckfrage: bool = False
ziel_oe_id: str | None = None
class SetOeWorkspaceRequest(BaseModel):
workspace_id: str | None = None
version_id: str | None = None
automatisch: bool = True
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
# Diagnosezugang (02.10.2026): nur der SHA-512-crypt-Hash, nie das
# Passwort selbst - das kennt allein die Kundenplattform (Root-Admin).
root_password_hash: str | None = None
# Offline-Medium (Roadmap V5): Golden Image auf die ISO legen.
offline_medium: bool = False
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 OrgRessourceRequest(BaseModel):
typ: str
name: str
daten: dict = {}
ganze_organisation: bool = True
oe_ids: list[str] = []
gruppe_ids: list[str] = []
class UpdateGruppeRequest(BaseModel):
name: 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
personalisierungen_beenden_bei_neuinstallation(cur, row[0])
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
AND (ac.gueltig_bis IS NULL OR ac.gueltig_bis > now())
""",
(code,),
)
row = cur.fetchone()
if row is None:
return None
return {"organization_id": str(row[0]), "organization_name": row[1]}
def mark_activation_code_used(code: str):
"""Nutzung zaehlen (GUI-Runde 09.10.2026) - nur nach erfolgreicher
Aktivierung, damit die Verwaltungsseite zeigt, ob ein Code noch
gebraucht wird."""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE activation_codes
SET verwendet_anzahl = verwendet_anzahl + 1, zuletzt_verwendet = now()
WHERE code = %s
""",
(code,),
)
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,
es.neuinstallation_ohne_rueckfrage, es.ziel_oe_id
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, neuinstallation_ohne_rueckfrage, ziel_oe_id,
) = 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),
"neuinstallation_ohne_rueckfrage": bool(neuinstallation_ohne_rueckfrage),
"ziel_oe_id": str(ziel_oe_id) if ziel_oe_id else None,
}
def geraet_in_ziel_oe(device_id: str, ziel_oe_id: str) -> None:
"""Gerät aus einem Installationsmedium mit Ziel-OE dorthin setzen
(Schritt F) - auch bei einer Neuinstallation eines bekannten Geräts:
wer mit diesem Stick installiert, will es dort haben. Die Distro-
Verträglichkeit wurde beim Erstellen des Mediums geprüft."""
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)
""",
(ziel_oe_id, device_id, ziel_oe_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,
bv.partitioning, bv.disk_encryption, bv.secure_boot_required,
(SELECT count(*) FROM assignments a JOIN devices d ON d.id = a.device_id
WHERE a.bereitstellungsvorlage_id = bv.id AND d.archived_at IS NULL),
(SELECT count(*) FROM enrollment_sessions es
WHERE es.bereitstellungsvorlage_id = bv.id AND es.revoked_at IS NULL AND es.expires_at > now())
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],
"partitioning": row[8],
"disk_encryption": row[9],
"secure_boot_required": row[10],
# GUI-Runde 09.10.2026 (Punkt 5): Nutzung - nur aktive Geräte und
# gültige Installationsmedien; solange es die gibt, ist Löschen
# gesperrt (archivierte Geräte/abgelaufene Medien zählen nicht).
"geraete_anzahl": row[11],
"medien_anzahl": row[12],
}
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,
scheme: str = "single",
extra_partitions: list[dict] | None = None,
) -> 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). Seit 19.09.2026 (siehe platform-docs/roadmap/
e2e-testrunde-lokale-adressierung.md Phase 3) deckt dieses Formular auch
scheme:"custom" + extra_partitions ab - Validierung liegt im aufrufenden
Endpoint (create_bereitstellungsvorlage_endpoint()).
"""
vorlage_id = uuid4()
partitioning = {
"scheme": scheme,
"root_filesystem": root_filesystem,
"target_disk_rank": target_disk_rank,
"extra_partitions": extra_partitions or [],
}
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)
# Live gefunden (11.09.2026, Phase 6 Icon-Backfill): learningequality.org
# liefert sein Favicon mit Content-Type "image/svg+xml", tatsaechlich sind
# die Bytes ein PNG-in-ICO-Container - ein Browser haette das als kaputtes
# Bild angezeigt (SVG-Parser auf Binaerdaten). Server-Header sind nicht
# vertrauenswuerdig genug, um sie ungeprueft in icon_content_type zu
# uebernehmen - _erkanntes_bildformat() sniffed stattdessen die echten
# Magic Bytes, der Header dient nur noch als Fallback, falls die Signatur
# nichts Bekanntes ergibt (z.B. ein Format ausserhalb dieser Liste).
_BILD_SIGNATUREN = [
(b"\x89PNG\r\n\x1a\n", "image/png"),
(b"\xff\xd8\xff", "image/jpeg"),
(b"GIF87a", "image/gif"),
(b"GIF89a", "image/gif"),
(b"\x00\x00\x01\x00", "image/x-icon"),
]
def _erkanntes_bildformat(data: bytes) -> str | None:
kopf = data[:16]
for signatur, mime in _BILD_SIGNATUREN:
if kopf.startswith(signatur):
return mime
if kopf.startswith(b"RIFF") and data[8:12] == b"WEBP":
return "image/webp"
if b"<svg" in data[:512]:
return "image/svg+xml"
return None
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, _erkanntes_bildformat(data) or 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)
def fetch_merkmal_icon(icon_source_hint: str | None, homepage_url: str | None) -> tuple[bytes, str] | None:
"""
Icon-Fetch fuer ein Merkmal (Phase 6, 11.09.2026, Plan-Klaerungsrunde
"Kombi aus Simple-Icons-CDN + Favicon-Fetch"): zuerst den Simple-Icons-
Slug probieren (bekannte Marken-Apps, liefert ein sauberes einheitliches
SVG), sonst fetch_favicon() der hinterlegten Homepage (Nischen-Apps,
gleicher Mechanismus wie bei Webapps). None, wenn beides fehlschlaegt
oder gar nichts hinterlegt ist - der Aufrufer setzt dann kein
icon_filename, die Flottenmatrix faellt auf ihr bisheriges Haekchen/
Fallback-Icon zurueck.
"""
if icon_source_hint:
gefunden = _fetch_url_bytes(f"{SIMPLE_ICONS_CDN_URL}/{icon_source_hint}", FAVICON_MAX_BYTES)
if gefunden is not None:
return gefunden
if homepage_url:
return fetch_favicon(homepage_url)
return None
# 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"]
# Standardbrowser je Workspace-Version (13.10.2026, Migration 0058): Firefox
# ist auf den Images vorinstalliert, die übrigen sind Merkmale und nur
# wählbar, wenn sie in derselben Version enthalten sind. Der Server gibt die
# Vorgabe als eigenen Auftrag an den Agenten weiter (Rolle "standardbrowser",
# Option "browser"); es gibt dafür bewusst kein Katalog-Merkmal, damit die
# Vorgabe nicht zusätzlich in der Merkmal-Auswahl auftaucht.
STANDARDBROWSER_AUFTRAG = "standardbrowser"
STANDARDBROWSER_FIREFOX = "firefox"
STANDARDBROWSER_MERKMALE = ["google-chrome", "chromium", "browser-brave", "microsoft-edge"]
def standardbrowser_auswahl(cur, version_id) -> list[dict]:
"""Was in dieser Version als Standardbrowser wählbar ist."""
cur.execute(
"""
SELECT m.key, m.name
FROM workspace_version_merkmale vm
JOIN merkmale m ON m.id = vm.merkmal_id
WHERE vm.workspace_version_id = %s AND m.key = ANY(%s)
ORDER BY m.name
""",
(version_id, STANDARDBROWSER_MERKMALE),
)
return [{"key": STANDARDBROWSER_FIREFOX, "name": "Firefox (vorinstalliert)"}] + [
{"key": key, "name": name} for key, name in cur.fetchall()
]
def set_workspace_standardbrowser(workspace_id: str, browser: str | None):
"""Setzt die Vorgabe im Entwurf. Rückgabe: None oder Fehlermeldung."""
with get_database_connection() as conn:
with conn.cursor() as cur:
vid = entwurf_id(cur, workspace_id)
if vid is None:
return "Änderungen gehen nur in einem Entwurf - bitte zuerst einen Entwurf anlegen."
if browser is not None and browser not in {b["key"] for b in standardbrowser_auswahl(cur, vid)}:
return "Dieser Browser ist im Workspace nicht enthalten."
cur.execute("UPDATE workspace_versionen SET standardbrowser = %s WHERE id = %s", (browser, vid))
return None
def _resolve_kiosk_browser(version_id) -> str | None:
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT m.key
FROM workspace_version_merkmale vm
JOIN merkmale m ON m.id = vm.merkmal_id
WHERE vm.workspace_version_id = %s AND m.key = ANY(%s)
""",
(version_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):
"""Webapps eines Geräts: die der Workspace-Version (geraete_basis())
plus die Ergänzungen aller Gruppen, in denen das Gerät ist."""
with get_database_connection() as conn:
with conn.cursor() as cur:
basis = geraete_basis(cur, device_id)
if basis is None:
return []
version_id, _backend_id = basis
cur.execute(
"""
SELECT w.id, w.name, w.url, w.icon_filename, w.kiosk_modus, k.key, w.auf_desktop, w.sort_order
FROM workspace_webapps w
LEFT JOIN kategorien k ON k.id = w.kategorie_id
WHERE w.workspace_version_id = %s
UNION ALL
SELECT gw.id, gw.name, gw.url, gw.icon_filename, gw.kiosk_modus, k.key, gw.auf_desktop, gw.sort_order
FROM gruppen_webapps gw
JOIN device_gruppen dg ON dg.gruppe_id = gw.gruppe_id
LEFT JOIN kategorien k ON k.id = gw.kategorie_id
WHERE dg.device_id = %s
ORDER BY 8, 2
""",
(version_id, device_id),
)
zeilen = [row[:7] for row in cur.fetchall()]
kiosk_browser = _resolve_kiosk_browser(version_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,
# Starter zusätzlich auf dem Desktop; sonst nur im Menü
# (Migration 0070).
"auf_desktop": auf_desktop,
}
for webapp_id, name, url, icon_filename, kiosk_modus, kategorie_key, auf_desktop in zeilen
]
def version_blueprints(cur, version_id, backend_id) -> list[dict]:
"""Merkmale einer Workspace-Version, die auf dieser Distro umgesetzt
sind, mit ihrer Ansible-Rolle."""
cur.execute(
"""
SELECT m.key, bp.ansible_role
FROM workspace_version_merkmale vm
JOIN merkmale m ON m.id = vm.merkmal_id
JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s
WHERE vm.workspace_version_id = %s
""",
(backend_id, version_id),
)
return [{"merkmal": key, "ansible_role": ansible_role} for key, ansible_role in cur.fetchall()]
def profil_blueprints(template_id: str) -> list[dict] | None:
"""Blueprints, mit denen ein Gerät über diese Bereitstellungsvorlage
(Installationsprofil) installiert wird: neueste veröffentlichte Version
des Profil-Workspace auf der Profil-Distro."""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT workspace_id, backend_id FROM bereitstellungsvorlagen WHERE id = %s",
(template_id,),
)
row = cur.fetchone()
if row is None:
return None
workspace_id, backend_id = row
return version_blueprints(cur, veroeffentlichte_version_id(cur, workspace_id), backend_id)
def fetch_assigned_blueprints(device_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
basis = geraete_basis(cur, device_id)
if basis is None:
return []
version_id, backend_id = basis
return version_blueprints(cur, version_id, backend_id)
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 veroeffentlichte_version_id(cur, workspace_id):
"""Neueste veröffentlichte Version eines Workspace (None, falls es noch
keine gibt) - das bekommen Geräte, deren OE "künftige Versionen
automatisch übernehmen" hat, und alle Bestandsgeräte ohne OE-Workspace."""
cur.execute(
"""
SELECT id FROM workspace_versionen
WHERE workspace_id = %s AND status = 'veroeffentlicht'
ORDER BY nummer DESC LIMIT 1
""",
(workspace_id,),
)
row = cur.fetchone()
return row[0] if row else None
def arbeitsversion_id(cur, workspace_id):
"""
Die Version, die die Admin-Bearbeitung eines Workspace verändert: der
offene Entwurf, sonst die neueste veröffentlichte. Übergangsregel bis
Schritt E (Entwurf/Veröffentlichen mit Wirkungsvorschau, siehe
platform-docs/roadmap/workspace-creator-neu.md): solange es keinen
Entwurf gibt, wirken Änderungen wie bisher sofort.
"""
cur.execute(
"""
SELECT id FROM workspace_versionen
WHERE workspace_id = %s
ORDER BY (status = 'entwurf') DESC, nummer DESC LIMIT 1
""",
(workspace_id,),
)
row = cur.fetchone()
return row[0] if row else None
def geraete_basis(cur, device_id: str):
"""Wie geraete_basis_herkunft(), ohne die Herkunft."""
ergebnis = geraete_basis_herkunft(cur, device_id)
return ergebnis[:2] if ergebnis else None
def geraete_basis_herkunft(cur, device_id: str):
"""
Grundausstattung eines Geräts (Workspace-Creator neu, Schritt C):
(workspace_version_id, backend_id, herkunft) oder None ohne Zuweisung.
herkunft: {"art": "oe", "oe_id", "automatisch"} oder {"art": "installation"}
- für die Wirkungsvorschau (wer steigt bei einer neuen Version mit um).
Die Distro (backend_id) kommt immer aus der Installation und ändert sich
nie. Der Workspace kommt von der nächstgelegenen OE mit Workspace (eigene
OE, dann die Eltern bis zur Wurzel) - mit deren gepinnter Version, oder
der neuesten veröffentlichten, wenn dort "automatisch aktualisieren"
gesetzt ist. Trägt keine OE der Kette einen Workspace, gilt wie bisher
der Workspace aus der Installation (neueste veröffentlichte Version).
Gruppen ersetzen die Grundausstattung nicht mehr, sie ergänzen nur
(resolve_katalog_overrides(), gruppen_webapps).
"""
cur.execute(
"""
SELECT bv.workspace_id, bv.backend_id, d.organization_id, d.oe_id
FROM assignments a
JOIN bereitstellungsvorlagen bv ON bv.id = a.bereitstellungsvorlage_id
JOIN devices d ON d.id = a.device_id
WHERE a.device_id = %s
""",
(device_id,),
)
row = cur.fetchone()
if row is None:
return None
installations_workspace_id, backend_id, organization_id, oe_id = row
if oe_id is not None:
cur.execute(
"""
SELECT id, parent_id, workspace_id, workspace_version_id, automatisch_aktualisieren
FROM organisationseinheiten WHERE organization_id = %s
""",
(organization_id,),
)
oes = {r[0]: r[1:] for r in cur.fetchall()}
aktuell = oe_id
while aktuell is not None and aktuell in oes:
parent_id, workspace_id, version_id, automatisch = oes[aktuell]
if workspace_id is not None:
# Sicherheitsnetz (Schritt F): passt die Distro des
# OE-Workspace nicht zur Distro des Geräts (z. B. nach einer
# Neuinstallation mit anderer Distro), gilt der Workspace aus
# der Installation - statt still fehlender Apps. Zuweisungen
# und Verschiebungen verhindern diesen Fall sonst vorab
# (distro_konflikte()).
cur.execute("SELECT backend_id FROM workspaces WHERE id = %s", (workspace_id,))
ws_backend = cur.fetchone()[0]
if ws_backend is not None and ws_backend != backend_id:
return (
veroeffentlichte_version_id(cur, installations_workspace_id), backend_id,
{"art": "installation", "oe_konflikt": aktuell},
)
automatisch = bool(automatisch or version_id is None)
if automatisch:
version_id = veroeffentlichte_version_id(cur, workspace_id)
return version_id, backend_id, {"art": "oe", "oe_id": aktuell, "automatisch": automatisch}
aktuell = parent_id
return (
veroeffentlichte_version_id(cur, installations_workspace_id), backend_id,
{"art": "installation"},
)
def oe_kette_workspace(cur, oe_id: str):
"""Nächstgelegener Workspace in der OE-Kette (eigene OE, dann Eltern) als
(workspace_id, name, backend_id) oder None - dieselbe Reihenfolge wie
geraete_basis_herkunft()."""
cur.execute("SELECT organization_id FROM organisationseinheiten WHERE id = %s", (oe_id,))
row = cur.fetchone()
if row is None:
return None
cur.execute(
"""
SELECT oe.id, oe.parent_id, w.id, w.name, w.backend_id
FROM organisationseinheiten oe
LEFT JOIN workspaces w ON w.id = oe.workspace_id
WHERE oe.organization_id = %s
""",
(row[0],),
)
oes = {r[0]: r[1:] for r in cur.fetchall()}
aktuell = UUID(str(oe_id))
while aktuell is not None and aktuell in oes:
parent_id, workspace_id, name, backend_id = oes[aktuell]
if workspace_id is not None:
return str(workspace_id), name, str(backend_id) if backend_id else None
aktuell = parent_id
return None
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: Grundausstattung (Workspace-Version, siehe
geraete_basis()) → 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:
basis = geraete_basis(cur, device_id)
if basis is None:
return []
version_id, backend_id = basis
cur.execute(
"""
SELECT m.id, m.key, bp.ansible_role,
(vm.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_version_merkmale vm
ON vm.merkmal_id = m.id AND vm.workspace_version_id = %s
WHERE m.im_auftragskatalog
""",
(backend_id, version_id),
)
rows = cur.fetchall()
cur.execute("SELECT standardbrowser FROM workspace_versionen WHERE id = %s", (version_id,))
standardbrowser_row = cur.fetchone()
standardbrowser = standardbrowser_row[0] if standardbrowser_row else None
ressourcen = fetch_geraete_ressourcen(cur, device_id)
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 = {}
if key == ORG_WLAN_MERKMAL and merkmal_id not in overrides:
org_wlan = org_wlan_fuer_geraet(device_id)
if org_wlan:
aktiv, optionen = True, org_wlan
optionen = decrypt_sensitive_optionen(optionen)
if key == VEYON_MERKMAL and aktiv:
optionen = veyon_optionen_anreichern(device_id, merkmal_id, optionen)
if key == PERSONALISIERUNG_MERKMAL and aktiv:
modus = personalisierung_modus(device_id)
optionen = {
**optionen,
"modus": modus,
# Für Rollenstände vor "modus" (18.10.2026).
"anmeldung_anbieten": modus == "personalisieren",
"geraet": str(device_id),
"kiosk_token": personalisierung_kiosk_token(device_id),
}
ergebnis.append({
"merkmal": key,
"ansible_role": ansible_role,
"state": "present" if aktiv else "absent",
"optionen": optionen,
})
# Standardbrowser der Workspace-Version: immer mitschicken, auch als
# "absent" - sonst liefe die Rolle nach dem Zurücknehmen der Vorgabe nie
# wieder und der alte Eintrag bliebe stehen.
ergebnis.append({
"merkmal": STANDARDBROWSER_AUFTRAG,
"ansible_role": STANDARDBROWSER_AUFTRAG,
"state": "present" if standardbrowser else "absent",
"optionen": {"browser": standardbrowser} if standardbrowser else {},
})
# Org-Ressourcen (siehe ORG_RESSOURCEN_AUFTRAG): wie beim Standardbrowser
# immer mitschicken, auch als "absent", damit die Rolle nach dem Löschen
# der letzten Ressource aufräumt. Nicht freigeschaltet: gar nicht.
if ressourcen is not None:
ergebnis.append({
"merkmal": ORG_RESSOURCEN_AUFTRAG,
"ansible_role": ORG_RESSOURCEN_AUFTRAG,
"state": "present" if ressourcen else "absent",
"optionen": {"ressourcen": ressourcen} if ressourcen else {},
})
return ergebnis
# Veyon (15.10.2026, platform-docs roadmap/veyon-schluesselverteilung-
# diskussion-2026-10-14.md, Bauplan Schritt 1, Migration 0063): ein
# Schluesselpaar je OE (= Raum). Der Check-in reichert die Optionen des
# Merkmals "veyon" an - wie die Org-Ressourcen serverseitig berechnet, der
# Agent reicht sie ungesehen an die Rolle weiter (seit Agent 17d7186 ueber
# eine root-Datei, nicht mehr per Kommandozeile):
# veyon_schluesselname, veyon_public_pem, veyon_raum, veyon_lehrer
# nur Lehrergeraete zusaetzlich: veyon_private_pem, veyon_rechner
# Format wie `veyon-cli authkeys create` (RSA 4096, privat PKCS#8-PEM,
# oeffentlich SubjectPublicKeyInfo-PEM) - auf Liebherr-83/-35 am 15.10.2026
# mit einem so erzeugten Paar geprueft.
VEYON_MERKMAL = "veyon"
VEYON_SCHLUESSEL_PRAEFIX = "tuxflotte"
def veyon_schluesselname(oe_id) -> str:
"""Veyon erlaubt nur Buchstaben: Praefix + erste 12 Hex-Zeichen der
OE-ID, Ziffern 0-9 auf g-p abgebildet. Eindeutig genug je
Installation (UNIQUE in der Tabelle faengt den Rest ab), stabil."""
hexteil = str(oe_id).replace("-", "")[:12].lower()
return VEYON_SCHLUESSEL_PRAEFIX + "".join(
chr(ord("g") + int(c)) if c.isdigit() else c for c in hexteil
)
def erzeuge_veyon_schluesselpaar() -> tuple[str, str]:
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
schluessel = rsa.generate_private_key(public_exponent=65537, key_size=4096)
privat = schluessel.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode("ascii")
oeffentlich = schluessel.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
).decode("ascii")
return privat, oeffentlich
def hole_oder_erzeuge_veyon_schluessel(cur, oe_id) -> tuple[str, str, str]:
"""(name, public_pem, private_pem) der OE; legt das Paar beim ersten
Bedarf an. ON CONFLICT DO NOTHING + erneutes Lesen: zwei Geraete
derselben OE, die gleichzeitig einchecken, bekommen dasselbe Paar."""
cur.execute(
"SELECT name, public_pem, private_pem_encrypted FROM veyon_schluessel WHERE oe_id = %s",
(oe_id,),
)
row = cur.fetchone()
if row is None:
privat, oeffentlich = erzeuge_veyon_schluesselpaar()
cur.execute(
"""
INSERT INTO veyon_schluessel (oe_id, name, public_pem, private_pem_encrypted)
VALUES (%s, %s, %s, %s)
ON CONFLICT (oe_id) DO NOTHING
""",
(oe_id, veyon_schluesselname(oe_id), oeffentlich,
_fernet().encrypt(privat.encode("ascii")).decode("ascii")),
)
cur.execute(
"SELECT name, public_pem, private_pem_encrypted FROM veyon_schluessel WHERE oe_id = %s",
(oe_id,),
)
row = cur.fetchone()
name, oeffentlich, privat_verschluesselt = row
privat = _fernet().decrypt(privat_verschluesselt.encode("ascii")).decode("ascii")
return name, oeffentlich, privat
def veyon_aktiv(cur, device_id, merkmal_id) -> bool:
"""Effektiver Zustand des Merkmals veyon fuer ein (anderes) Geraet -
dieselbe Aufloesung wie fetch_auftragskatalog_state(): OE-/Gruppen-
Zwischenschicht, sonst der Workspace-Standard."""
overrides = resolve_katalog_overrides(str(device_id))
if merkmal_id in overrides:
return bool(overrides[merkmal_id][0])
basis = geraete_basis(cur, str(device_id))
if basis is None:
return False
version_id, _backend_id = basis
cur.execute(
"SELECT 1 FROM workspace_version_merkmale WHERE workspace_version_id = %s AND merkmal_id = %s",
(version_id, merkmal_id),
)
return cur.fetchone() is not None
def veyon_ist_lehrer(cur, device_id, merkmal_id, optionen: dict) -> bool:
"""Lehrergeraet, wenn IRGENDEINE aktive Quelle das Haekchen setzt -
nicht nur die eine, deren Optionen resolve_katalog_overrides() liefert
(dort gewinnt die OE, sobald Veyon dort aktiv ist; ein Haekchen an der
Gruppe "Veyon-Lehrer" ginge sonst verloren). Entscheidung 15.10.2026:
Checkbox veyon_lehrer an der Gruppe."""
if optionen.get("veyon_lehrer") is True:
return True
cur.execute(
"""
SELECT 1
FROM device_gruppen dg
JOIN gruppen_merkmale gm ON gm.gruppe_id = dg.gruppe_id
WHERE dg.device_id = %s AND gm.merkmal_id = %s AND gm.aktiv
AND (gm.optionen ->> 'veyon_lehrer') = 'true'
""",
(device_id, merkmal_id),
)
return cur.fetchone() is not None
def veyon_optionen_anreichern(device_id, merkmal_id, optionen: dict) -> dict:
"""Siehe VEYON_MERKMAL. Fehler (z.B. fehlender TUXFLOTTE_ENCRYPTION_KEY)
brechen den Check-in nicht ab: die Rolle bekommt dann keinen Schluessel
und laesst die Authentifizierung unangetastet."""
try:
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT d.oe_id, oe.name
FROM devices d JOIN organisationseinheiten oe ON oe.id = d.oe_id
WHERE d.id = %s
""",
(device_id,),
)
row = cur.fetchone()
if row is None:
return optionen
oe_id, oe_name = row
name, oeffentlich, privat = hole_oder_erzeuge_veyon_schluessel(cur, oe_id)
lehrer = veyon_ist_lehrer(cur, device_id, merkmal_id, optionen)
ergebnis = dict(optionen)
ergebnis.update({
"veyon_schluesselname": name,
"veyon_public_pem": oeffentlich,
"veyon_raum": oe_name,
"veyon_lehrer": lehrer,
})
if lehrer:
cur.execute(
"""
SELECT id, hostname FROM devices
WHERE oe_id = %s AND archived_at IS NULL AND deprovisioned_at IS NULL
AND hostname IS NOT NULL AND hostname <> ''
ORDER BY lower(hostname)
""",
(oe_id,),
)
rechner = [
{"name": hostname, "host": f"{hostname}.local"}
for anderes_id, hostname in cur.fetchall()
if str(anderes_id) != str(device_id) and veyon_aktiv(cur, anderes_id, merkmal_id)
]
ergebnis["veyon_private_pem"] = privat
ergebnis["veyon_rechner"] = rechner
return ergebnis
except Exception as exc: # noqa: BLE001 - Check-in darf nicht scheitern
print(f"[veyon] Optionen fuer Geraet {device_id} nicht angereichert: {exc}")
return optionen
# Org-Ressourcen (15.10.2026, platform-docs roadmap/org-ressourcen-konzept.md,
# Migration 0062): lokale Dienste einer Organisation, die App-Rollen zum
# Vorbefüllen nutzen. Der Check-in schickt die für ein Gerät geltenden
# Ressourcen als eigenen Auftrag "org-ressourcen" mit (wie den
# Standardbrowser: kein Katalog-Merkmal, keine Ereigniszeile, der Agent
# reicht ihn ungesehen an Ansible weiter). App-Rollen lesen sie im selben
# Lauf aus tuxflotte_auftrag_optionen['org-ressourcen'].
ORG_RESSOURCEN_AUFTRAG = "org-ressourcen"
# Freischaltung: solange noch keine App-Rolle die Ressourcen nutzt, gelten
# Auftrag und Kunden-Maske nur für Organisationen mit Ausrollstufe "vorab"
# (Testorganisationen). Sonst bekäme jedes Kundengerät einen zusätzlichen
# Ansible-Lauf für eine Rolle, die sein stabiler Stand noch nicht kennt, und
# Kunden eine Maske ohne Wirkung. Seit 20.10.2026 offen (False): stabil
# c219e3b enthält Rolle, Lesezeichen und gemeinsame Ablage; Kundengeräte
# ohne Ressourcen bekommen den Auftrag als "absent" (legt nur Skript und
# Autostart für den Lesezeichen-Abgleich ab).
ORG_RESSOURCEN_NUR_VORAB = False
# Feldtypen: "text", "url", "port", "auswahl" (mit "werte"), "liste"
# (kommagetrennt). Pflichtfelder mit "pflicht". Felder für Zugangsdaten gibt
# es absichtlich nicht - die Ressourcen landen unverschlüsselt in
# /etc/tuxflotte/org-ressourcen.json und sind für jeden Benutzer lesbar.
ORG_RESSOURCEN_TYPEN = {
"mail": {
"name": "E-Mail (IMAP/SMTP)",
"felder": [
{"key": "mail_domain", "label": "Maildomäne (Teil nach dem @, z. B. schule.de)", "typ": "text", "pflicht": True},
{"key": "imap_server", "label": "IMAP-Server", "typ": "text", "pflicht": True},
{"key": "imap_port", "label": "IMAP-Port", "typ": "port", "standard": 993},
{"key": "imap_verschluesselung", "label": "IMAP-Verschlüsselung", "typ": "auswahl", "werte": ["ssl", "starttls"], "standard": "ssl"},
{"key": "smtp_server", "label": "SMTP-Server", "typ": "text", "pflicht": True},
{"key": "smtp_port", "label": "SMTP-Port", "typ": "port", "standard": 465},
{"key": "smtp_verschluesselung", "label": "SMTP-Verschlüsselung", "typ": "auswahl", "werte": ["ssl", "starttls"], "standard": "ssl"},
{"key": "benutzername", "label": "Anmeldename", "typ": "auswahl", "werte": ["mailadresse", "lokalteil"], "standard": "mailadresse"},
],
},
"nextcloud": {
"name": "Nextcloud",
"felder": [
{"key": "url", "label": "Adresse (z. B. https://cloud.schule.de)", "typ": "url", "pflicht": True},
],
},
"owncloud": {
"name": "ownCloud",
"felder": [
{"key": "url", "label": "Adresse (z. B. https://cloud.schule.de)", "typ": "url", "pflicht": True},
],
},
"webdav": {
"name": "WebDAV-Freigabe",
"felder": [
{"key": "url", "label": "Adresse (z. B. https://server/remote.php/dav/files/)", "typ": "url", "pflicht": True},
],
},
"smb": {
"name": "Windows-Freigabe (SMB/CIFS)",
"felder": [
{"key": "server", "label": "Server (Name oder IP)", "typ": "text", "pflicht": True},
{"key": "freigabe", "label": "Freigabename", "typ": "text", "pflicht": True},
{"key": "domaene", "label": "Domäne/Arbeitsgruppe (optional)", "typ": "text"},
],
},
"proxy": {
"name": "Web-Proxy",
"felder": [
{"key": "server", "label": "Proxy-Server (Name oder IP)", "typ": "text", "pflicht": True},
{"key": "port", "label": "Port", "typ": "port", "standard": 3128},
{"key": "ausnahmen", "label": "Ohne Proxy (kommagetrennt, z. B. localhost,.schule.local)", "typ": "liste"},
],
},
}
# Name/Host: Buchstaben, Ziffern, Punkt, Bindestrich, Unterstrich (IPv6 in
# eckigen Klammern). Freigabename zusätzlich Leerzeichen und $.
_RESSOURCE_HOST_RE = re.compile(r"^(\[[0-9a-fA-F:]+\]|[A-Za-z0-9._-]+)$")
_RESSOURCE_FREIGABE_RE = re.compile(r"^[A-Za-z0-9 ._$-]+$")
def pruefe_ressource(typ: str, daten: dict) -> tuple[dict | None, str | None]:
"""Prüft die Felder einer Ressource gegen ORG_RESSOURCEN_TYPEN. Liefert
(bereinigte Daten, None) oder (None, Fehlermeldung). Unbekannte Felder
werden verworfen, damit auch keine Zugangsdaten hineinrutschen."""
definition = ORG_RESSOURCEN_TYPEN.get(typ)
if definition is None:
return None, f"Unbekannter Ressourcentyp: {typ}"
ergebnis = {}
for feld in definition["felder"]:
roh = daten.get(feld["key"])
wert = roh.strip() if isinstance(roh, str) else roh
if wert in (None, ""):
if feld.get("pflicht"):
return None, f"Bitte „{feld['label']}“ ausfüllen."
if "standard" in feld:
ergebnis[feld["key"]] = feld["standard"]
continue
if feld["typ"] == "port":
try:
wert = int(wert)
except (TypeError, ValueError):
return None, f"„{feld['label']}“ muss eine Zahl sein."
if not 1 <= wert <= 65535:
return None, f"„{feld['label']}“ muss zwischen 1 und 65535 liegen."
elif feld["typ"] == "auswahl":
if wert not in feld["werte"]:
return None, f"Ungültiger Wert für „{feld['label']}“."
elif feld["typ"] == "url":
teile = urlparse(str(wert))
if teile.scheme not in ("http", "https") or not teile.hostname:
return None, f"„{feld['label']}“ muss mit http:// oder https:// beginnen."
if teile.username or teile.password:
return None, "Zugangsdaten gehören nicht in die Adresse."
wert = str(wert).rstrip("/")
elif feld["typ"] == "liste":
eintraege = [e.strip() for e in str(wert).split(",") if e.strip()]
if any(not _RESSOURCE_HOST_RE.match(e.lstrip("*")) for e in eintraege):
return None, f"„{feld['label']}“ enthält ungültige Einträge."
wert = eintraege
else:
wert = str(wert)
muster = _RESSOURCE_FREIGABE_RE if feld["key"] == "freigabe" else _RESSOURCE_HOST_RE
if feld["key"] in ("mail_domain", "imap_server", "smtp_server", "server", "freigabe", "domaene") and not muster.match(wert):
return None, f"„{feld['label']}“ enthält ungültige Zeichen."
ergebnis[feld["key"]] = wert
return ergebnis, None
def _ist_uuid(wert) -> bool:
try:
UUID(str(wert))
except ValueError:
return False
return True
def org_ressourcen_freigeschaltet(cur, organization_id) -> bool:
if not ORG_RESSOURCEN_NUR_VORAB:
return True
cur.execute("SELECT ausrollstufe FROM organizations WHERE id = %s", (organization_id,))
row = cur.fetchone()
return bool(row) and row[0] == "vorab"
def _ressourcen_ziele_pruefen(cur, organization_id, oe_ids, gruppe_ids) -> str | None:
if oe_ids:
cur.execute(
"SELECT count(*) FROM organisationseinheiten WHERE organization_id = %s AND id::text = ANY(%s)",
(organization_id, oe_ids),
)
if cur.fetchone()[0] != len(set(oe_ids)):
return "Unbekannte Organisationseinheit."
if gruppe_ids:
cur.execute(
"SELECT count(*) FROM gruppen WHERE organization_id = %s AND id::text = ANY(%s)",
(organization_id, gruppe_ids),
)
if cur.fetchone()[0] != len(set(gruppe_ids)):
return "Unbekannte Gruppe."
return None
def fetch_org_ressourcen(organization_id: str) -> dict:
if not _ist_uuid(organization_id):
return {"freigeschaltet": False, "ressourcen": []}
with get_database_connection() as conn:
with conn.cursor() as cur:
freigeschaltet = org_ressourcen_freigeschaltet(cur, organization_id)
cur.execute(
"""
SELECT id, typ, name, daten, ganze_organisation
FROM org_ressourcen WHERE organization_id = %s
ORDER BY typ, name
""",
(organization_id,),
)
zeilen = cur.fetchall()
cur.execute(
"""
SELECT z.ressource_id, z.oe_id, oe.name, z.gruppe_id, g.name
FROM org_ressourcen_ziele z
JOIN org_ressourcen r ON r.id = z.ressource_id
LEFT JOIN organisationseinheiten oe ON oe.id = z.oe_id
LEFT JOIN gruppen g ON g.id = z.gruppe_id
WHERE r.organization_id = %s
ORDER BY oe.name, g.name
""",
(organization_id,),
)
ziele = {}
for ressource_id, oe_id, oe_name, gruppe_id, gruppe_name in cur.fetchall():
eintrag = ziele.setdefault(ressource_id, {"oes": [], "gruppen": []})
if oe_id is not None:
eintrag["oes"].append({"id": str(oe_id), "name": oe_name})
else:
eintrag["gruppen"].append({"id": str(gruppe_id), "name": gruppe_name})
ressourcen = []
for ressource_id, typ, name, daten, ganze_organisation in zeilen:
z = ziele.get(ressource_id, {"oes": [], "gruppen": []})
ressourcen.append({
"id": str(ressource_id),
"typ": typ,
"typ_name": ORG_RESSOURCEN_TYPEN.get(typ, {}).get("name", typ),
"name": name,
"daten": daten,
"ganze_organisation": ganze_organisation,
"oes": z["oes"],
"gruppen": z["gruppen"],
})
return {"freigeschaltet": freigeschaltet, "ressourcen": ressourcen}
def speichere_org_ressource(
organization_id: str, ressource_id: str | None, typ: str, name: str, daten: dict,
ganze_organisation: bool, oe_ids: list[str], gruppe_ids: list[str],
) -> tuple[dict | None, str | None]:
"""Legt eine Ressource an (ressource_id None) oder ersetzt sie komplett
(Felder und Ziele). Liefert ({"id": …}, None) oder (None, Fehler)."""
if not all(_ist_uuid(x) for x in [organization_id, *([ressource_id] if ressource_id else []), *(oe_ids or []), *(gruppe_ids or [])]):
return None, "Ungültige Kennung."
name = (name or "").strip()
if not name:
return None, "Bitte einen Namen angeben."
bereinigt, fehler = pruefe_ressource(typ, daten or {})
if fehler:
return None, fehler
oe_ids = [] if ganze_organisation else list(dict.fromkeys(oe_ids or []))
gruppe_ids = [] if ganze_organisation else list(dict.fromkeys(gruppe_ids or []))
with get_database_connection() as conn:
with conn.cursor() as cur:
if fehler := _ressourcen_ziele_pruefen(cur, organization_id, oe_ids, gruppe_ids):
return None, fehler
try:
if ressource_id is None:
ressource_id = str(uuid4())
cur.execute(
"""
INSERT INTO org_ressourcen (id, organization_id, typ, name, daten, ganze_organisation)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(ressource_id, organization_id, typ, name, Jsonb(bereinigt), ganze_organisation),
)
else:
cur.execute(
"""
UPDATE org_ressourcen
SET typ = %s, name = %s, daten = %s, ganze_organisation = %s, updated_at = now()
WHERE id = %s AND organization_id = %s
""",
(typ, name, Jsonb(bereinigt), ganze_organisation, ressource_id, organization_id),
)
if cur.rowcount == 0:
return None, "Die Ressource wurde nicht gefunden."
cur.execute("DELETE FROM org_ressourcen_ziele WHERE ressource_id = %s", (ressource_id,))
except psycopg.errors.UniqueViolation:
conn.rollback()
return None, "Eine Ressource mit diesem Namen gibt es schon."
for oe_id in oe_ids:
cur.execute("INSERT INTO org_ressourcen_ziele (ressource_id, oe_id) VALUES (%s, %s)", (ressource_id, oe_id))
for gruppe_id in gruppe_ids:
cur.execute("INSERT INTO org_ressourcen_ziele (ressource_id, gruppe_id) VALUES (%s, %s)", (ressource_id, gruppe_id))
return {"id": ressource_id}, None
def loesche_org_ressource(organization_id: str, ressource_id: str) -> bool:
if not (_ist_uuid(organization_id) and _ist_uuid(ressource_id)):
return False
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM org_ressourcen WHERE id = %s AND organization_id = %s",
(ressource_id, organization_id),
)
return cur.rowcount > 0
def fetch_geraete_ressourcen(cur, device_id) -> list[dict] | None:
"""Ressourcen, die für ein Gerät gelten - None, wenn sie für seine
Organisation (noch) nicht freigeschaltet sind. Eine Ressource gilt, wenn
sie für die ganze Organisation wirkt, eine ihrer OEs die OE des Geräts
oder eine darüberliegende ist, oder das Gerät in einer ihrer Gruppen ist.
Sortiert vom Spezifischsten zum Allgemeinsten (Gruppe, OE nahe am Gerät,
OE weiter oben, ganze Organisation), innerhalb gleicher Stufe nach Name:
Rollen, die nur eine Ressource eines Typs brauchen (Web-Proxy), nehmen
die erste."""
cur.execute("SELECT organization_id, oe_id FROM devices WHERE id = %s", (device_id,))
row = cur.fetchone()
if row is None:
return None
organization_id, oe_id = row
if not org_ressourcen_freigeschaltet(cur, organization_id):
return None
# Abstand jeder OE der Kette zur Geräte-OE (0 = eigene OE), wie in
# resolve_katalog_overrides() in Python verfolgt.
abstand_by_oe = {}
if oe_id is not None:
cur.execute("SELECT id, parent_id FROM organisationseinheiten WHERE organization_id = %s", (organization_id,))
parent_by_id = {r[0]: r[1] for r in cur.fetchall()}
current, abstand = oe_id, 0
while current is not None and current not in abstand_by_oe:
abstand_by_oe[current] = abstand
current, abstand = parent_by_id.get(current), abstand + 1
cur.execute("SELECT gruppe_id FROM device_gruppen WHERE device_id = %s", (device_id,))
gruppen = {r[0] for r in cur.fetchall()}
cur.execute(
"""
SELECT r.id, r.typ, r.name, r.daten, r.ganze_organisation, z.oe_id, z.gruppe_id
FROM org_ressourcen r
LEFT JOIN org_ressourcen_ziele z ON z.ressource_id = r.id
WHERE r.organization_id = %s
""",
(organization_id,),
)
# Rang je Ressource: kleiner = spezifischer. Gruppe 0, OE 1 + Abstand,
# ganze Organisation 1000.
beste = {}
for ressource_id, typ, name, daten, ganze_organisation, z_oe, z_gruppe in cur.fetchall():
kandidaten = []
if ganze_organisation:
kandidaten.append((1000, "organisation"))
if z_gruppe is not None and z_gruppe in gruppen:
kandidaten.append((0, "gruppe"))
if z_oe is not None and z_oe in abstand_by_oe:
kandidaten.append((1 + abstand_by_oe[z_oe], "oe"))
for rang, quelle in kandidaten:
if ressource_id not in beste or rang < beste[ressource_id][0]:
beste[ressource_id] = (rang, quelle, typ, name, daten)
return [
{"id": str(rid), "typ": typ, "name": name, "quelle": quelle, **daten}
for rid, (rang, quelle, typ, name, daten) in sorted(beste.items(), key=lambda e: (e[1][0], e[1][3]))
]
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:
basis = geraete_basis(cur, device_id)
if basis is None:
return None
version_id, backend_id = basis
cur.execute(
"""
SELECT m.id, m.key, m.name, m.description,
k.key, k.name, k.sort_order,
(vm.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_version_merkmale vm
ON vm.merkmal_id = m.id AND vm.workspace_version_id = %s
WHERE m.im_auftragskatalog
ORDER BY k.sort_order NULLS LAST, m.name
""",
(backend_id, version_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). Ohne eigenen Präfix der
Organisation gilt STANDARD_HOSTNAME_PRAEFIX. None nur, wenn es die
Organisation nicht gibt.
"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:
return None
praefix, stellen, modus, naechste_sequenz = row
praefix = praefix or STANDARD_HOSTNAME_PRAEFIX
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.
Neues Gerät: nach der Hostnamenkonvention der Organisation (ohne
eigenen Präfix: STANDARD_HOSTNAME_PRAEFIX). Der vom Client gemeldete
Name ist nur noch Rückfall, wenn die Vergabe nichts liefert.
Bekanntes Gerät (Neuinstallation): Kunden-ISO-Test 02.10.2026 - bisher
galt hier immer der vom Client gemeldete Name. Der Client ist aber das
Live-System des Boot-Mediums und meldet dessen Hostnamen ("debian"),
nicht den des installierten Geräts - jede Neuinstallation benannte das
Gerät deshalb in "debian" um und die Konvention griff nie. Jetzt:
passt der gespeicherte Name zur Konvention, bleibt er; sonst wird nach
der Konvention neu vergeben. Seit 13.10.2026 auch ohne eigenen Präfix
(Standardpräfix): ein Gerät, das noch "debian" heißt, bekommt bei der
nächsten Neuinstallation seinen Namen.
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT d.hostname, o.hostname_praefix, o.hostname_stellen
FROM devices d
JOIN organizations o ON o.id = %s
WHERE d.device_fingerprint = %s
""",
(organization_id, device_fingerprint),
)
bestehend = cur.fetchone()
if bestehend is None:
generiert = generate_hostname_for_organization(cur, organization_id)
return generiert or reported_hostname
gespeichert, praefix, stellen = bestehend
praefix = praefix or STANDARD_HOSTNAME_PRAEFIX
if gespeichert and re.fullmatch(rf"{re.escape(praefix)}-\d{{{stellen}}}", gespeichert):
return gespeichert
generiert = generate_hostname_for_organization(cur, organization_id)
return generiert or gespeichert 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 oe.id, oe.parent_id, oe.name, oe.ist_standard,
oe.workspace_id, w.name, b.name, b.version, oe.automatisch_aktualisieren, v.nummer
FROM organisationseinheiten oe
LEFT JOIN workspaces w ON w.id = oe.workspace_id
LEFT JOIN backends b ON b.id = w.backend_id
LEFT JOIN workspace_versionen v ON v.id = oe.workspace_version_id
WHERE oe.organization_id = %s
ORDER BY oe.name
""",
(organization_id,),
)
zeilen = cur.fetchall()
oes = [
{
"id": str(oe_id),
"parent_id": str(parent_id) if parent_id is not None else None,
"name": name,
"ist_standard": ist_standard,
# Workspace-Creator neu, Schritt F: eigener Workspace der OE
# (oder None) und der wirksame, ggf. geerbte.
"workspace": (
{"id": str(ws_id), "name": ws_name, "distro": f"{b_name} {b_version}",
"automatisch": automatisch or nummer is None, "version": nummer}
if ws_id else None
),
}
for oe_id, parent_id, name, ist_standard, ws_id, ws_name, b_name, b_version, automatisch, nummer in zeilen
]
nach_id = {oe["id"]: oe for oe in oes}
for oe in oes:
aktuell, gesehen = oe, set()
while aktuell is not None and aktuell["workspace"] is None and aktuell["id"] not in gesehen:
gesehen.add(aktuell["id"])
aktuell = nach_id.get(aktuell["parent_id"])
oe["wirksamer_workspace"] = (
{**aktuell["workspace"], "geerbt_von": None if aktuell is oe else aktuell["name"]}
if aktuell is not None and aktuell["workspace"] else None
)
return oes
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)
konflikte = distro_konflikte(cur, str(organization_id), oe_parent={oe_id: parent_id})
if konflikte:
return {"konflikt": konflikt_meldung(konflikte)}
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, "Der Standort/Zweck wurde nicht gefunden."
if row[0]:
return False, "„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, "Darunter gibt es noch weitere Standorte/Zwecke – 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, "Hier 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(
"""
SELECT d.organization_id FROM devices d JOIN organisationseinheiten oe ON oe.id = %s
WHERE d.id = %s AND d.organization_id = oe.organization_id
""",
(oe_id, device_id),
)
row = cur.fetchone()
if row is None:
return False
konflikte = distro_konflikte(cur, str(row[0]), geraet_oe={device_id: oe_id})
if konflikte:
return konflikt_meldung(konflikte)
cur.execute("UPDATE devices SET oe_id = %s WHERE id = %s", (oe_id, device_id))
return True
# --- Workspace an der OE und Distro-Konfliktregel (Schritt F, 07.10.2026) ---
def distro_konflikte(cur, organization_id, oe_workspace=None, oe_parent=None, geraet_oe=None) -> list[dict]:
"""
Simuliert eine Änderung und meldet jedes aktive Gerät der Organisation,
das danach einen Workspace mit anderer Distro bekäme als seine eigene.
oe_workspace: {oe_id: workspace_id oder None}, oe_parent: {oe_id:
parent_id}, geraet_oe: {device_id: oe_id}. Grundlage der Regel
"blockieren mit klarer Meldung" (ADR-0038) - eine andere Distro hieße
Neuinstallation.
"""
oe_workspace = {str(k): v for k, v in (oe_workspace or {}).items()}
oe_parent = {str(k): v for k, v in (oe_parent or {}).items()}
geraet_oe = {str(k): v for k, v in (geraet_oe or {}).items()}
cur.execute(
"SELECT id::text, parent_id::text, workspace_id::text, name FROM organisationseinheiten WHERE organization_id = %s",
(organization_id,),
)
oes = {}
for oe_id, parent_id, workspace_id, name in cur.fetchall():
oes[oe_id] = {
"parent": oe_parent.get(oe_id, parent_id),
"workspace": oe_workspace.get(oe_id, workspace_id) if oe_id in oe_workspace else workspace_id,
"name": name,
}
cur.execute("SELECT w.id::text, w.name, w.backend_id, b.name FROM workspaces w LEFT JOIN backends b ON b.id = w.backend_id")
workspaces = {wid: (name, backend_id, bname) for wid, name, backend_id, bname in cur.fetchall()}
cur.execute(
"""
SELECT d.id::text, COALESCE(d.hostname, d.id::text), d.oe_id::text, bv.backend_id, b.name
FROM devices d
JOIN assignments a ON a.device_id = d.id
JOIN bereitstellungsvorlagen bv ON bv.id = a.bereitstellungsvorlage_id
JOIN backends b ON b.id = bv.backend_id
WHERE d.organization_id = %s AND d.archived_at IS NULL AND d.deprovisioned_at IS NULL
""",
(organization_id,),
)
konflikte = []
for device_id, hostname, oe_id, backend_id, distro in cur.fetchall():
aktuell, gesehen = geraet_oe.get(device_id, oe_id), set()
while aktuell is not None and aktuell in oes and aktuell not in gesehen:
gesehen.add(aktuell)
ws = oes[aktuell]["workspace"]
if ws is not None:
ws_name, ws_backend, ws_distro = workspaces.get(str(ws), ("?", None, None))
if ws_backend is not None and ws_backend != backend_id:
konflikte.append({
"geraet": hostname, "geraet_distro": distro,
"oe": oes[aktuell]["name"], "workspace": ws_name, "workspace_distro": ws_distro,
})
break
aktuell = oes[aktuell]["parent"]
return konflikte
def konflikt_meldung(konflikte: list[dict]) -> str:
beispiele = "; ".join(
f"{k['geraet']} ({k['geraet_distro']}) bekäme „{k['workspace']}“ ({k['workspace_distro']}) über „{k['oe']}“"
for k in konflikte[:5]
)
rest = f" und {len(konflikte) - 5} weitere" if len(konflikte) > 5 else ""
return (
f"Nicht möglich – {len(konflikte)} Gerät(e) hätten danach einen Workspace mit anderer Distro, "
f"das ginge nur per Neuinstallation: {beispiele}{rest}."
)
def set_oe_workspace(oe_id: str, workspace_id: str | None, version_id: str | None, automatisch: bool):
"""Workspace (oder keinen) an eine OE hängen. Rückgabe (True, None) oder
(None, (fehlercode, nachricht))."""
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, ("not_found", "Standort/Zweck wurde nicht gefunden.")
organization_id = str(row[0])
if workspace_id is not None:
sichtbar = {w["id"] for w in fetch_available_workspaces_for_organization(organization_id)}
if workspace_id not in sichtbar:
return None, ("workspace_nicht_verfuegbar", "Dieser Workspace steht der Organisation nicht zur Verfügung.")
cur.execute("SELECT backend_id FROM workspaces WHERE id = %s", (workspace_id,))
if cur.fetchone()[0] is None:
return None, ("ohne_distro", "Der Workspace hat noch keine Distro und kann nicht zugewiesen werden.")
if not automatisch:
if not version_id:
return None, ("version_fehlt", "Bitte eine Version wählen oder „automatisch aktualisieren“ setzen.")
cur.execute(
"SELECT 1 FROM workspace_versionen WHERE id = %s AND workspace_id = %s AND status = 'veroeffentlicht'",
(version_id, workspace_id),
)
if cur.fetchone() is None:
return None, ("version_ungueltig", "Die gewählte Version gehört nicht zu diesem Workspace.")
konflikte = distro_konflikte(cur, organization_id, oe_workspace={oe_id: workspace_id})
if konflikte:
return None, ("distro_konflikt", konflikt_meldung(konflikte))
cur.execute(
"""
UPDATE organisationseinheiten
SET workspace_id = %s, workspace_version_id = %s, automatisch_aktualisieren = %s
WHERE id = %s
""",
(
workspace_id,
None if (workspace_id is None or automatisch) else version_id,
True if workspace_id is None else automatisch,
oe_id,
),
)
return True, 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
FROM gruppen g
WHERE g.organization_id = %s
ORDER BY g.name
""",
(organization_id,),
)
return [{"id": str(gruppe_id), "name": name} for gruppe_id, 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_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. Seit 07.10.2026
tragen Gruppen keinen eigenen Workspace mehr (nur noch Ergänzungen),
die frühere Exklusivitätsregel entfällt damit.
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT d.organization_id, g.organization_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
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, d.agent_sha256, d.ansible_weg"""
def _health_felder_aus_row(debug_mode_until, disk, ram, uptime, load1, load5, load15, collected_at, agent_sha256, ansible_weg) -> 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,
"agent_sha256": agent_sha256,
"ansible_weg": ansible_weg,
"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}, be.name
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
LEFT JOIN backends be ON be.id = bv.backend_id
WHERE d.organization_id = %s AND d.archived_at IS NULL
ORDER BY d.hostname NULLS LAST, d.created_at
""",
(organization_id,),
)
zeilen = cur.fetchall()
# Wirksamer Workspace (Schritt F): über OE oder Installation,
# wie ihn der Agent anwendet - nicht mehr nur der der Installation.
wirksam = {}
for zeile in zeilen:
basis = geraete_basis(cur, str(zeile[0]))
if basis is not None and basis[0] is not None:
cur.execute(
"SELECT w.name, v.nummer FROM workspace_versionen v JOIN workspaces w ON w.id = v.workspace_id WHERE v.id = %s",
(basis[0],),
)
wirksam[zeile[0]] = cur.fetchone()
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,
"workspace_name": wirksam[device_id][0] if device_id in wirksam else workspace_name,
"workspace_version": wirksam[device_id][1] if device_id in wirksam else None,
"distro": rest[-1],
**_health_felder_aus_row(*rest[:-1]),
}
for (
device_id, hostname, fingerprint, last_checkin, deprovisioned_at, oe_id, oe_name,
workspace_name,
*rest,
) in zeilen
]
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"]),
}
DASHBOARD_DISK_ROT_PROZENT = 95
def _geraete_ampel(cur, organization_id: str | None = None) -> dict:
"""
Ampel je Gerät (GUI-Runde 22.10.2026): rot = jetzt handeln (letzter
gemeldeter Agent-Lauf mit Fehlern, Platte >= 95 %), gelb = beobachten
(Platte/RAM >= 85 %, länger als 7 Tage oder noch nie gemeldet - in den
Ferien normal, deshalb nicht rot), sonst grün. Gründe als kurze Texte,
rot vor gelb vor grün sortiert.
"""
cur.execute(
"""
SELECT d.id, d.hostname, o.name, d.agent_last_checkin,
d.health_disk_used_percent, d.health_ram_used_percent, lauf.fehler
FROM devices d
JOIN organizations o ON o.id = d.organization_id
LEFT JOIN LATERAL (
SELECT array_remove(array_agg(m.name ORDER BY m.name)
FILTER (WHERE e.event_type LIKE '%%failed'), NULL) AS fehler
FROM device_merkmal_events e
JOIN merkmale m ON m.id = e.merkmal_id
WHERE e.device_id = d.id
AND e.occurred_at = (SELECT max(occurred_at) FROM device_merkmal_events WHERE device_id = d.id)
) lauf ON true
WHERE d.archived_at IS NULL AND (%(org)s::uuid IS NULL OR d.organization_id = %(org)s::uuid)
""",
{"org": organization_id},
)
schwelle = datetime.now(timezone.utc) - timedelta(days=DASHBOARD_KEIN_CHECKIN_TAGE)
geraete = []
zaehler = {"kritisch": 0, "warnung": 0, "gut": 0}
for device_id, hostname, org_name, checkin, disk, ram, fehler in cur.fetchall():
rot, gelb = [], []
if fehler:
rot.append(f"letzter Lauf: {len(fehler)} Merkmal(e) fehlgeschlagen ({', '.join(fehler[:3])}{' …' if len(fehler) > 3 else ''})")
if disk is not None and disk >= DASHBOARD_DISK_ROT_PROZENT:
rot.append(f"Platte {round(disk)} % voll")
elif disk is not None and disk >= DASHBOARD_DISK_KRITISCH_PROZENT:
gelb.append(f"Platte {round(disk)} % voll")
if ram is not None and ram >= DASHBOARD_RAM_KRITISCH_PROZENT:
gelb.append(f"RAM {round(ram)} % belegt")
if checkin is None:
gelb.append("noch nie gemeldet")
elif checkin < schwelle:
gelb.append(f"seit {(datetime.now(timezone.utc) - checkin).days} Tagen nicht gemeldet")
stufe = "kritisch" if rot else ("warnung" if gelb else "gut")
zaehler[stufe] += 1
geraete.append({
"id": str(device_id),
"name": hostname or "unbenannt",
"organisation": org_name,
"stufe": stufe,
"gruende": rot + gelb,
"agent_last_checkin": checkin.isoformat() if checkin else None,
})
reihenfolge = {"kritisch": 0, "warnung": 1, "gut": 2}
geraete.sort(key=lambda g: (reihenfolge[g["stufe"]], g["organisation"].lower(), g["name"].lower()))
return {"zaehler": zaehler, "geraete": geraete}
DASHBOARD_VERTEILUNG_MAX = 5
VERTEILUNG_SONDERFAELLE = {"ohne Installation", "ohne Workspace", "unbekannt"}
def _verteilung(zaehler: dict, max_teile: int = DASHBOARD_VERTEILUNG_MAX) -> list[dict]:
"""Zähler -> [{name, anzahl}] absteigend; ab max_teile+1 Einträgen werden
die kleinsten zu "Weitere" zusammengefasst (Ringdiagramm: höchstens
sechs Teile, GUI-Runde 09.10.2026)."""
# Keine echten Kategorien (grau dargestellt) immer ans Ende, damit die
# Kategoriefarben für echte Einträge lückenlos in fester Reihenfolge bleiben.
sonder = {n: a for n, a in zaehler.items() if n in VERTEILUNG_SONDERFAELLE}
teile = sorted(((n, a) for n, a in zaehler.items() if n not in sonder), key=lambda e: (-e[1], e[0]))
if len(teile) + len(sonder) > max_teile + 1:
behalten = max(max_teile - len(sonder), 1)
rest = sum(anzahl for _, anzahl in teile[behalten:])
teile = teile[:behalten] + ([("Weitere", rest)] if rest else [])
teile += sorted(sonder.items())
return [{"name": name, "anzahl": anzahl} for name, anzahl in teile]
def _dashboard_verteilungen(cur, organization_id: str | None = None) -> dict:
"""
Verteilungen für die Dashboard-Ringdiagramme (GUI-Runde 09.10.2026,
Punkt 6): Gerätezustand, Distro, wirksamer Workspace, bei
organisationsübergreifender Sicht auch Organisation. Zustand ist
ausschließend (jedes Gerät genau einmal): nicht gemeldet hat Vorrang vor
knappen Ressourcen.
"""
cur.execute(
"""
SELECT d.id, o.name, 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 AND (%(org)s::uuid IS NULL OR d.organization_id = %(org)s::uuid)
""",
{"org": organization_id},
)
geraete = cur.fetchall()
cur.execute("SELECT id, name FROM backends")
backend_namen = {str(i): n for i, n in cur.fetchall()}
cur.execute("SELECT v.id, w.name FROM workspace_versionen v JOIN workspaces w ON w.id = v.workspace_id")
version_workspace = {str(i): n for i, n in cur.fetchall()}
schwelle = datetime.now(timezone.utc) - timedelta(days=DASHBOARD_KEIN_CHECKIN_TAGE)
zustand = {"in Ordnung": 0, "Ressourcen knapp": 0, f"länger als {DASHBOARD_KEIN_CHECKIN_TAGE} Tage nicht gemeldet": 0, "noch nie gemeldet": 0}
distros, workspaces, organisationen = {}, {}, {}
for device_id, org_name, checkin, disk, ram in geraete:
if checkin is None:
zustand["noch nie gemeldet"] += 1
elif checkin < schwelle:
zustand[f"länger als {DASHBOARD_KEIN_CHECKIN_TAGE} Tage nicht gemeldet"] += 1
elif (disk or 0) >= DASHBOARD_DISK_KRITISCH_PROZENT or (ram or 0) >= DASHBOARD_RAM_KRITISCH_PROZENT:
zustand["Ressourcen knapp"] += 1
else:
zustand["in Ordnung"] += 1
basis = geraete_basis(cur, str(device_id))
if basis is None:
distro, workspace = "ohne Installation", "ohne Installation"
else:
version_id, backend_id = basis
distro = backend_namen.get(str(backend_id), "unbekannt")
workspace = version_workspace.get(str(version_id), "ohne Workspace") if version_id else "ohne Workspace"
distros[distro] = distros.get(distro, 0) + 1
workspaces[workspace] = workspaces.get(workspace, 0) + 1
organisationen[org_name] = organisationen.get(org_name, 0) + 1
ergebnis = {
"geraete_gesamt": len(geraete),
# feste Reihenfolge (Statusfarben), leere Zustände bleiben drin -
# die Oberfläche blendet sie in der Legende aus
"zustand": [{"name": n, "anzahl": a} for n, a in zustand.items()],
"distros": _verteilung(distros),
"workspaces": _verteilung(workspaces),
}
if organization_id is None:
ergebnis["organisationen"] = _verteilung(organisationen)
return ergebnis
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()
verteilungen = _dashboard_verteilungen(cur, organization_id)
ampel = _geraete_ampel(cur, organization_id)
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()
# Einstieg vereinfachen, Schritt 4: "Erste Schritte" auf der
# Startseite (installiert, aber noch nicht online; nur "Neue Geräte").
cur.execute(
"SELECT COUNT(*) FROM devices WHERE organization_id = %s AND archived_at IS NULL", (organization_id,)
)
(anzahl_geraete,) = cur.fetchone()
cur.execute("SELECT COUNT(*) FROM organisationseinheiten WHERE organization_id = %s", (organization_id,))
(anzahl_standorte,) = 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,
"hat_geraet": anzahl_geraete > 0,
"nur_standard_standort": anzahl_standorte <= 1,
"hostname_konvention_aktiv": bool(hostname_praefix),
},
"flotten_gesundheit": _dashboard_flotten_gesundheit(geraete_rows),
"ampel": ampel,
"aktivitaeten": fetch_aktivitaeten(organization_id, DASHBOARD_LETZTE_AKTIONEN_LIMIT),
"verteilungen": verteilungen,
"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
],
}
AKTIVITAETEN_LIMIT = 50
INSTALLATIONSEREIGNIS_TEXTE = {
"activation_confirmed": "Aktivierung bestätigt",
"installation_started": "Installation gestartet",
"installation_completed": "Installation abgeschlossen",
"installation_failed": "Installation fehlgeschlagen",
}
def fetch_aktivitaeten(organization_id: str | None = None, limit: int = AKTIVITAETEN_LIMIT):
"""
Aktivitätenliste (GUI-Runde 09.10.2026, Punkt 4): Installationsereignisse
einzeln, Agent-Läufe zusammengefasst. Ein Lauf meldet je Merkmal ein
Ereignis (rund 56) mit demselben Zeitstempel - die Liste bliebe sonst
unlesbar. Ohne organization_id über alle Organisationen (Admin-Dashboard).
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
WITH laeufe AS (
SELECT e.device_id, e.occurred_at, count(*) AS anzahl,
array_remove(array_agg(m.name ORDER BY m.name)
FILTER (WHERE e.event_type LIKE '%%failed'), NULL) AS fehler
FROM device_merkmal_events e
JOIN merkmale m ON m.id = e.merkmal_id
JOIN devices d ON d.id = e.device_id
WHERE (%(org)s::uuid IS NULL OR d.organization_id = %(org)s::uuid)
GROUP BY e.device_id, e.occurred_at
ORDER BY e.occurred_at DESC
LIMIT %(limit)s
),
installationen AS (
SELECT ie.device_id, ie.occurred_at, ie.event_type
FROM device_installation_events ie
JOIN devices d ON d.id = ie.device_id
WHERE (%(org)s::uuid IS NULL OR d.organization_id = %(org)s::uuid)
ORDER BY ie.occurred_at DESC
LIMIT %(limit)s
)
SELECT x.occurred_at, x.art, x.event_type, x.anzahl, x.fehler,
d.id, d.hostname, o.name
FROM (
SELECT occurred_at, device_id, 'agent' AS art, NULL AS event_type, anzahl, fehler FROM laeufe
UNION ALL
SELECT occurred_at, device_id, 'installation', event_type, NULL, NULL FROM installationen
) x
JOIN devices d ON d.id = x.device_id
JOIN organizations o ON o.id = d.organization_id
ORDER BY x.occurred_at DESC
LIMIT %(limit)s
""",
{"org": organization_id, "limit": limit},
)
rows = cur.fetchall()
ergebnis = []
for occurred_at, art, event_type, anzahl, fehler, device_id, hostname, org_name in rows:
fehler = fehler or []
if art == "agent":
text = f"Agent-Lauf: {anzahl} Merkmale geprüft" + (
f", {len(fehler)} fehlgeschlagen ({', '.join(fehler[:5])}{' …' if len(fehler) > 5 else ''})"
if fehler else ", ohne Fehler"
)
problem = bool(fehler)
else:
text = INSTALLATIONSEREIGNIS_TEXTE.get(event_type, event_type)
problem = event_type == "installation_failed"
ergebnis.append({
"zeit": occurred_at.isoformat(),
"art": art,
"text": text,
"problem": problem,
"device_id": str(device_id),
"hostname": hostname or "unbenannt",
"organization_name": org_name,
})
return ergebnis
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()
verteilungen = _dashboard_verteilungen(cur, None)
ampel = _geraete_ampel(cur, None)
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
],
"ampel": ampel,
"plattform_ereignisse": fetch_plattform_ereignisse(),
"aktivitaeten": fetch_aktivitaeten(None, DASHBOARD_LETZTE_AKTIONEN_LIMIT),
"verteilungen": verteilungen,
"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.
Seit 23.10.2026 zugleich deprovisioniert (wie deprovision_device()):
Vorher checkte ein gelöschtes Gerät weiter ein und wurde unsichtbar
weiterverwaltet (Liebherr-47, gelöscht 03.10. 13:56, Check-in 16:24).
Eine Neuinstallation (/api/v1/activate) setzt beides wieder zurück.
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE devices
SET archived_at = CURRENT_TIMESTAMP,
deprovisioned_at = COALESCE(deprovisioned_at, CURRENT_TIMESTAMP),
agent_secret_hash = NULL, agent_secret_issued_at = NULL
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 o.id, o.name, o.created_at, o.kontakt_name, o.adresse, o.telefon,
o.kontakt_email, o.notizen, o.ausrollstufe, o.kundengruppe_id, k.name
FROM organizations o
LEFT JOIN kundengruppen k ON k.id = o.kundengruppe_id
ORDER BY o.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,
"ausrollstufe": ausrollstufe,
"kundengruppe_id": str(kundengruppe_id) if kundengruppe_id else None,
"kundengruppe_name": kundengruppe_name,
}
for (
org_id, name, created_at,
kontakt_name, adresse, telefon, kontakt_email, notizen, ausrollstufe,
kundengruppe_id, kundengruppe_name,
) in cur.fetchall()
]
def ausrollstufe_fuer_geraet(device_id: str) -> str:
"""
Spiegel-Stufe für das Gerät: "vorab" oder "stabil" (das freigegebene
Repo, für Organisationen mit Ausrollstufe "testing"). Unbekanntes Gerät:
stabil.
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT o.ausrollstufe FROM devices d
JOIN organizations o ON o.id = d.organization_id
WHERE d.id = %s
""",
(device_id,),
)
row = cur.fetchone()
return "vorab" if row and row[0] == "vorab" else "stabil"
def ansible_repo_fuer_geraet(device_id: str) -> str:
"""Adresse des Rollen-Repos nach der Ausrollstufe der Organisation."""
return ANSIBLE_CONTENT_REPO if ausrollstufe_fuer_geraet(device_id) == "vorab" else ANSIBLE_CONTENT_REPO_STABIL
# --- Spiegel für Geräte (ADR-0046) -------------------------------------------
# Zustand je Ausrollstufe: {"commit", "sha256", "erlaubt": [neueste zuerst]}.
# Liegt zusätzlich in stand.json, damit nach einem Neustart sofort
# ausgeliefert wird, auch wenn Gitea gerade nicht erreichbar ist.
_spiegel_lock = threading.Lock()
_spiegel_staende: dict = {}
def _spiegel_repos() -> dict:
return {"vorab": ANSIBLE_CONTENT_REPO, "stabil": ANSIBLE_CONTENT_REPO_STABIL, "agent": AGENT_REPO}
def _spiegel_git(*args, binaer: bool = False):
ergebnis = subprocess.run(
["git", *args],
capture_output=True,
timeout=SPIEGEL_GIT_TIMEOUT_SECONDS,
env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
check=True,
)
return ergebnis.stdout if binaer else ergebnis.stdout.decode().strip()
def spiegel_stand_laden() -> None:
try:
daten = json.loads((SPIEGEL_DIR / "stand.json").read_text())
except (OSError, ValueError):
return
with _spiegel_lock:
_spiegel_staende.update({k: v for k, v in daten.items() if k in AUSROLLSTUFEN})
geladen = ", ".join(f"{k} {v['commit'][:7]}" for k, v in sorted(_spiegel_staende.items()))
print(f"[spiegel] Stand geladen: {geladen or 'leer'}", flush=True)
def _spiegel_holen(name: str, url: str) -> Path:
"""Bare-Spiegel anlegen oder aktualisieren, liefert seinen Pfad."""
pfad = SPIEGEL_DIR / f"{name}.git"
if (pfad / "HEAD").exists():
if _spiegel_git("-C", str(pfad), "remote", "get-url", "origin") != url:
_spiegel_git("-C", str(pfad), "remote", "set-url", "origin", url)
_spiegel_git("-C", str(pfad), "remote", "update", "--prune")
else:
neu = SPIEGEL_DIR / f".{name}.git.neu"
if neu.exists():
shutil.rmtree(neu)
_spiegel_git("clone", "--quiet", "--mirror", url, str(neu))
neu.rename(pfad)
return pfad
def _spiegel_archiv(stufe: str, pfad: Path) -> dict:
"""Archiv des aktuellen Stands einer Stufe (einmal je Commit) und die
Liste der auslieferbaren Stände; ältere Archive werden gelöscht."""
commit = _spiegel_git("-C", str(pfad), "rev-parse", "HEAD^{commit}")
archiv_dir = SPIEGEL_DIR / "archive" / stufe
archiv_dir.mkdir(parents=True, exist_ok=True)
archiv = archiv_dir / f"{commit}.tar.gz"
if not archiv.exists():
neu = archiv_dir / f".{commit}.tar.gz.neu"
_spiegel_git("-C", str(pfad), "archive", "--format=tar.gz", "-o", str(neu), commit)
neu.rename(archiv)
sha256 = hashlib.sha256(archiv.read_bytes()).hexdigest()
with _spiegel_lock:
bisher = _spiegel_staende.get(stufe, {}).get("erlaubt", [])
erlaubt = [commit] + [c for c in bisher if c != commit][: SPIEGEL_STAENDE_JE_STUFE - 1]
for datei in archiv_dir.glob("*.tar.gz"):
if datei.name.removesuffix(".tar.gz") not in erlaubt:
datei.unlink()
return {"commit": commit, "sha256": sha256, "erlaubt": erlaubt}
def plattform_ereignis_eintragen(art: str, text: str) -> None:
"""Ein Eintrag für "Änderungen an der Plattform" (Admin-Dashboard). Ein
Fehler hier darf den Aufrufer (Spiegel-Thread) nie aufhalten."""
try:
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute("INSERT INTO plattform_ereignisse (art, text) VALUES (%s, %s)", (art, text[:500]))
except Exception as exc: # noqa: BLE001 - nur protokollieren
print(f"[plattform] Ereignis nicht gespeichert ({art}): {exc}", flush=True)
def fetch_plattform_ereignisse(limit: int = 30) -> list[dict]:
try:
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute("SELECT zeit, art, text FROM plattform_ereignisse ORDER BY zeit DESC LIMIT %s", (limit,))
return [{"zeit": zeit.isoformat(), "art": art, "text": text} for zeit, art, text in cur.fetchall()]
except psycopg.errors.UndefinedTable:
# Migration 0071 noch nicht eingespielt - Dashboard trotzdem zeigen.
return []
def spiegel_aktualisieren() -> None:
"""Ein Durchlauf: alle Spiegel holen, Archive erzeugen, Stand sichern.
Ein Fehler bei einem Repo lässt die anderen und den letzten guten Stand
dieser Stufe unberührt."""
SPIEGEL_DIR.mkdir(parents=True, exist_ok=True)
for name, url in _spiegel_repos().items():
if not url:
continue
try:
pfad = _spiegel_holen(name, url)
if name in AUSROLLSTUFEN:
stand = _spiegel_archiv(name, pfad)
with _spiegel_lock:
vorher = _spiegel_staende.get(name, {}).get("commit")
_spiegel_staende[name] = stand
if vorher != stand["commit"]:
print(f"[spiegel] {name}: {str(vorher)[:7]} -> {stand['commit'][:7]}", flush=True)
betreff = _spiegel_git("-C", str(pfad), "log", "-1", "--format=%s", stand["commit"])
plattform_ereignis_eintragen(
f"ausrollen-{name}",
f"{name}: {str(vorher)[:7] if vorher else '–'} -> {stand['commit'][:7]} {betreff}",
)
except (subprocess.SubprocessError, OSError) as exc:
fehler = exc.stderr.decode(errors="replace").strip() if getattr(exc, "stderr", None) else exc
print(f"[spiegel] {name}: Fehler, letzter Stand bleibt: {fehler}", flush=True)
with _spiegel_lock:
daten = json.dumps(_spiegel_staende, indent=2)
neu = SPIEGEL_DIR / ".stand.json.neu"
neu.write_text(daten)
neu.rename(SPIEGEL_DIR / "stand.json")
def run_spiegel_schleife() -> None:
"""Daemon-Thread wie run_iso_build_expiry_sweep(): ein Fehler darf die
Schleife nicht beenden."""
while True:
try:
spiegel_aktualisieren()
except Exception as exc:
print(f"[spiegel] Durchlauf-Fehler (nächster Versuch in {SPIEGEL_INTERVALL_SECONDS}s): {exc}", flush=True)
time.sleep(SPIEGEL_INTERVALL_SECONDS)
def ansible_stand_fuer_geraet(device_id: str) -> dict | None:
"""Stand für den Check-in: Commit und Prüfsumme des Archivs der
Ausrollstufe. None, solange der Spiegel noch nichts hat - dann bleibt
dem Agent der Git-Weg über "ansible_repo"."""
stufe = ausrollstufe_fuer_geraet(device_id)
with _spiegel_lock:
stand = _spiegel_staende.get(stufe)
if not stand:
return None
return {"commit": stand["commit"], "sha256": stand["sha256"]}
def _geraet_aus_kopfzeilen(authorization: str | None, geraet: str | None) -> str:
"""Gerät für die GET-Endpunkte des Spiegels: Secret im Authorization-
Header, Geräte-ID in X-Tuxflotte-Geraet (nie in der URL)."""
if not authorization or not authorization.startswith("Bearer ") or not geraet or not _ist_uuid(geraet):
raise HTTPException(status_code=401, detail="Nicht angemeldet.")
if not verify_agent_secret(geraet, authorization.removeprefix("Bearer ")):
raise HTTPException(status_code=401, detail="Nicht angemeldet.")
return geraet
def set_ausrollstufe(organization_id: str, ausrollstufe: str) -> bool:
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE organizations SET ausrollstufe = %s, updated_at = now() WHERE id = %s RETURNING id",
(ausrollstufe, organization_id),
)
return cur.fetchone() is not None
def set_kundengruppe(organization_id: str, kundengruppe_id: str | None) -> bool:
"""Kundengruppe steuert, welche Vorlagen-Workspaces die Organisation
sieht (Sichtbarkeit "kundengruppe"). NULL = nur globale Vorlagen."""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE organizations SET kundengruppe_id = %s, updated_at = now() WHERE id = %s RETURNING id",
(kundengruppe_id, organization_id),
)
return cur.fetchone() is not None
def kundengruppe_gueltig(kundengruppe_id: str | None) -> bool:
return kundengruppe_id is None or any(k["id"] == kundengruppe_id for k in fetch_kundengruppen())
def fetch_organization(organization_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT o.id, o.name, o.created_at, o.kontakt_name, o.adresse, o.telefon,
o.kontakt_email, o.notizen, o.ausrollstufe, o.kundengruppe_id, k.name,
o.email_domaenen
FROM organizations o
LEFT JOIN kundengruppen k ON k.id = o.kundengruppe_id
WHERE o.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],
"ausrollstufe": row[8],
"kundengruppe_id": str(row[9]) if row[9] else None,
"kundengruppe_name": row[10],
"email_domaenen": list(row[11] or []),
}
def create_organization(name: str, kundengruppe_id: str | None = None):
organization_id = uuid4()
standard_oe_id = uuid4()
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO organizations (id, name, kundengruppe_id) VALUES (%s, %s, %s)",
(organization_id, name, kundengruppe_id),
)
# 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)
""",
(standard_oe_id, organization_id, STANDARD_OE_NAME),
)
standard = standard_ausstattung_anlegen(cur, organization_id, standard_oe_id, kundengruppe_id)
return {"id": str(organization_id), "name": name, "standard_workspace": standard}
# WLAN der Organisation (Einstieg vereinfachen, Schritt 2, Migration 0074).
# Gespeichert in der Form der Merkmal-Optionen der Rolle org-wlan; ein am
# Merkmal selbst (OE/Gruppe) gesetzter Wert hat Vorrang.
ORG_WLAN_MERKMAL = "org-wlan"
def org_wlan_pruefen(ssid: str, passwort: str, offen: bool) -> str | None:
if not ssid:
return None
if len(ssid.encode("utf-8")) > 32 or "\n" in ssid or "\r" in ssid:
return "Der WLAN-Name (SSID) darf höchstens 32 Bytes lang sein."
if offen:
return None
if not passwort:
return "Bitte das WLAN-Passwort eingeben oder „Offenes WLAN“ wählen."
if "\n" in passwort or "\r" in passwort or not (8 <= len(passwort) <= 63 or re.fullmatch(r"[0-9A-Fa-f]{64}", passwort)):
return "Das WLAN-Passwort muss 8 bis 63 Zeichen lang sein."
return None
def fetch_org_wlan(organization_id: str, mit_passwort: bool = False) -> dict | None:
"""{"ssid", "offen", "hat_passwort"[, "passwort"]} oder None ohne WLAN."""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute("SELECT org_wlan FROM organizations WHERE id = %s", (organization_id,))
row = cur.fetchone()
if not row or not row[0] or not row[0].get("wlan_ssid"):
return None
gespeichert = row[0]
ergebnis = {
"ssid": gespeichert["wlan_ssid"],
"offen": not gespeichert.get("wlan_passwort"),
"hat_passwort": bool(gespeichert.get("wlan_passwort")),
}
if mit_passwort:
ergebnis["passwort"] = decrypt_sensitive_optionen(gespeichert).get("wlan_passwort") or ""
return ergebnis
def set_org_wlan(organization_id: str, ssid: str, passwort: str, offen: bool) -> str | None:
"""Speichert das WLAN der Organisation; leere SSID entfernt es. Ein leeres
Passwort bei gleicher SSID behält das gespeicherte. Fehlermeldung oder None."""
ssid = (ssid or "").strip()
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute("SELECT org_wlan FROM organizations WHERE id = %s FOR UPDATE", (organization_id,))
row = cur.fetchone()
if row is None:
return "Die Organisation wurde nicht gefunden."
bisher = row[0] or {}
if ssid and not offen and not passwort and bisher.get("wlan_ssid") == ssid and bisher.get("wlan_passwort"):
neu = bisher
else:
fehler = org_wlan_pruefen(ssid, passwort, offen)
if fehler:
return fehler
neu = encrypt_sensitive_optionen({"wlan_ssid": ssid, "wlan_passwort": "" if offen else passwort}) if ssid else None
cur.execute(
"UPDATE organizations SET org_wlan = %s, updated_at = now() WHERE id = %s",
(Jsonb(neu) if neu else None, organization_id),
)
return None
def org_wlan_fuer_geraet(device_id: str) -> dict | None:
"""Optionen für die Rolle org-wlan aus dem WLAN der Organisation des Geräts."""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT o.org_wlan FROM devices d JOIN organizations o ON o.id = d.organization_id WHERE d.id = %s",
(device_id,),
)
row = cur.fetchone()
if not row or not row[0] or not row[0].get("wlan_ssid"):
return None
return dict(row[0])
def standard_ausstattung_anlegen(cur, organization_id, standard_oe_id, kundengruppe_id) -> str | None:
"""
Einstieg vereinfachen, Schritt 1 (25.10.2026): Hat die Art der
Organisation (Kundengruppe) einen Standard-Workspace, bekommt die neue
Organisation sofort eine Bereitstellungsvorlage dafür (Distro des
Workspace, eine Partition, ext4) und den Workspace an "Neue Geräte".
Der Kunde kann damit gleich ein Medium bauen. Gibt den Namen des
Workspace zurück oder None.
"""
if not kundengruppe_id:
return None
cur.execute(
"""
SELECT w.id, w.name, w.backend_id
FROM kundengruppen k JOIN workspaces w ON w.id = k.standard_workspace_id
WHERE k.id = %s AND w.backend_id IS NOT NULL
""",
(kundengruppe_id,),
)
row = cur.fetchone()
if row is None:
return None
workspace_id, workspace_name, backend_id = row
partitioning = {"scheme": "single", "root_filesystem": "ext4", "target_disk_rank": 1, "extra_partitions": []}
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, TRUE, FALSE, %s, FALSE)
""",
(uuid4(), organization_id, workspace_id, backend_id, workspace_name, Jsonb(partitioning)),
)
cur.execute(
"UPDATE organisationseinheiten SET workspace_id = %s, automatisch_aktualisieren = TRUE WHERE id = %s",
(workspace_id, standard_oe_id),
)
return workspace_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, bezeichnung, gueltig_bis,
verwendet_anzahl, zuletzt_verwendet,
(gueltig_bis IS NOT NULL AND gueltig_bis <= now()) AS abgelaufen
FROM activation_codes
WHERE organization_id = %s
ORDER BY created_at DESC
""",
(organization_id,),
)
return [
{
"code": code, "active": active, "created_at": created_at.isoformat(),
"bezeichnung": bezeichnung,
"gueltig_bis": gueltig_bis.isoformat() if gueltig_bis else None,
"verwendet_anzahl": verwendet_anzahl,
"zuletzt_verwendet": zuletzt_verwendet.isoformat() if zuletzt_verwendet else None,
"abgelaufen": abgelaufen,
}
for code, active, created_at, bezeichnung, gueltig_bis, verwendet_anzahl,
zuletzt_verwendet, abgelaufen in cur.fetchall()
]
def create_activation_code(organization_id: str, bezeichnung: str | None = None, gueltig_tage: int | None = None):
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, bezeichnung, gueltig_bis)
VALUES (%s, %s, %s, CASE WHEN %s::int IS NULL THEN NULL ELSE now() + make_interval(days => %s::int) END)
""",
(code, organization_id, bezeichnung or None, gueltig_tage, gueltig_tage),
)
return {"code": code, "active": True}
def set_activation_code_active(organization_id: str, code: str, active: bool) -> bool:
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE activation_codes SET active = %s WHERE code = %s AND organization_id = %s",
(active, code, organization_id),
)
return cur.rowcount == 1
def delete_activation_code(organization_id: str, code: str) -> bool:
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM activation_codes WHERE code = %s AND organization_id = %s",
(code, organization_id),
)
return cur.rowcount == 1
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, m.icon_filename
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,
# Phase 6 (11.09.2026) - relativer Pfad wie bei den
# Webapp-Icons, kein voller URL (siehe icon_path dort).
"icon_path": f"/merkmal-icons/{icon_filename}" if icon_filename else None,
}
for (
mid, key, name, description, im_auftragskatalog,
kategorie_id, kategorie_name, icon_filename,
) 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, art FROM kategorien ORDER BY sort_order, name"
)
return [
{
"id": str(kid),
"key": key,
"name": name,
"description": description,
"sort_order": sort_order,
"art": art,
}
for kid, key, name, description, sort_order, art in cur.fetchall()
]
def create_kategorie(key: str, name: str, description: str | None, sort_order: int, art: str = "app"):
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, art)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(kategorie_id, key, name, description, sort_order, art),
)
return {"id": str(kategorie_id), "key": key, "name": name, "art": art}
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,
w.backend_id, b.name, b.version, w.ist_vorlage, w.sichtbarkeit,
(SELECT max(nummer) FROM workspace_versionen v
WHERE v.workspace_id = w.id AND v.status = 'veroeffentlicht'),
EXISTS (SELECT 1 FROM workspace_versionen v
WHERE v.workspace_id = w.id AND v.status = 'entwurf'),
ARRAY(SELECT k.name FROM workspace_freigabe_kundengruppen f
JOIN kundengruppen k ON k.id = f.kundengruppe_id
WHERE f.workspace_id = w.id ORDER BY k.sort_order),
ARRAY(SELECT o2.name FROM workspace_freigabe_organisationen f
JOIN organizations o2 ON o2.id = f.organization_id
WHERE f.workspace_id = w.id ORDER BY o2.name)
FROM workspaces w
LEFT JOIN organizations o ON o.id = w.organization_id
LEFT JOIN backends b ON b.id = w.backend_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,
"backend": (
{"id": str(backend_id), "name": backend_name, "version": backend_version}
if backend_id else None
),
"ist_vorlage": ist_vorlage,
"sichtbarkeit": sichtbarkeit,
"version": version,
"hat_entwurf": hat_entwurf,
# Für Anzeigen wie "Vorlage für Schulen" (07.10.2026)
"freigabe_kundengruppen": list(kundengruppen_namen),
"freigabe_organisationen": list(organisationen_namen),
}
for (
wid, key, name, description, organization_id, organization_name,
backend_id, backend_name, backend_version, ist_vorlage, sichtbarkeit,
version, hat_entwurf, kundengruppen_namen, organisationen_namen,
) in cur.fetchall()
]
def fetch_available_workspaces_for_organization(organization_id: str):
"""
Workspaces, die eine Organisation sehen und verwenden darf: die eigenen
plus die für sie freigegebenen Vorlagen - global, für ihre Kundengruppe
oder ausdrücklich für sie (Workspace-Creator neu, 07.10.2026). Bewusst
NICHT fetch_workspaces() - die liefert Workspaces aller Organisationen
(nur für die Admin-Ansicht), das würde im Kundenportal fremde, private
Workspace-Namen preisgeben.
"""
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
FROM workspaces w
JOIN organizations o ON o.id = %s
WHERE w.organization_id = o.id
OR (w.ist_vorlage AND (
w.sichtbarkeit = 'global'
OR (w.sichtbarkeit = 'kundengruppe' AND EXISTS (
SELECT 1 FROM workspace_freigabe_kundengruppen f
WHERE f.workspace_id = w.id AND f.kundengruppe_id = o.kundengruppe_id))
OR (w.sichtbarkeit = 'organisation' AND EXISTS (
SELECT 1 FROM workspace_freigabe_organisationen f
WHERE f.workspace_id = w.id AND f.organization_id = o.id))))
ORDER BY (w.organization_id IS NULL) DESC, w.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,
backend_id: str | None = None,
):
"""Legt einen Workspace samt Version 1 an. Bis Schritt E (Entwurf/
Veröffentlichen) ist Version 1 sofort veröffentlicht und wird direkt
bearbeitet (siehe arbeitsversion_id())."""
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, backend_id, sichtbarkeit, ist_vorlage)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""",
(
workspace_id, organization_id, key, name, description, backend_id,
"global" if organization_id is None else "organisation",
organization_id is None,
),
)
cur.execute(
"""
INSERT INTO workspace_versionen (id, workspace_id, nummer, status, veroeffentlicht_am)
VALUES (%s, %s, 1, 'veroeffentlicht', now())
""",
(uuid4(), workspace_id),
)
return {"id": str(workspace_id), "key": key, "name": name}
def workspace_key_aus_name(cur, name: str, organization_id: str | None) -> str:
"""Key (eindeutig je Eigentümer) aus dem Anzeigenamen, Umlaute auf ihr
ASCII-Grundzeichen reduziert; bei Kollision mit -2, -3 … ergänzt."""
ascii_only = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode("ascii")
basis = re.sub(r"[^a-z0-9]+", "-", ascii_only.lower()).strip("-")[:48] or "workspace"
cur.execute(
"SELECT key FROM workspaces WHERE organization_id IS NOT DISTINCT FROM %s",
(organization_id,),
)
vergeben = {row[0] for row in cur.fetchall()}
key, zaehler = basis, 2
while key in vergeben:
key = f"{basis}-{zaehler}"
zaehler += 1
return key
def create_workspace_mit_inhalt(
name: str, description: str | None, organization_id: str | None, backend_id: str,
merkmal_ids: list[str], webapp_template_ids: list[str],
):
"""
Legt einen Workspace aus dem Assistenten in einem Rutsch an (eine
Transaktion): Workspace mit Distro, veröffentlichte Version 1, Merkmale,
Webapps aus Vorlagen. Lehnt ab, wenn ein Merkmal auf der gewählten Distro
nicht umgesetzt ist - ein Workspace soll nichts enthalten, was auf seinen
Geräten still fehlt (ADR-0038).
Rückgabe: (workspace_dict, None) oder (None, (fehlercode, nachricht)).
"""
name = name.strip()
if not name:
return None, ("invalid_request", "Bitte einen Namen angeben.")
merkmal_ids = list(dict.fromkeys(merkmal_ids))
webapp_template_ids = list(dict.fromkeys(webapp_template_ids))
workspace_id, version_id = uuid4(), uuid4()
icon_kopien = []
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute("SELECT name FROM backends WHERE id = %s", (backend_id,))
if cur.fetchone() is None:
return None, ("backend_not_found", "Die gewählte Distro gibt es nicht.")
if organization_id is not None:
cur.execute("SELECT 1 FROM organizations WHERE id = %s", (organization_id,))
if cur.fetchone() is None:
return None, ("organization_not_found", "Die gewählte Organisation gibt es nicht.")
if merkmal_ids:
cur.execute(
"""
SELECT m.id::text, m.name, bp.id IS NOT NULL
FROM merkmale m
LEFT JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s
WHERE m.id::text = ANY(%s)
""",
(backend_id, merkmal_ids),
)
gefunden = {mid: (mname, umgesetzt) for mid, mname, umgesetzt in cur.fetchall()}
if len(gefunden) != len(merkmal_ids):
return None, ("merkmal_not_found", "Mindestens ein gewähltes Merkmal gibt es nicht.")
fehlend = sorted(mname for mname, umgesetzt in gefunden.values() if not umgesetzt)
if fehlend:
return None, (
"distro_konflikt",
"Auf der gewählten Distro nicht umgesetzt: " + ", ".join(fehlend),
)
vorlagen = []
if webapp_template_ids:
cur.execute(
"""
SELECT id, name, url, icon_filename, icon_content_type, kategorie_id
FROM webapp_templates WHERE id::text = ANY(%s)
""",
(webapp_template_ids,),
)
vorlagen = cur.fetchall()
if len(vorlagen) != len(webapp_template_ids):
return None, ("template_not_found", "Mindestens eine gewählte Webapp-Vorlage gibt es nicht.")
key = workspace_key_aus_name(cur, name, organization_id)
cur.execute(
"""
INSERT INTO workspaces (id, organization_id, key, name, description, backend_id, sichtbarkeit, ist_vorlage)
VALUES (%s, %s, %s, %s, %s, %s, %s, FALSE)
""",
(
workspace_id, organization_id, key, name, description, backend_id,
"global" if organization_id is None else "organisation",
),
)
cur.execute(
"""
INSERT INTO workspace_versionen (id, workspace_id, nummer, status, notiz, veroeffentlicht_am)
VALUES (%s, %s, 1, 'veroeffentlicht', 'Mit dem Assistenten erstellt', now())
""",
(version_id, workspace_id),
)
for merkmal_id in merkmal_ids:
cur.execute(
"INSERT INTO workspace_version_merkmale (workspace_version_id, merkmal_id) VALUES (%s, %s)",
(version_id, merkmal_id),
)
for sort_order, (template_id, tname, url, icon_filename, icon_content_type, kategorie_id) in enumerate(vorlagen):
webapp_id = uuid4()
kopie = f"{webapp_id}{Path(icon_filename).suffix}" if icon_filename else None
if kopie:
icon_kopien.append((icon_filename, kopie))
cur.execute(
"""
INSERT INTO workspace_webapps
(id, workspace_id, workspace_version_id, name, url, icon_filename, icon_content_type,
sort_order, template_id, kategorie_id)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
webapp_id, workspace_id, version_id, tname, url, kopie, icon_content_type,
sort_order, template_id, kategorie_id,
),
)
# Eigene Icon-Kopie je Webapp wie bei activate_webapp_template(), damit
# Änderungen an der Vorlage die Kopie nicht mitreißen.
for quelle, ziel in icon_kopien:
quelle_pfad = WEBAPP_ICONS_DIR / quelle
if quelle_pfad.is_file():
WEBAPP_ICONS_DIR.mkdir(parents=True, exist_ok=True)
(WEBAPP_ICONS_DIR / ziel).write_bytes(quelle_pfad.read_bytes())
return {"id": str(workspace_id), "key": key, "name": name}, None
# --- Workspace-Verwaltung (Workspace-Creator neu, Schritt E, 07.10.2026) ---
#
# Inhalte (Merkmale, Webapps) ändern sich nur noch in einem Entwurf. Die
# veröffentlichte Version ist eingefroren; Veröffentlichen macht den Entwurf
# zur neuen Version, auf die alle Geräte mit "automatisch aktualisieren"
# (bzw. ohne OE-Workspace) umsteigen. Gepinnte OEs bleiben auf ihrer Version.
def entwurf_id(cur, workspace_id):
cur.execute(
"SELECT id FROM workspace_versionen WHERE workspace_id = %s AND status = 'entwurf'",
(workspace_id,),
)
row = cur.fetchone()
return row[0] if row else None
def _version_kopieren(cur, quelle_version_id, ziel_version_id, ziel_workspace_id, merkmal_filter=None):
"""Merkmale und Webapps einer Version in eine andere kopieren (Webapps
mit neuer ID, Icon-Datei geteilt). merkmal_filter: optionale Menge
erlaubter Merkmal-IDs."""
cur.execute(
"SELECT merkmal_id FROM workspace_version_merkmale WHERE workspace_version_id = %s",
(quelle_version_id,),
)
for (merkmal_id,) in cur.fetchall():
if merkmal_filter is None or merkmal_id in merkmal_filter:
cur.execute(
"INSERT INTO workspace_version_merkmale (workspace_version_id, merkmal_id) VALUES (%s, %s)",
(ziel_version_id, merkmal_id),
)
# Standardbrowser mitnehmen, sofern der Browser im Ziel noch enthalten ist
# (merkmal_filter kann ihn beim Kopieren auf eine andere Distro entfernen).
cur.execute("SELECT standardbrowser FROM workspace_versionen WHERE id = %s", (quelle_version_id,))
quelle_row = cur.fetchone()
if quelle_row and quelle_row[0] in {b["key"] for b in standardbrowser_auswahl(cur, ziel_version_id)}:
cur.execute(
"UPDATE workspace_versionen SET standardbrowser = %s WHERE id = %s",
(quelle_row[0], ziel_version_id),
)
cur.execute(
"""
INSERT INTO workspace_webapps (id, workspace_id, workspace_version_id, name, url, icon_filename,
icon_content_type, sort_order, template_id, kategorie_id, kiosk_modus,
auf_desktop)
SELECT gen_random_uuid(), %s, %s, name, url, icon_filename, icon_content_type, sort_order,
template_id, kategorie_id, kiosk_modus, auf_desktop
FROM workspace_webapps WHERE workspace_version_id = %s
""",
(ziel_workspace_id, ziel_version_id, quelle_version_id),
)
def workspace_entwurf_anlegen(workspace_id: str):
"""Neuer Entwurf als Kopie der neuesten veröffentlichten Version; gibt es
schon einen, wird er zurückgegeben. None, wenn der Workspace fehlt."""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute("SELECT 1 FROM workspaces WHERE id = %s FOR UPDATE", (workspace_id,))
if cur.fetchone() is None:
return None
vorhanden = entwurf_id(cur, workspace_id)
if vorhanden:
return str(vorhanden)
cur.execute("SELECT COALESCE(max(nummer), 0) + 1 FROM workspace_versionen WHERE workspace_id = %s", (workspace_id,))
(nummer,) = cur.fetchone()
neu = uuid4()
cur.execute(
"INSERT INTO workspace_versionen (id, workspace_id, nummer, status) VALUES (%s, %s, %s, 'entwurf')",
(neu, workspace_id, nummer),
)
quelle = veroeffentlichte_version_id(cur, workspace_id)
if quelle:
_version_kopieren(cur, quelle, neu, workspace_id)
return str(neu)
def _icons_aufraeumen(cur, icon_dateien):
"""Webapp-Icons löschen, die keine Zeile mehr verwendet (nach dem Löschen
von Webapp-Zeilen)."""
for datei in {d for d in icon_dateien if d}:
cur.execute(
"""
SELECT 1 FROM workspace_webapps WHERE icon_filename = %s
UNION ALL SELECT 1 FROM gruppen_webapps WHERE icon_filename = %s
UNION ALL SELECT 1 FROM webapp_templates WHERE icon_filename = %s
LIMIT 1
""",
(datei, datei, datei),
)
if cur.fetchone() is None:
pfad = WEBAPP_ICONS_DIR / datei
if pfad.is_file():
pfad.unlink()
def workspace_entwurf_verwerfen(workspace_id: str) -> bool:
with get_database_connection() as conn:
with conn.cursor() as cur:
vid = entwurf_id(cur, workspace_id)
if vid is None:
return False
cur.execute("SELECT icon_filename FROM workspace_webapps WHERE workspace_version_id = %s", (vid,))
icons = [r[0] for r in cur.fetchall()]
cur.execute("DELETE FROM workspace_versionen WHERE id = %s", (vid,))
_icons_aufraeumen(cur, icons)
return True
def workspace_veroeffentlichen(workspace_id: str, notiz: str | None):
"""Rückgabe: (nummer, None) oder (None, (fehlercode, nachricht))."""
with get_database_connection() as conn:
with conn.cursor() as cur:
vid = entwurf_id(cur, workspace_id)
if vid is None:
return None, ("kein_entwurf", "Es gibt keinen Entwurf zum Veröffentlichen.")
# Gleiche Distro-Regel wie beim Anlegen: nichts veröffentlichen,
# was auf der Distro des Workspace still fehlen würde.
cur.execute(
"""
SELECT m.name FROM workspace_version_merkmale vm
JOIN merkmale m ON m.id = vm.merkmal_id
JOIN workspaces w ON w.id = %s
LEFT JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = w.backend_id
WHERE vm.workspace_version_id = %s AND w.backend_id IS NOT NULL AND bp.id IS NULL
ORDER BY m.name
""",
(workspace_id, vid),
)
fehlend = [r[0] for r in cur.fetchall()]
if fehlend:
return None, ("distro_konflikt", "Auf der Distro des Workspace nicht umgesetzt: " + ", ".join(fehlend))
cur.execute(
"""
UPDATE workspace_versionen SET status = 'veroeffentlicht', veroeffentlicht_am = now(), notiz = %s
WHERE id = %s RETURNING nummer
""",
(notiz or None, vid),
)
(nummer,) = cur.fetchone()
cur.execute("UPDATE workspaces SET updated_at = now() WHERE id = %s", (workspace_id,))
return nummer, None
def workspace_versionen(workspace_id: str) -> list[dict]:
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT v.id, v.nummer, v.status, v.notiz, v.erstellt_am, v.veroeffentlicht_am,
(SELECT count(*) FROM workspace_version_merkmale vm WHERE vm.workspace_version_id = v.id),
(SELECT count(*) FROM workspace_webapps ww WHERE ww.workspace_version_id = v.id)
FROM workspace_versionen v WHERE v.workspace_id = %s ORDER BY v.nummer DESC
""",
(workspace_id,),
)
return [
{
"id": str(vid), "nummer": nummer, "status": status, "notiz": notiz,
"erstellt_am": erstellt.isoformat() if erstellt else None,
"veroeffentlicht_am": veroeff.isoformat() if veroeff else None,
"anzahl_merkmale": anz_m, "anzahl_webapps": anz_w,
}
for vid, nummer, status, notiz, erstellt, veroeff, anz_m, anz_w in cur.fetchall()
]
def workspace_nutzung(workspace_id: str) -> dict:
"""
Wer nutzt diesen Workspace: Installationsprofile (künftige
Installationen), OEs (mit Version/Automatik) und Geräte (aktive, nicht
archivierte) - letztere aufgeteilt in "steigen bei neuer Version
automatisch um" und "auf Version N gepinnt".
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT bv.id, bv.label, o.name FROM bereitstellungsvorlagen bv
JOIN organizations o ON o.id = bv.organization_id
WHERE bv.workspace_id = %s ORDER BY o.name, bv.label
""",
(workspace_id,),
)
profile = [{"id": str(i), "label": l, "organisation": o} for i, l, o in cur.fetchall()]
cur.execute(
"""
SELECT oe.id, oe.name, o.name, oe.automatisch_aktualisieren, v.nummer
FROM organisationseinheiten oe
JOIN organizations o ON o.id = oe.organization_id
LEFT JOIN workspace_versionen v ON v.id = oe.workspace_version_id
WHERE oe.workspace_id = %s ORDER BY o.name, oe.name
""",
(workspace_id,),
)
oes = [
{"id": str(i), "name": n, "organisation": o, "automatisch": a or nr is None, "version": nr}
for i, n, o, a, nr in cur.fetchall()
]
cur.execute(
"""
SELECT d.id, o.name FROM devices d
JOIN organizations o ON o.id = d.organization_id
JOIN assignments a ON a.device_id = d.id
WHERE d.archived_at IS NULL AND d.deprovisioned_at IS NULL
"""
)
geraete = cur.fetchall()
cur.execute("SELECT id, nummer FROM workspace_versionen WHERE workspace_id = %s", (workspace_id,))
eigene_versionen = {vid: nr for vid, nr in cur.fetchall()}
automatisch, gepinnt, organisationen = 0, {}, set()
for device_id, org_name in geraete:
basis = geraete_basis_herkunft(cur, str(device_id))
if basis is None or basis[0] not in eigene_versionen:
continue
organisationen.add(org_name)
if basis[2]["art"] == "installation" or basis[2]["automatisch"]:
automatisch += 1
else:
nr = eigene_versionen[basis[0]]
gepinnt[nr] = gepinnt.get(nr, 0) + 1
return {
"profile": profile,
"oes": oes,
"geraete_automatisch": automatisch,
"geraete_gepinnt": [{"version": nr, "anzahl": n} for nr, n in sorted(gepinnt.items())],
"organisationen": sorted(organisationen | {p["organisation"] for p in profile} | {o["organisation"] for o in oes}),
"in_benutzung": bool(profile or oes or automatisch or gepinnt),
}
def workspace_wirkung(workspace_id: str) -> dict | None:
"""Wirkungsvorschau für das Veröffentlichen des Entwurfs: Unterschiede
zur neuesten veröffentlichten Version plus Nutzung."""
with get_database_connection() as conn:
with conn.cursor() as cur:
neu = entwurf_id(cur, workspace_id)
if neu is None:
return None
alt = veroeffentlichte_version_id(cur, workspace_id)
def merkmale(vid):
if vid is None:
return {}
cur.execute(
"""
SELECT m.id, m.name, k.art FROM workspace_version_merkmale vm
JOIN merkmale m ON m.id = vm.merkmal_id
LEFT JOIN kategorien k ON k.id = m.kategorie_id
WHERE vm.workspace_version_id = %s
""",
(vid,),
)
return {mid: (name, art or "app") for mid, name, art in cur.fetchall()}
def webapps(vid):
if vid is None:
return set()
cur.execute("SELECT name, url FROM workspace_webapps WHERE workspace_version_id = %s", (vid,))
return set(cur.fetchall())
def standardbrowser(vid):
if vid is None:
return None
cur.execute("SELECT standardbrowser FROM workspace_versionen WHERE id = %s", (vid,))
key = cur.fetchone()[0]
return next((b["name"] for b in standardbrowser_auswahl(cur, vid) if b["key"] == key), key)
m_alt, m_neu = merkmale(alt), merkmale(neu)
w_alt, w_neu = webapps(alt), webapps(neu)
sb_alt, sb_neu = standardbrowser(alt), standardbrowser(neu)
def namen(ids, quelle, art):
return sorted(quelle[i][0] for i in ids if quelle[i][1] == art)
hinzu, weg = set(m_neu) - set(m_alt), set(m_alt) - set(m_neu)
return {
"apps_neu": namen(hinzu, m_neu, "app"),
"apps_weg": namen(weg, m_alt, "app"),
"richtlinien_neu": namen(hinzu, m_neu, "richtlinie"),
"richtlinien_weg": namen(weg, m_alt, "richtlinie"),
"webapps_neu": sorted(n for n, _u in w_neu - w_alt),
"webapps_weg": sorted(n for n, _u in w_alt - w_neu),
"standardbrowser_geaendert": sb_alt != sb_neu,
"standardbrowser_alt": sb_alt,
"standardbrowser_neu": sb_neu,
"unveraendert": not (hinzu or weg or w_neu != w_alt or sb_alt != sb_neu),
"nutzung": workspace_nutzung(workspace_id),
}
def workspace_aktualisieren(workspace_id: str, name: str, description: str | None) -> bool:
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE workspaces SET name = %s, description = %s, updated_at = now() WHERE id = %s RETURNING id",
(name.strip(), description or None, workspace_id),
)
return cur.fetchone() is not None
def workspace_vorlage_setzen(
workspace_id: str, ist_vorlage: bool, sichtbarkeit: str,
kundengruppe_ids: list[str], organization_ids: list[str],
):
"""Vorlagen-Kennzeichen und für wen sie sichtbar ist. Rückgabe wie
workspace_veroeffentlichen()."""
if sichtbarkeit not in ("global", "kundengruppe", "organisation"):
return None, ("invalid_request", "Unbekannte Sichtbarkeit.")
if ist_vorlage and sichtbarkeit == "kundengruppe" and not kundengruppe_ids:
return None, ("invalid_request", "Bitte mindestens eine Kundengruppe wählen.")
if ist_vorlage and sichtbarkeit == "organisation" and not organization_ids:
return None, ("invalid_request", "Bitte mindestens eine Organisation wählen.")
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE workspaces SET ist_vorlage = %s, sichtbarkeit = %s, updated_at = now() WHERE id = %s RETURNING id",
(ist_vorlage, sichtbarkeit, workspace_id),
)
if cur.fetchone() is None:
return None, ("not_found", "Workspace wurde nicht gefunden.")
cur.execute("DELETE FROM workspace_freigabe_kundengruppen WHERE workspace_id = %s", (workspace_id,))
cur.execute("DELETE FROM workspace_freigabe_organisationen WHERE workspace_id = %s", (workspace_id,))
if sichtbarkeit == "kundengruppe":
for kg in dict.fromkeys(kundengruppe_ids):
cur.execute(
"INSERT INTO workspace_freigabe_kundengruppen (workspace_id, kundengruppe_id) VALUES (%s, %s)",
(workspace_id, kg),
)
if sichtbarkeit == "organisation":
for org in dict.fromkeys(organization_ids):
cur.execute(
"INSERT INTO workspace_freigabe_organisationen (workspace_id, organization_id) VALUES (%s, %s)",
(workspace_id, org),
)
return True, None
def workspace_freigaben(workspace_id: str) -> dict:
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute("SELECT kundengruppe_id FROM workspace_freigabe_kundengruppen WHERE workspace_id = %s", (workspace_id,))
kgs = [str(r[0]) for r in cur.fetchall()]
cur.execute("SELECT organization_id FROM workspace_freigabe_organisationen WHERE workspace_id = %s", (workspace_id,))
orgs = [str(r[0]) for r in cur.fetchall()]
return {"kundengruppe_ids": kgs, "organization_ids": orgs}
def fetch_kundengruppen() -> list[dict]:
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute("SELECT id, key, name FROM kundengruppen ORDER BY sort_order, name")
return [{"id": str(i), "key": k, "name": n} for i, k, n in cur.fetchall()]
def workspace_kopie_pruefen(workspace_id: str, backend_id: str) -> list[str] | None:
"""Merkmale der neuesten veröffentlichten Version, die auf der Ziel-Distro
nicht umgesetzt sind und bei einer Kopie wegfallen würden."""
with get_database_connection() as conn:
with conn.cursor() as cur:
vid = veroeffentlichte_version_id(cur, workspace_id)
if vid is None:
return None
cur.execute(
"""
SELECT m.name FROM workspace_version_merkmale vm
JOIN merkmale m ON m.id = vm.merkmal_id
LEFT JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s
WHERE vm.workspace_version_id = %s AND bp.id IS NULL ORDER BY m.name
""",
(backend_id, vid),
)
return [r[0] for r in cur.fetchall()]
def workspace_kopieren(workspace_id: str, name: str, backend_id: str, organization_id: str | None):
"""Kopie der neuesten veröffentlichten Version als neuer Workspace
(Version 1) - auch auf eine andere Distro; Merkmale, die dort fehlen,
fallen weg und werden zurückgemeldet."""
name = name.strip()
if not name:
return None, ("invalid_request", "Bitte einen Namen angeben.")
with get_database_connection() as conn:
with conn.cursor() as cur:
quelle = veroeffentlichte_version_id(cur, workspace_id)
if quelle is None:
return None, ("not_found", "Workspace oder veröffentlichte Version nicht gefunden.")
cur.execute("SELECT description FROM workspaces WHERE id = %s", (workspace_id,))
(description,) = cur.fetchone()
cur.execute("SELECT 1 FROM backends WHERE id = %s", (backend_id,))
if cur.fetchone() is None:
return None, ("backend_not_found", "Die gewählte Distro gibt es nicht.")
cur.execute(
"""
SELECT vm.merkmal_id, m.name, bp.id IS NOT NULL FROM workspace_version_merkmale vm
JOIN merkmale m ON m.id = vm.merkmal_id
LEFT JOIN blueprints bp ON bp.merkmal_id = vm.merkmal_id AND bp.backend_id = %s
WHERE vm.workspace_version_id = %s
""",
(backend_id, quelle),
)
zeilen = cur.fetchall()
erlaubt = {mid for mid, _n, ok in zeilen if ok}
weggefallen = sorted(n for _mid, n, ok in zeilen if not ok)
neu_ws, neu_v = uuid4(), uuid4()
key = workspace_key_aus_name(cur, name, organization_id)
cur.execute(
"""
INSERT INTO workspaces (id, organization_id, key, name, description, backend_id, sichtbarkeit, ist_vorlage)
VALUES (%s, %s, %s, %s, %s, %s, %s, FALSE)
""",
(neu_ws, organization_id, key, name, description, backend_id,
"global" if organization_id is None else "organisation"),
)
cur.execute(
"""
INSERT INTO workspace_versionen (id, workspace_id, nummer, status, notiz, veroeffentlicht_am)
VALUES (%s, %s, 1, 'veroeffentlicht', %s, now())
""",
(neu_v, neu_ws, "Kopie von Workspace " + str(workspace_id)),
)
_version_kopieren(cur, quelle, neu_v, neu_ws, merkmal_filter=erlaubt)
return {"id": str(neu_ws), "key": key, "name": name, "weggefallen": weggefallen}, None
def workspace_loeschen(workspace_id: str):
"""Löscht einen Workspace samt Versionen - nur wenn ihn nichts mehr nutzt
(kein Profil, keine OE, kein Gerät)."""
nutzung = workspace_nutzung(workspace_id)
if nutzung["in_benutzung"]:
return None, ("in_benutzung", "Der Workspace wird noch verwendet und kann nicht gelöscht werden.")
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute("SELECT icon_filename FROM workspace_webapps WHERE workspace_id = %s", (workspace_id,))
icons = [r[0] for r in cur.fetchall()]
cur.execute("DELETE FROM workspaces WHERE id = %s RETURNING id", (workspace_id,))
if cur.fetchone() is None:
return None, ("not_found", "Workspace wurde nicht gefunden.")
_icons_aufraeumen(cur, icons)
return True, None
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,
quelle["backend"]["id"] if quelle["backend"] else None,
)
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,
w.backend_id, b.name, b.version, w.ist_vorlage, w.sichtbarkeit
FROM workspaces w
LEFT JOIN organizations o ON o.id = w.organization_id
LEFT JOIN backends b ON b.id = w.backend_id
WHERE w.id = %s
""",
(workspace_id,),
)
row = cur.fetchone()
if row is None:
return None
version_id = arbeitsversion_id(cur, workspace_id)
cur.execute(
"SELECT nummer, status FROM workspace_versionen WHERE id = %s",
(version_id,),
)
version_row = cur.fetchone()
cur.execute("SELECT standardbrowser FROM workspace_versionen WHERE id = %s", (version_id,))
standardbrowser_row = cur.fetchone()
browser_auswahl = standardbrowser_auswahl(cur, version_id)
cur.execute(
"""
SELECT m.id, m.key, m.name
FROM workspace_version_merkmale vm
JOIN merkmale m ON m.id = vm.merkmal_id
WHERE vm.workspace_version_id = %s
ORDER BY m.key
""",
(version_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],
"backend": {"id": str(row[6]), "name": row[7], "version": row[8]} if row[6] else None,
"ist_vorlage": row[9],
"sichtbarkeit": row[10],
"version": (
{"id": str(version_id), "nummer": version_row[0], "status": version_row[1]}
if version_row else None
),
"merkmale": merkmale,
"standardbrowser": standardbrowser_row[0] if standardbrowser_row else None,
"standardbrowser_auswahl": browser_auswahl,
}
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_version_merkmale (workspace_version_id, merkmal_id)
VALUES (%s, %s)
ON CONFLICT DO NOTHING
""",
(arbeitsversion_id(cur, 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 _write_merkmal_icon(merkmal_id, icon_bytes: bytes, content_type: str) -> str:
# Live gefunden (11.09.2026): "image/x-icon" (klassisches favicon.ico,
# von _erkanntes_bildformat() erkannt) fehlt in Pythons mimetypes-
# Datenbank (guess_extension liefert None) - .ico ist bei irrigem/
# leerem Ergebnis der sinnvollste Default, gleiche Begruendung wie in
# _write_webapp_icon() (dort schon so geloest, hier zunaechst
# faelschlich .svg als Default gesetzt gewesen).
extension = mimetypes.guess_extension(content_type) or ".ico"
if extension == ".jpe":
extension = ".jpg"
filename = f"{merkmal_id}{extension}"
MERKMAL_ICONS_DIR.mkdir(parents=True, exist_ok=True)
(MERKMAL_ICONS_DIR / filename).write_bytes(icon_bytes)
return filename
def aktualisiere_merkmal_icon(merkmal_id: str) -> dict | None:
"""
Holt (falls möglich) ein Icon fuer ein Merkmal und schreibt icon_filename/
icon_content_type - Gegenstueck zu create_webapp()s Favicon-Fetch, aber
als eigener, wiederholbar aufrufbarer Schritt statt beim Anlegen
(Merkmale entstehen per Migration, nicht per API-Aufruf mit sofortigem
Netzwerk-Kontext). Liefert None, wenn das Merkmal nicht existiert;
{"icon_path": None} (kein Fehler), wenn weder Simple-Icons-Slug noch
Favicon-Fetch etwas geliefert haben - dann bleibt das Fallback-Icon in
der Flottenmatrix bestehen.
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT icon_source_hint, homepage_url FROM merkmale WHERE id = %s",
(merkmal_id,),
)
row = cur.fetchone()
if row is None:
return None
icon_source_hint, homepage_url = row
gefunden = fetch_merkmal_icon(icon_source_hint, homepage_url)
icon_filename = None
icon_content_type = None
if gefunden is not None:
icon_bytes, icon_content_type = gefunden
icon_filename = _write_merkmal_icon(merkmal_id, icon_bytes, icon_content_type)
cur.execute(
"UPDATE merkmale SET icon_filename = %s, icon_content_type = %s WHERE id = %s",
(icon_filename, icon_content_type, merkmal_id),
)
return {"icon_path": f"/merkmal-icons/{icon_filename}" if icon_filename else None}
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, w.auf_desktop
FROM workspace_webapps w
LEFT JOIN kategorien k ON k.id = w.kategorie_id
WHERE w.workspace_version_id = %s
ORDER BY w.sort_order, w.name
""",
(arbeitsversion_id(cur, 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,
"auf_desktop": auf_desktop,
}
for (webapp_id, name, url, icon_filename, sort_order, kategorie_id, kategorie_name,
kiosk_modus, auf_desktop) 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, auf_desktop: 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, workspace_version_id, name, url, icon_filename, icon_content_type,
sort_order, kategorie_id, kiosk_modus, auf_desktop)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
webapp_id, workspace_id, arbeitsversion_id(cur, workspace_id), name, url,
icon_filename, icon_content_type, sort_order, kategorie_id, kiosk_modus, auf_desktop,
),
)
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,))
# Seit den Workspace-Versionen (07.10.2026) teilen sich Kopien
# derselben Webapp in mehreren Versionen eine Icon-Datei - nur
# löschen, wenn sie niemand mehr verwendet.
icon_noch_verwendet = False
if row and row[0]:
cur.execute(
"""
SELECT 1 FROM workspace_webapps WHERE icon_filename = %s
UNION ALL
SELECT 1 FROM gruppen_webapps WHERE icon_filename = %s
LIMIT 1
""",
(row[0], row[0]),
)
icon_noch_verwendet = cur.fetchone() is not None
if row and row[0] and not icon_noch_verwendet:
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 t.id, t.name, t.url, t.description, t.icon_filename, t.kategorie_id, k.name
FROM webapp_templates t
LEFT JOIN kategorien k ON k.id = t.kategorie_id
ORDER BY t.name
"""
)
return [
{
"id": str(template_id),
"name": name,
"url": url,
"description": description,
"icon_path": f"/webapp-icons/{icon_filename}" if icon_filename else None,
"kategorie_id": str(kategorie_id) if kategorie_id else None,
"kategorie_name": kategorie_name,
}
for template_id, name, url, description, icon_filename, kategorie_id, kategorie_name 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_version_id = %s AND w.template_id = t.id
)
ORDER BY t.name
""",
(arbeitsversion_id(cur, 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, kategorie_id: str | None = 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, kategorie_id)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""",
(template_id, name, url, description, icon_filename, icon_content_type, kategorie_id),
)
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, kategorie_id: str | None = 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, kategorie_id = %s, updated_at = now()
WHERE id = %s
""",
(name, url, description, icon_filename, icon_content_type, kategorie_id, 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, kategorie_id FROM webapp_templates WHERE id = %s",
(template_id,),
)
template = cur.fetchone()
if template is None:
return None
name, url, icon_filename, icon_content_type, kategorie_id = template
version_id = arbeitsversion_id(cur, workspace_id)
cur.execute(
"SELECT 1 FROM workspace_webapps WHERE workspace_version_id = %s AND template_id = %s",
(version_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, workspace_version_id, name, url, icon_filename, icon_content_type,
template_id, kategorie_id)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
webapp_id, workspace_id, version_id, name, url, copied_icon_filename,
icon_content_type, template_id, kategorie_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:
version_id = arbeitsversion_id(cur, workspace_id)
cur.execute(
"DELETE FROM workspace_version_merkmale WHERE workspace_version_id = %s AND merkmal_id = %s",
(version_id, merkmal_id),
)
# War das der Standardbrowser, gilt wieder "keine Vorgabe".
cur.execute(
"""
UPDATE workspace_versionen SET standardbrowser = NULL
WHERE id = %s AND standardbrowser = (SELECT key FROM merkmale WHERE id = %s)
""",
(version_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 ziel_oe_pruefen(cur, organization_id: str, ziel_oe_id: str, bereitstellungsvorlage_id: str) -> str | None:
"""Passt die Ziel-OE zum Installationsprofil? Fehlermeldung oder None.
Gilt in der OE (auch geerbt) ein Workspace, muss das Profil dieselbe
Distro installieren."""
cur.execute(
"SELECT 1 FROM organisationseinheiten WHERE id = %s AND organization_id = %s",
(ziel_oe_id, organization_id),
)
if cur.fetchone() is None:
return "Der Standort/Zweck gehört nicht zu dieser Organisation."
cur.execute(
"SELECT bv.backend_id, b.name FROM bereitstellungsvorlagen bv JOIN backends b ON b.id = bv.backend_id WHERE bv.id = %s",
(bereitstellungsvorlage_id,),
)
profil = cur.fetchone()
if profil is None:
return "Das Installationsprofil wurde nicht gefunden."
cur.execute("SELECT id, parent_id, workspace_id FROM organisationseinheiten WHERE organization_id = %s", (organization_id,))
oes = {oe: (parent, ws) for oe, parent, ws in cur.fetchall()}
aktuell = UUID(str(ziel_oe_id))
while aktuell is not None and aktuell in oes:
parent, ws = oes[aktuell]
if ws is not None:
cur.execute(
"SELECT w.name, w.backend_id, b.name FROM workspaces w LEFT JOIN backends b ON b.id = w.backend_id WHERE w.id = %s",
(ws,),
)
ws_name, ws_backend, ws_distro = cur.fetchone()
if ws_backend is not None and ws_backend != profil[0]:
return (
f"Der Standort/Zweck nutzt „{ws_name}“ ({ws_distro}), das Installationsprofil installiert aber "
f"{profil[1]}. Bitte ein {ws_distro}-Profil oder einen anderen Standort/Zweck wählen."
)
return None
aktuell = parent
return None
def vorlage_fuer_ziel_oe(cur, organization_id: str, ziel_oe_id: str | None):
"""
Einstieg vereinfachen, Schritt 3 (25.10.2026): Die Installation
(Bereitstellungsvorlage) ergibt sich aus der Ziel-OE, der Kunde wählt sie
nicht mehr. Gilt in der OE (auch geerbt) ein Workspace, eine Vorlage mit
dessen Distro, bevorzugt die Standard-Vorlage; sonst die Standard-
Vorlage der Organisation. Ohne Ziel-OE gilt die Standard-OE.
Rückgabe (vorlage_id, None) oder (None, fehlermeldung).
"""
cur.execute("SELECT id, parent_id, workspace_id, ist_standard FROM organisationseinheiten WHERE organization_id = %s", (organization_id,))
oes = {str(oe): (parent, ws, standard) for oe, parent, ws, standard in cur.fetchall()}
if not ziel_oe_id:
ziel_oe_id = next((oe for oe, (_p, _w, standard) in oes.items() if standard), None)
backend = distro = None
aktuell = str(ziel_oe_id) if ziel_oe_id else None
while aktuell is not None and aktuell in oes:
parent, ws, _standard = oes[aktuell]
if ws is not None:
cur.execute(
"SELECT w.backend_id, b.name FROM workspaces w LEFT JOIN backends b ON b.id = w.backend_id WHERE w.id = %s",
(ws,),
)
backend, distro = cur.fetchone()
break
aktuell = str(parent) if parent else None
if backend is not None:
cur.execute(
"""
SELECT id FROM bereitstellungsvorlagen WHERE organization_id = %s AND backend_id = %s
ORDER BY is_default DESC, created_at LIMIT 1
""",
(organization_id, backend),
)
row = cur.fetchone()
if row is None:
return None, (
f"Für {distro} ist für deine Organisation noch keine Installation eingerichtet. "
"Bitte wende dich an den Support."
)
return str(row[0]), None
cur.execute(
"SELECT id FROM bereitstellungsvorlagen WHERE organization_id = %s ORDER BY is_default DESC, created_at LIMIT 1",
(organization_id,),
)
row = cur.fetchone()
if row is None:
return None, "Für deine Organisation ist noch keine Installation eingerichtet. Bitte wende dich an den Support."
return str(row[0]), None
def create_enrollment_session(
organization_id: str, bereitstellungsvorlage_id: str | None, max_devices: int, expires_at: str,
neuinstallation_ohne_rueckfrage: bool = False, ziel_oe_id: str | None = None,
):
"""Rückgabe: ({"id": ..., "bereitstellungsvorlage_id": ...}, None) oder (None, fehlermeldung)."""
session_id = uuid4()
with get_database_connection() as conn:
with conn.cursor() as cur:
if not bereitstellungsvorlage_id:
bereitstellungsvorlage_id, fehler = vorlage_fuer_ziel_oe(cur, organization_id, ziel_oe_id)
if fehler:
return None, fehler
if ziel_oe_id:
fehler = ziel_oe_pruefen(cur, organization_id, ziel_oe_id, bereitstellungsvorlage_id)
if fehler:
return None, fehler
cur.execute(
"""
INSERT INTO enrollment_sessions
(id, organization_id, bereitstellungsvorlage_id, max_devices, expires_at,
neuinstallation_ohne_rueckfrage, ziel_oe_id)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""",
(session_id, organization_id, bereitstellungsvorlage_id, max_devices, expires_at,
neuinstallation_ohne_rueckfrage, ziel_oe_id or None),
)
return {"id": str(session_id), "bereitstellungsvorlage_id": str(bereitstellungsvorlage_id)}, None
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 delete_enrollment_session(organization_id: str, session_id: str) -> str:
"""
GUI-Runde 23.10.2026: "Ausgegebene Medien" verwalten. Gelöscht wird nur,
was ohnehin nicht mehr installieren kann (widerrufen, abgelaufen oder
Kontingent aufgebraucht) - ein noch gültiges Medium muss erst
deaktiviert werden. Nichts verweist per Fremdschlüssel auf die Session;
installierte Geräte bleiben unberührt. Rückgabe: "geloescht",
"nicht_gefunden" oder "noch_gueltig".
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
DELETE FROM enrollment_sessions
WHERE id = %s AND organization_id = %s
AND (revoked_at IS NOT NULL OR expires_at <= CURRENT_TIMESTAMP OR devices_used >= max_devices)
RETURNING id
""",
(session_id, organization_id),
)
if cur.fetchone() is not None:
return "geloescht"
cur.execute(
"SELECT 1 FROM enrollment_sessions WHERE id = %s AND organization_id = %s",
(session_id, organization_id),
)
return "noch_gueltig" if cur.fetchone() is not None else "nicht_gefunden"
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 fetch_vorlage_for_session(session_id: str) -> tuple[str | None, str | None]:
"""
(Name der Bereitstellungsvorlage, Backend-Key) einer Enrollment Session -
fuer den Boot-Menue-Eintrag (VORLAGE_LABEL) und das Offline-Medium
(passendes Golden Image) in build_customer_iso.sh.
"""
try:
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT bv.label, b.key
FROM enrollment_sessions es
JOIN bereitstellungsvorlagen bv ON bv.id = es.bereitstellungsvorlage_id
JOIN backends b ON b.id = bv.backend_id
WHERE es.id = %s
""",
(session_id,),
)
row = cur.fetchone()
except Exception:
# Rein unterstuetzend - ein Fehler hier darf den ISO-Bau nie verhindern.
return None, None
return (row[0], row[1]) if row else (None, None)
def golden_image_path_for_backend(backend_key: str | None) -> Path | None:
"""
Pfad des Golden Images eines Image-Backends. Der Dateiname steht nur an
einer Stelle - GOLDEN_IMAGE_FILENAME im jeweiligen backend.sh des
Installers -, deshalb wird er dort gelesen statt hier dupliziert.
"""
if not backend_key or not re.fullmatch(r"[a-z0-9-]+", backend_key):
return None
backend_file = Path(TUXFLOTTE_INSTALLER_DIR) / "backends" / backend_key / "backend.sh"
try:
match = re.search(r'^GOLDEN_IMAGE_FILENAME="([^"/]+)"', backend_file.read_text(), re.MULTILINE)
except OSError:
return None
if not match:
return None
path = GOLDEN_IMAGES_DIR / match.group(1)
return path if path.is_file() else None
ROOT_PASSWORD_HASH_PATTERN = re.compile(r"^\$6\$[./A-Za-z0-9]{1,16}\$[./A-Za-z0-9]{86}$")
def run_iso_build(
build_id, organization_id: str, activation_code: str, wifi_ssid: str | None, wifi_psk: str | None,
root_password_hash: str | None = None,
offline_medium: bool = False,
) -> 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")
vorlage_label, backend_key = fetch_vorlage_for_session(activation_code)
vorlage_label = (vorlage_label or "").replace("\n", " ")
golden_image_file = ""
if offline_medium:
golden_image = golden_image_path_for_backend(backend_key)
if golden_image is None:
update_iso_build(
build_id, status="failed", mark_finished=True,
error_message=f"Offline-Medium: kein Golden Image für Backend '{backend_key}' gefunden.",
)
return
golden_image_file = str(golden_image)
try:
# Laeuft seit 03.10.2026 ohne jede Privilegien-Eskalation:
# build_customer_iso.sh legt die Kundendaten als eigene kleine
# Squashfs-Schicht auf das Boot-Medium, statt das ganze Squashfs als
# root neu zu packen (vorher scoped sudo, ADR-0027 - abgeloest durch
# das Berechtigungskonzept, siehe platform-docs).
result = subprocess.run(
[
"bash",
f"{TUXFLOTTE_INSTALLER_DIR}/scripts/build_customer_iso.sh",
BOOT_MEDIUM_PATH,
str(output_path),
activation_code,
wifi_ssid or "",
# Geheimnisse per stdin statt als Argument (Kunden-ISO-Test
# 02.10.2026, Befund B6): Argumente stehen in der
# Prozessliste - siehe build_customer_iso.sh.
"-",
volid,
],
input=(
f"WIFI_PSK={wifi_psk or ''}\n"
f"ROOT_PASSWORD_HASH={root_password_hash or ''}\n"
f"VORLAGE_LABEL={vorlage_label}\n"
f"GOLDEN_IMAGE_FILE={golden_image_file}\n"
),
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"
}
# --- Personalisierung (ADR-0041) ---------------------------------------------
#
# Vereinfachter Ablauf (Ergänzung 24.10.2026): Der Kunden-Admin trägt in der
# Geräte-Info die Adresse des Benutzers ein, die Kundenplattform schickt einen
# Einladungslink. Über den Link wählt der Benutzer Anmeldename und Passwort;
# der Link belegt zugleich den Besitz der Adresse. Die Kundenplattform hasht
# das Passwort, bevor es hierher kommt; Klartext erreicht anode nie.
# Angelegt wird das Konto beim nächsten Start vom Agent.
PERSONALISIERUNG_EINLADUNG_GUELTIG = timedelta(days=7)
PERSONALISIERUNG_LAUFEND = ("eingeladen", "bestaetigt")
# Anmeldenamen: Kleinbuchstaben, Ziffern, Punkt, Binde- und Unterstrich,
# beginnend mit einem Buchstaben. Systemkonten prüft die Rolle am Gerät
# zusätzlich gegen /etc/passwd.
PERSONALISIERUNG_ANMELDENAME_MUSTER = re.compile(r"^[a-z][a-z0-9._-]{1,30}$")
PERSONALISIERUNG_RESERVIERT = {
"root", "gast", "personalisierung", "tuxflotte", "admin", "administrator",
"daemon", "bin", "sys", "sync", "games", "man", "lp", "mail", "news", "uucp",
"proxy", "www-data", "backup", "list", "irc", "gnats", "nobody", "messagebus",
"sshd", "lightdm", "gdm", "polkitd", "avahi", "colord", "pulse", "rtkit",
"saned", "syslog", "dnsmasq", "usbmux", "kernoops", "geoclue", "cups-pk-helper",
"speech-dispatcher", "hplip", "fwupd-refresh", "tss", "uuidd", "tcpdump",
"veyon", "ansible", "sudo", "wheel", "users", "nogroup",
}
PERSONALISIERUNG_RESERVIERT_PRAEFIXE = ("systemd-", "tuxflotte", "_")
DOMAIN_MUSTER = re.compile(r"^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$")
EMAIL_MUSTER = re.compile(r"^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]{1,64}@([A-Za-z0-9.-]{1,253})$")
def personalisierung_token_hash(token: str) -> str:
return hashlib.sha256((token or "").encode()).hexdigest()
def anmeldename_fehler(anmeldename: str) -> str | None:
if not PERSONALISIERUNG_ANMELDENAME_MUSTER.match(anmeldename):
return (
"Der Anmeldename muss mit einem Kleinbuchstaben beginnen und darf nur "
"Kleinbuchstaben, Ziffern, Punkt, Binde- und Unterstrich enthalten (2 bis 31 Zeichen)."
)
if anmeldename in PERSONALISIERUNG_RESERVIERT or anmeldename.startswith(PERSONALISIERUNG_RESERVIERT_PRAEFIXE):
return "Dieser Anmeldename ist reserviert. Bitte einen anderen wählen."
return None
UMLAUTE = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"})
def anmeldename_vorschlag(email: str) -> str:
"""Vorschlag aus dem Teil vor dem @ (m.hahn@schule.de -> m.hahn), sonst leer."""
lokal = (email or "").split("@", 1)[0].lower().translate(UMLAUTE)
name = re.sub(r"^[^a-z]+", "", re.sub(r"[^a-z0-9._-]", "", lokal))[:31].rstrip("._-")
return name if anmeldename_fehler(name) is None else ""
def email_domaenen_normalisieren(domaenen: list[str]) -> tuple[list[str], str | None]:
ergebnis = []
for eintrag in domaenen:
domain = (eintrag or "").strip().lower().lstrip("@").rstrip(".")
if not domain:
continue
if not DOMAIN_MUSTER.match(domain):
return [], f"„{eintrag.strip()}“ ist keine gültige Domäne."
if domain not in ergebnis:
ergebnis.append(domain)
return ergebnis, None
def personalisierung_verfallene_markieren(cur) -> None:
# Lazy statt Zeitgeber: jeder Zugriff räumt zuerst auf. Die Adresse bleibt
# stehen, sie stammt vom Kunden-Admin (Migration 0072).
cur.execute(
"""
UPDATE personalisierungen
SET status = 'verfallen', passwort_hash = NULL, bestaetigung_token_hash = NULL
WHERE status = 'eingeladen' AND bestaetigung_bis < now()
"""
)
# Merkmal "Personalisierung möglich": der Check-in sagt der Rolle in den
# Optionen, wofür das Konto "Personalisierung" am Anmeldebildschirm gerade da
# ist (ADR-0041, Ergänzungen 18.10.2026):
# personalisieren - kein persönliches Konto oder ein Vorgang läuft
# passwort - ein Konto ist eingerichtet: Eintrag "Passwort vergessen?"
# aus - nur gesperrte Konten: kein Eintrag
PERSONALISIERUNG_MERKMAL = "personalisierung"
def personalisierung_modus(device_id: str) -> str:
with get_database_connection() as conn:
with conn.cursor() as cur:
personalisierung_verfallene_markieren(cur)
cur.execute(
"""
SELECT
bool_or(status = ANY(%s)),
bool_or(status = 'angelegt' AND NOT entfernen),
bool_or(status = 'angelegt' AND NOT entfernen AND NOT gesperrt)
FROM personalisierungen
WHERE device_id = %s
""",
(list(PERSONALISIERUNG_LAUFEND), device_id),
)
laufend, konto_vorhanden, konto_aktiv = cur.fetchone()
if laufend or not konto_vorhanden:
return "personalisieren"
return "passwort" if konto_aktiv else "aus"
# Der Kiosk "Passwort vergessen?" und seit 25.10.2026 der QR-Code am
# Anmeldebildschirm weisen sich mit Gerät und diesem Token aus.
# Abgeleitet statt gespeichert; lesen kann es jedes lokale Konto des Geräts,
# es erlaubt aber nur, dem Besitzer einen Link an seine eigene Adresse zu
# schicken (begrenzt, siehe VERGESSEN_*).
def personalisierung_kiosk_token(device_id) -> str:
schluessel = (ENCRYPTION_KEY or "").encode()
if not schluessel:
return ""
return hmac.new(schluessel, f"personalisierung-kiosk:{device_id}".encode(), hashlib.sha256).hexdigest()[:32]
VERGESSEN_LINK_GUELTIG = timedelta(hours=1)
VERGESSEN_ABSTAND = timedelta(minutes=10)
VERGESSEN_JE_TAG = 3
def email_maskieren(email: str | None) -> str:
if not email or "@" not in email:
return ""
lokal, domain = email.rsplit("@", 1)
return f"{lokal[:1]}•••@{domain}"
def _vergessen_konto(cur, device_id: str, token: str, anmeldename: str | None = None):
"""Das aktive persönliche Konto des Geräts, wenn das Kiosk-Token passt."""
erwartet = personalisierung_kiosk_token(device_id)
if not erwartet or not hmac.compare_digest(erwartet, (token or "").strip()):
return None
cur.execute(
"""
SELECT p.id, p.anmeldename, p.email, p.vergessen_mails, d.hostname, o.name
FROM personalisierungen p
JOIN devices d ON d.id = p.device_id
JOIN organizations o ON o.id = p.organization_id
WHERE p.device_id::text = %s AND p.status = 'angelegt' AND NOT p.entfernen AND NOT p.gesperrt
AND (%s::text IS NULL OR p.anmeldename = %s)
ORDER BY p.angelegt_am DESC
LIMIT 1
""",
(str(device_id), anmeldename or None, anmeldename or None),
)
return cur.fetchone()
def vergessen_pruefen(device_id: str, token: str, anmeldename: str | None = None) -> dict | None:
with get_database_connection() as conn:
with conn.cursor() as cur:
row = _vergessen_konto(cur, device_id, token, anmeldename)
if row is None:
return None
_pid, anmeldename, email, _mails, hostname, org_name = row
return {"anmeldename": anmeldename, "email_maskiert": email_maskieren(email), "hostname": hostname, "organisation": org_name}
def vergessen_anfordern(device_id: str, token: str, anmeldename: str | None = None):
"""
Erzeugt den Link für "Passwort vergessen?". Gibt (Ergebnis, Fehler)
zurück; das Ergebnis enthält die volle Adresse und das Token für die
Mail, die kundenplattform verschickt.
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
row = _vergessen_konto(cur, device_id, token, anmeldename)
if row is None:
return None, "Auf diesem Gerät ist kein persönliches Konto eingerichtet."
return _vergessen_link(cur, row)
def _vergessen_link(cur, row):
"""Neuer Link für ein Konto, sofern die Begrenzung es zulässt. Gibt (Ergebnis, Fehler) zurück."""
jetzt = datetime.now(timezone.utc)
pid, anmeldename, email, mails, hostname, org_name = row
cur.execute("SELECT 1 FROM personalisierungen WHERE id = %s FOR UPDATE", (pid,))
letzte = [m for m in (mails or []) if m > jetzt - timedelta(days=1)]
if letzte and max(letzte) > jetzt - VERGESSEN_ABSTAND:
return None, "Es wurde gerade erst ein Link verschickt. Bitte im Postfach (auch im Spam-Ordner) nachsehen oder in 10 Minuten erneut versuchen."
if len(letzte) >= VERGESSEN_JE_TAG:
return None, "Heute wurden schon mehrere Links verschickt. Bitte morgen erneut versuchen oder an die Verwaltung wenden."
link_token = secrets.token_urlsafe(32)
cur.execute(
"""
UPDATE personalisierungen
SET vergessen_token_hash = %s, vergessen_bis = %s, vergessen_mails = %s
WHERE id = %s
""",
(personalisierung_token_hash(link_token), jetzt + VERGESSEN_LINK_GUELTIG, letzte + [jetzt], pid),
)
return {
"personalisierung_id": str(pid),
"token": link_token,
"email": email,
"email_maskiert": email_maskieren(email),
"anmeldename": anmeldename,
"hostname": hostname,
"organisation": org_name,
}, None
def vergessen_per_adresse(email: str) -> list[dict]:
"""
"Passwort vergessen?" auf der öffentlichen Seite (ADR-0041, Ergänzung
24.10.2026): je eingerichtetem Konto mit dieser Adresse ein Link, auch auf
mehreren Geräten. Konten an der Begrenzung werden still übergangen; die
Seite antwortet immer gleich und verrät nicht, ob es die Adresse gibt.
"""
email = (email or "").strip()
if not EMAIL_MUSTER.match(email):
return []
links = []
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT p.id, p.anmeldename, p.email, p.vergessen_mails, d.hostname, o.name
FROM personalisierungen p
JOIN devices d ON d.id = p.device_id
JOIN organizations o ON o.id = p.organization_id
WHERE lower(p.email) = lower(%s) AND p.status = 'angelegt' AND NOT p.entfernen AND NOT p.gesperrt
ORDER BY p.angelegt_am
""",
(email,),
)
for row in cur.fetchall():
ergebnis, _fehler = _vergessen_link(cur, row)
if ergebnis:
links.append(ergebnis)
return links
def _neues_passwort_konto(cur, token: str):
cur.execute(
"""
SELECT p.id, p.anmeldename, d.hostname
FROM personalisierungen p
JOIN devices d ON d.id = p.device_id
WHERE p.vergessen_token_hash = %s AND p.vergessen_bis > now()
AND p.status = 'angelegt' AND NOT p.entfernen AND NOT p.gesperrt
""",
(personalisierung_token_hash(token),),
)
return cur.fetchone()
def neues_passwort_pruefen(token: str) -> dict | None:
with get_database_connection() as conn:
with conn.cursor() as cur:
row = _neues_passwort_konto(cur, token)
if row is None:
return None
return {"anmeldename": row[1], "hostname": row[2]}
def neues_passwort_setzen(token: str, passwort_hash: str) -> dict | None:
"""Wie der Reset der Verwaltung, nur ohne Änderungszwang; der Link ist danach verbraucht."""
with get_database_connection() as conn:
with conn.cursor() as cur:
row = _neues_passwort_konto(cur, token)
if row is None:
return None
pid, anmeldename, hostname = row
cur.execute(
"""
UPDATE personalisierungen
SET reset_id = %s, reset_passwort_hash = %s, reset_aendern = FALSE,
reset_angefordert_am = now(), reset_angefordert_von = 'selbst (Mail-Link)',
reset_gesetzt_am = NULL, vergessen_token_hash = NULL, vergessen_bis = NULL
WHERE id = %s
""",
(uuid4(), passwort_hash, pid),
)
return {"anmeldename": anmeldename, "hostname": hostname}
PERSONALISIERUNG_SPALTEN = (
"p.id, p.device_id, p.status, p.code_gueltig_bis, p.bestehenden_behalten, p.erstellt_von, "
"p.erstellt_am, p.email, p.anmeldename, p.eingereicht_am, p.bestaetigung_bis, p.bestaetigt_am, "
"p.angelegt_am, p.beendet_am, p.gesperrt, p.entfernen, p.geraet_meldung, "
"p.reset_angefordert_am, p.reset_angefordert_von, p.reset_gesetzt_am, "
"(p.reset_passwort_hash IS NOT NULL), p.reset_aendern"
)
def personalisierung_als_dict(row) -> dict:
def zeit(wert):
return wert.isoformat() if wert else None
return {
"id": str(row[0]),
"device_id": str(row[1]),
"status": row[2],
"code_gueltig_bis": zeit(row[3]),
"bestehenden_behalten": row[4],
"erstellt_von": row[5],
"erstellt_am": zeit(row[6]),
"email": row[7],
"anmeldename": row[8],
"eingereicht_am": zeit(row[9]),
"bestaetigung_bis": zeit(row[10]),
"bestaetigt_am": zeit(row[11]),
"angelegt_am": zeit(row[12]),
"beendet_am": zeit(row[13]),
"gesperrt": row[14],
"entfernen": row[15],
"geraet_meldung": row[16],
"reset_angefordert_am": zeit(row[17]),
"reset_angefordert_von": row[18],
"reset_gesetzt_am": zeit(row[19]),
"reset_offen": row[20],
"reset_aendern": row[21],
}
def fetch_personalisierungen_geraet(device_id: str) -> list[dict]:
with get_database_connection() as conn:
with conn.cursor() as cur:
personalisierung_verfallene_markieren(cur)
cur.execute(
f"SELECT {PERSONALISIERUNG_SPALTEN} FROM personalisierungen p "
"WHERE p.device_id = %s ORDER BY p.erstellt_am DESC",
(device_id,),
)
return [personalisierung_als_dict(row) for row in cur.fetchall()]
def fetch_personalisierungen_organisation(organization_id: str) -> dict[str, dict]:
"""
Für die Geräteliste: je Gerät der laufende Vorgang und die angelegten
Konten, in einer Abfrage statt einer je Gerät.
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
personalisierung_verfallene_markieren(cur)
cur.execute(
f"SELECT {PERSONALISIERUNG_SPALTEN} FROM personalisierungen p "
"WHERE p.organization_id = %s AND (p.status = ANY(%s) OR p.status = 'angelegt') "
"ORDER BY p.erstellt_am",
(organization_id, list(PERSONALISIERUNG_LAUFEND)),
)
rows = cur.fetchall()
ergebnis: dict[str, dict] = {}
for row in rows:
eintrag = personalisierung_als_dict(row)
geraet = ergebnis.setdefault(eintrag["device_id"], {"laufend": None, "konten": []})
if eintrag["status"] == "angelegt":
geraet["konten"].append(eintrag)
else:
geraet["laufend"] = eintrag
return ergebnis
def personalisierung_merkmal_aktiv(device_id: str) -> bool:
"""Ob das Gerät das Merkmal "Personalisierung möglich" hat (Workspace, OE, Gruppe)."""
# Die Ansicht der Geräte-Info, nicht fetch_auftragskatalog_state(): Der
# Check-in-Weg reichert Optionen an und erzeugt dabei etwa Veyon-Schlüssel.
return any(
e["merkmal"] == PERSONALISIERUNG_MERKMAL and e["state"] == "present"
for e in fetch_auftragskatalog_listing(device_id) or []
)
# Die Stand-Seite nach dem Speichern weist sich mit der ID und diesem Token
# aus. Abgeleitet statt gespeichert, wie das Kiosk-Token; es zeigt nur den
# Stand dieses einen Vorgangs.
def personalisierung_stand_token(personalisierung_id) -> str:
schluessel = (ENCRYPTION_KEY or "").encode()
if not schluessel:
return ""
return hmac.new(schluessel, f"personalisierung-stand:{personalisierung_id}".encode(), hashlib.sha256).hexdigest()[:32]
def create_personalisierung_einladung(device_id: str, email: str, bestehenden_behalten: bool, erstellt_von: str | None):
"""
Gibt (ergebnis, fehler) zurück. Das Ergebnis enthält das Token für den
Link in der Mail, die kundenplattform verschickt.
"""
if not personalisierung_merkmal_aktiv(device_id):
return None, "Das Gerät hat das Merkmal „Personalisierung möglich“ nicht."
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT d.organization_id, d.hostname, o.name, o.email_domaenen
FROM devices d JOIN organizations o ON o.id = d.organization_id
WHERE d.id = %s
""",
(device_id,),
)
row = cur.fetchone()
if row is None:
return None, "Das Gerät wurde nicht gefunden."
organization_id, hostname, org_name, domaenen = row
treffer = EMAIL_MUSTER.match(email)
if not treffer:
return None, "Die E-Mail-Adresse ist ungültig."
if domaenen and treffer.group(1).lower() not in domaenen:
return None, "Erlaubt sind nur Adressen der Einrichtung (" + ", ".join("@" + d for d in domaenen) + ")."
personalisierung_verfallene_markieren(cur)
# Eine neue Einladung ersetzt einen laufenden Vorgang; der alte
# Link wird damit ungültig.
cur.execute(
"""
UPDATE personalisierungen
SET status = 'zurueckgezogen', passwort_hash = NULL, bestaetigung_token_hash = NULL
WHERE device_id = %s AND status = ANY(%s)
""",
(device_id, list(PERSONALISIERUNG_LAUFEND)),
)
# Ohne "Bestehenden Zugang behalten" wird das bisherige Konto
# sofort gesperrt; entfernt wird es erst, wenn das neue angelegt
# ist (siehe record_persoenliche_konten_ergebnisse()).
if not bestehenden_behalten:
cur.execute(
"UPDATE personalisierungen SET gesperrt = TRUE WHERE device_id = %s AND status = 'angelegt'",
(device_id,),
)
token = secrets.token_urlsafe(32)
personalisierung_id = uuid4()
cur.execute(
"""
INSERT INTO personalisierungen
(id, device_id, organization_id, status, email, bestaetigung_token_hash,
bestaetigung_bis, bestehenden_behalten, erstellt_von)
VALUES (%s, %s, %s, 'eingeladen', %s, %s, %s, %s, %s)
RETURNING bestaetigung_bis
""",
(
personalisierung_id, device_id, organization_id, email, personalisierung_token_hash(token),
datetime.now(timezone.utc) + PERSONALISIERUNG_EINLADUNG_GUELTIG,
bestehenden_behalten, erstellt_von,
),
)
gueltig_bis = cur.fetchone()[0]
return {
"personalisierung_id": str(personalisierung_id),
"token": token,
"gueltig_bis": gueltig_bis.isoformat(),
"email": email,
"hostname": hostname,
"organisation": org_name,
}, None
def withdraw_personalisierung(device_id: str) -> bool:
with get_database_connection() as conn:
with conn.cursor() as cur:
personalisierung_verfallene_markieren(cur)
cur.execute(
"""
UPDATE personalisierungen
SET status = 'zurueckgezogen', passwort_hash = NULL, bestaetigung_token_hash = NULL
WHERE device_id = %s AND status = ANY(%s)
""",
(device_id, list(PERSONALISIERUNG_LAUFEND)),
)
return cur.rowcount > 0
def _einladung(cur, token: str, sperren: bool = False):
personalisierung_verfallene_markieren(cur)
cur.execute(
"""
SELECT p.id, p.device_id, p.email, d.hostname, o.name
FROM personalisierungen p
JOIN devices d ON d.id = p.device_id
JOIN organizations o ON o.id = p.organization_id
WHERE p.bestaetigung_token_hash = %s AND p.status = 'eingeladen'
""" + (" FOR UPDATE OF p" if sperren else ""),
(personalisierung_token_hash(token),),
)
return cur.fetchone()
def einladung_pruefen(token: str) -> dict | None:
"""Seite hinter dem Link: zeigt Einrichtung, Gerät und Namensvorschlag, verbraucht nichts."""
with get_database_connection() as conn:
with conn.cursor() as cur:
row = _einladung(cur, token)
if row is None:
return None
_pid, _device_id, email, hostname, org_name = row
return {"email": email, "hostname": hostname, "organisation": org_name, "vorschlag": anmeldename_vorschlag(email)}
def einladung_einloesen(token: str, anmeldename: str, passwort_hash: str):
"""
Gibt (ergebnis, fehler) zurück. Unbekannte, verbrauchte und abgelaufene
Links ergeben dieselbe Meldung.
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
row = _einladung(cur, token, sperren=True)
if row is None:
return None, "Der Link ist ungültig, schon verwendet oder abgelaufen."
personalisierung_id, device_id, _email, hostname, org_name = row
fehler = anmeldename_fehler(anmeldename)
if fehler:
return None, fehler
cur.execute(
"SELECT 1 FROM personalisierungen WHERE device_id = %s AND status = 'angelegt' AND anmeldename = %s",
(device_id, anmeldename),
)
if cur.fetchone():
return None, "Diesen Anmeldenamen gibt es auf diesem Gerät schon."
cur.execute(
"""
UPDATE personalisierungen
SET status = 'bestaetigt', anmeldename = %s, passwort_hash = %s, bestaetigung_token_hash = NULL,
eingereicht_am = now(), bestaetigt_am = now()
WHERE id = %s
""",
(anmeldename, passwort_hash, personalisierung_id),
)
return {
"personalisierung_id": str(personalisierung_id),
"stand_token": personalisierung_stand_token(personalisierung_id),
"anmeldename": anmeldename,
"hostname": hostname,
"organisation": org_name,
}, None
def personalisierung_stand(personalisierung_id: str, token: str) -> dict | None:
erwartet = personalisierung_stand_token(personalisierung_id)
if not erwartet or not hmac.compare_digest(erwartet, (token or "").strip()):
return None
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT p.status, p.anmeldename, p.geraet_meldung, d.hostname, o.name
FROM personalisierungen p
JOIN devices d ON d.id = p.device_id
JOIN organizations o ON o.id = p.organization_id
WHERE p.id::text = %s
""",
(personalisierung_id,),
)
row = cur.fetchone()
if row is None:
return None
status, anmeldename, meldung, hostname, org_name = row
return {"status": status, "anmeldename": anmeldename, "geraet_meldung": meldung, "hostname": hostname, "organisation": org_name}
def set_email_domaenen(organization_id: str, domaenen: list[str]) -> bool:
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute("UPDATE organizations SET email_domaenen = %s WHERE id = %s", (domaenen, organization_id))
return cur.rowcount > 0
def fetch_persoenliche_konten_fuer_agent(device_id: str) -> list[dict]:
"""
Soll-Zustand der persönlichen Konten für den Check-in (Schritt 3). Je
Konto genau eine Aktion: anlegen (mit Passwort-Hash), aktiv, sperren
oder entfernen. Konten, die hier fehlen, fasst die Rolle nicht an.
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
personalisierung_verfallene_markieren(cur)
cur.execute(
"""
SELECT id, status, anmeldename, passwort_hash, gesperrt, entfernen,
reset_id, reset_passwort_hash, reset_aendern
FROM personalisierungen
WHERE device_id = %s AND status IN ('bestaetigt', 'angelegt')
ORDER BY erstellt_am
""",
(device_id,),
)
rows = cur.fetchall()
konten = []
for pid, status, anmeldename, passwort_hash, gesperrt, entfernen, reset_id, reset_hash, reset_aendern in rows:
if status == "bestaetigt":
if not passwort_hash:
continue
konten.append({"id": str(pid), "anmeldename": anmeldename, "aktion": "anlegen", "passwort_hash": passwort_hash})
else:
aktion = "entfernen" if entfernen else "sperren" if gesperrt else "aktiv"
konto = {"id": str(pid), "anmeldename": anmeldename, "aktion": aktion}
# Passwort-Reset der Verwaltung (Migration 0066): einmalig, bis
# das Gerät die reset_id zurückmeldet.
if reset_hash and not entfernen:
konto["passwort_reset"] = {
"id": str(reset_id),
"passwort_hash": reset_hash,
"aendern": reset_aendern,
}
konten.append(konto)
return konten
PERSOENLICHE_KONTEN_ERGEBNISSE = ("angelegt", "aktiv", "gesperrt", "entfernt", "fehler")
def record_persoenliche_konten_ergebnisse(device_id: str, ergebnisse: list) -> None:
with get_database_connection() as conn:
with conn.cursor() as cur:
for eintrag in ergebnisse:
meldung = (eintrag.meldung or "")[:1000] or None
cur.execute(
"SELECT status, bestehenden_behalten FROM personalisierungen WHERE id = %s AND device_id = %s FOR UPDATE",
(eintrag.id, device_id),
)
row = cur.fetchone()
if row is None:
continue
status, behalten = row
if eintrag.ergebnis == "angelegt" and status == "bestaetigt":
cur.execute(
"""
UPDATE personalisierungen
SET status = 'angelegt', angelegt_am = now(), passwort_hash = NULL, geraet_meldung = NULL
WHERE id = %s
""",
(eintrag.id,),
)
if not behalten:
cur.execute(
"""
UPDATE personalisierungen SET entfernen = TRUE, gesperrt = TRUE
WHERE device_id = %s AND status = 'angelegt' AND id <> %s
""",
(device_id, eintrag.id),
)
elif eintrag.ergebnis == "entfernt" and status == "angelegt":
cur.execute(
"UPDATE personalisierungen SET status = 'beendet', beendet_am = now(), geraet_meldung = NULL WHERE id = %s",
(eintrag.id,),
)
elif eintrag.ergebnis == "fehler" and status == "bestaetigt":
cur.execute(
"""
UPDATE personalisierungen
SET status = 'fehlgeschlagen', passwort_hash = NULL, geraet_meldung = %s
WHERE id = %s
""",
(meldung, eintrag.id),
)
elif eintrag.ergebnis == "fehler":
cur.execute("UPDATE personalisierungen SET geraet_meldung = %s WHERE id = %s", (meldung, eintrag.id))
elif eintrag.ergebnis in ("aktiv", "gesperrt"):
cur.execute("UPDATE personalisierungen SET geraet_meldung = NULL WHERE id = %s", (eintrag.id,))
# Nur der gemeldete Reset ist erledigt; ein neuerer (andere
# reset_id) bleibt stehen.
if eintrag.passwort_reset_id and eintrag.ergebnis in ("aktiv", "gesperrt"):
cur.execute(
"""
UPDATE personalisierungen
SET reset_passwort_hash = NULL, reset_gesetzt_am = now()
WHERE id = %s AND reset_id::text = %s AND reset_passwort_hash IS NOT NULL
""",
(eintrag.id, eintrag.passwort_reset_id),
)
def set_personalisierung_gesperrt(device_id: str, personalisierung_id: str, gesperrt: bool) -> bool:
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE personalisierungen SET gesperrt = %s
WHERE id = %s AND device_id = %s AND status = 'angelegt' AND NOT entfernen
""",
(gesperrt, personalisierung_id, device_id),
)
return cur.rowcount > 0
def set_personalisierung_passwort_reset(
device_id: str, personalisierung_id: str, passwort_hash: str, aendern: bool, angefordert_von: str | None
) -> bool:
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE personalisierungen
SET reset_id = %s, reset_passwort_hash = %s, reset_aendern = %s,
reset_angefordert_am = now(), reset_angefordert_von = %s, reset_gesetzt_am = NULL
WHERE id = %s AND device_id = %s AND status = 'angelegt' AND NOT entfernen
""",
(uuid4(), passwort_hash, aendern, angefordert_von, personalisierung_id, device_id),
)
return cur.rowcount > 0
def personalisierungen_beenden_bei_neuinstallation(cur, device_id) -> None:
"""
ADR-0041: Der Fingerprint ist hardwarebasiert, ein neu installiertes
Gerät kommt als dasselbe zurück - ohne die Konten von vorher.
"""
cur.execute(
"""
UPDATE personalisierungen
SET status = 'beendet', beendet_am = now(), passwort_hash = NULL, bestaetigung_token_hash = NULL,
reset_passwort_hash = NULL
WHERE device_id = %s AND (status = ANY(%s) OR status = 'angelegt')
""",
(device_id, list(PERSONALISIERUNG_LAUFEND)),
)
class PersoenlichesKontoErgebnis(BaseModel):
id: str
ergebnis: str
meldung: str | None = None
# Gesetzt, wenn die Rolle den Passwort-Reset mit dieser ID ausgeführt hat.
passwort_reset_id: str | None = None
class PasswortResetRequest(BaseModel):
passwort_hash: str
aendern: bool = False
angefordert_von: str | None = None
class AgentPersoenlicheKontenRequest(BaseModel):
device_id: str
ergebnisse: list[PersoenlichesKontoErgebnis]
class SetGesperrtRequest(BaseModel):
gesperrt: bool
class CreatePersonalisierungRequest(BaseModel):
email: str
bestehenden_behalten: bool = False
erstellt_von: str | None = None
class EinladungRequest(BaseModel):
token: str
anmeldename: str | None = None
passwort_hash: str | None = None
class StandRequest(BaseModel):
id: str
token: str
class VergessenRequest(BaseModel):
device_id: str
token: str
# Seit dem QR-Code am Anmeldebildschirm (Ergänzung 25.10.2026) nennt der
# Link das Konto, dessen Anmeldung fehlschlug; ohne Angabe das zuletzt
# eingerichtete wie beim alten Kiosk.
anmeldename: str | None = None
class NeuesPasswortRequest(BaseModel):
token: str
passwort_hash: str | None = None
class SetEmailDomaenenRequest(BaseModel):
domaenen: list[str]
@app.get("/api/v1/devices/{device_id}/personalisierung")
def get_personalisierung_geraet(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, "personalisierungen": fetch_personalisierungen_geraet(device_id)}
@app.post("/api/v1/devices/{device_id}/personalisierung")
def post_personalisierung_einladung(
device_id: str,
payload: CreatePersonalisierungRequest,
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, fehler = create_personalisierung_einladung(
device_id, payload.email.strip(), payload.bestehenden_behalten, payload.erstellt_von
)
if fehler:
return {"success": False, "error": "abgelehnt", "message": fehler}
return {"success": True, **ergebnis}
@app.post("/api/v1/devices/{device_id}/personalisierung/zurueckziehen")
def post_personalisierung_zurueckziehen(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": withdraw_personalisierung(device_id)}
@app.get("/api/v1/organizations/{organization_id}/personalisierungen")
def get_personalisierungen_organisation(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, "geraete": fetch_personalisierungen_organisation(organization_id)}
@app.post("/api/v1/devices/{device_id}/personalisierung/{personalisierung_id}/gesperrt")
def post_personalisierung_gesperrt(
device_id: str,
personalisierung_id: str,
payload: SetGesperrtRequest,
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": set_personalisierung_gesperrt(device_id, personalisierung_id, payload.gesperrt)}
@app.post("/api/v1/devices/{device_id}/personalisierung/{personalisierung_id}/passwort-reset")
def post_personalisierung_passwort_reset(
device_id: str,
personalisierung_id: str,
payload: PasswortResetRequest,
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.passwort_hash.startswith("$6$"):
return {"success": False, "error": "invalid_request", "message": "Kein SHA-512-crypt-Hash."}
ok = set_personalisierung_passwort_reset(
device_id, personalisierung_id, payload.passwort_hash, payload.aendern, payload.angefordert_von
)
if not ok:
return {"success": False, "error": "not_found", "message": "Kein eingerichtetes Konto mit dieser ID."}
return {"success": True}
@app.post("/api/v1/agent/persoenliche-konten")
def agent_persoenliche_konten(
payload: AgentPersoenlicheKontenRequest,
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."}
if not verify_agent_secret(payload.device_id, authorization.removeprefix("Bearer ")):
return {"success": False, "error": "unauthorized", "message": "Ungültiges Agent-Secret."}
for eintrag in payload.ergebnisse:
if eintrag.ergebnis not in PERSOENLICHE_KONTEN_ERGEBNISSE:
return {"success": False, "error": "invalid_request", "message": f"Unbekanntes Ergebnis: {eintrag.ergebnis}"}
try:
UUID(eintrag.id)
except ValueError:
return {"success": False, "error": "invalid_request", "message": "Ungültige Konto-ID."}
record_persoenliche_konten_ergebnisse(payload.device_id, payload.ergebnisse)
return {"success": True}
@app.post("/api/v1/personalisierung/einladung")
def post_personalisierung_einladung_link(payload: EinladungRequest, authorization: str | None = Header(default=None)):
"""Ohne passwort_hash nur prüfen (Seite hinter dem Link), mit Name und Hash einlösen."""
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
if payload.passwort_hash is None:
ergebnis = einladung_pruefen(payload.token)
if ergebnis is None:
return {"success": False, "error": "ungueltig", "message": "Der Link ist ungültig, schon verwendet oder abgelaufen."}
return {"success": True, **ergebnis}
if not ROOT_PASSWORD_HASH_PATTERN.match(payload.passwort_hash):
return {"success": False, "error": "invalid_request", "message": "Ungültiger Passwort-Hash."}
ergebnis, fehler = einladung_einloesen(payload.token, (payload.anmeldename or "").strip(), payload.passwort_hash)
if fehler:
return {"success": False, "error": "abgelehnt", "message": fehler}
return {"success": True, **ergebnis}
@app.post("/api/v1/personalisierung/stand")
def post_personalisierung_stand(payload: StandRequest, 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 = personalisierung_stand(payload.id, payload.token)
if ergebnis is None:
return {"success": False, "error": "ungueltig", "message": "Unbekannter Vorgang."}
return {"success": True, **ergebnis}
@app.post("/api/v1/personalisierung/vergessen/pruefen")
def post_vergessen_pruefen(payload: VergessenRequest, 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 = vergessen_pruefen(payload.device_id, payload.token, payload.anmeldename)
if ergebnis is None:
return {"success": False, "error": "kein_konto", "message": "Auf diesem Gerät ist kein persönliches Konto eingerichtet."}
return {"success": True, **ergebnis}
@app.post("/api/v1/personalisierung/vergessen/anfordern")
def post_vergessen_anfordern(payload: VergessenRequest, 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, fehler = vergessen_anfordern(payload.device_id, payload.token, payload.anmeldename)
if ergebnis is None:
return {"success": False, "error": "abgelehnt", "message": fehler}
return {"success": True, **ergebnis}
class VergessenPerAdresseRequest(BaseModel):
email: str
@app.post("/api/v1/personalisierung/vergessen/per-adresse")
def post_vergessen_per_adresse(payload: VergessenPerAdresseRequest, 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, "links": vergessen_per_adresse(payload.email)}
@app.post("/api/v1/personalisierung/neues-passwort")
def post_neues_passwort(payload: NeuesPasswortRequest, authorization: str | None = Header(default=None)):
"""Ohne passwort_hash nur prüfen (Seite hinter dem Link), mit Hash setzen."""
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
if payload.passwort_hash is None:
ergebnis = neues_passwort_pruefen(payload.token)
elif not payload.passwort_hash.startswith("$6$"):
return {"success": False, "error": "invalid_request", "message": "Kein SHA-512-crypt-Hash."}
else:
ergebnis = neues_passwort_setzen(payload.token, payload.passwort_hash)
if ergebnis is None:
return {"success": False, "error": "ungueltig", "message": "Der Link ist ungültig, schon verwendet oder abgelaufen."}
return {"success": True, **ergebnis}
@app.put("/api/v1/organizations/{organization_id}/email-domaenen")
def put_email_domaenen(
organization_id: str,
payload: SetEmailDomaenenRequest,
authorization: str | None = Header(default=None),
):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
domaenen, fehler = email_domaenen_normalisieren(payload.domaenen)
if fehler:
return {"success": False, "error": "invalid_request", "message": fehler}
if not set_email_domaenen(organization_id, domaenen):
return {"success": False, "error": "organization_not_found", "message": "Die Organisation wurde nicht gefunden."}
return {"success": True, "domaenen": domaenen}
@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
device_conflict = False
if success:
hostname = resolve_hostname_for_activation(
organization_id, payload.device_fingerprint, payload.hostname
)
try:
device, device_created = load_or_create_device(
organization_id,
payload.device_fingerprint,
hostname,
)
except RuntimeError:
# Bare-Metal-Fund (21.09.2026): dieselbe physische Hardware
# (device_fingerprint bleibt stabil, anders als bei VM-Testumgebungen
# mit Snapshot-Reset) mit einem Aktivierungscode einer ANDEREN
# Organisation erneut aktiviert - load_or_create_device() wirft dafuer
# bewusst einen RuntimeError, der bisher ungefangen bis zu FastAPI
# durchschlug (HTTP 500 statt einer klaren, erwarteten Ablehnung).
success = False
device_conflict = True
else:
hardware_snapshot_id = store_hardware_snapshot(
device["id"],
payload.hardware,
)
if activation is not None:
mark_activation_code_used(payload.activation_code)
if enrollment_session is not None:
consume_enrollment_session(enrollment_session["session_id"])
if enrollment_session.get("ziel_oe_id"):
geraet_in_ziel_oe(device["id"], enrollment_session["ziel_oe_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 device_conflict:
return {
"success": False,
"error": "device_belongs_to_other_organization",
"message": "Dieses Gerät ist bereits einer anderen Organisation zugeordnet."
}
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"]
]
# GUI-Runde 09.10.2026 (Punkt 8): Bei einem Medium mit Ziel-OE gilt
# deren Workspace (Schritt F) - die Installer-Übersicht ("Workspace:
# ...", 25_installation_confirm.sh) zeigte bisher den des Profils.
# Gleiche Distro-Regel wie geraete_basis_herkunft(): passt die Distro
# nicht, bleibt es beim Workspace aus der Installation.
if enrollment_session.get("ziel_oe_id") and templates:
with get_database_connection() as conn:
with conn.cursor() as cur:
oe_ws = oe_kette_workspace(cur, enrollment_session["ziel_oe_id"])
if oe_ws and (oe_ws[2] is None or oe_ws[2] == templates[0]["backend"]["id"]):
templates[0] = {
**templates[0],
"workspace": {"id": oe_ws[0], "name": oe_ws[1]},
"workspace_herkunft": "ziel_oe",
}
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"])
),
# Kunden-ISO-Test 03.10.2026: pro Installationsmedium abschaltbare
# Rueckfrage bei bereits installierten Geraeten (siehe
# 25_installation_confirm.sh) - fuer das bewusste Neuaufsetzen
# ganzer Raeume.
"reinstall_without_confirmation": bool(
enrollment_session and enrollment_session.get("neuinstallation_ohne_rueckfrage")
),
},
"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(
"""
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),
)
# Schritt F: dieselbe Grundausstattung, die der Agent später
# anwendet - liegt das Gerät (z. B. über die Ziel-OE des Mediums)
# in einer OE mit Workspace, gilt deren Workspace, sonst wie
# bisher der Workspace des Profils.
basis = geraete_basis(cur, payload.device_id)
if basis is not None:
version_id, _backend = basis
blueprints = version_blueprints(cur, version_id, backend_id)
cur.execute(
"SELECT w.key FROM workspace_versionen v JOIN workspaces w ON w.id = v.workspace_id WHERE v.id = %s",
(version_id,),
)
key_row = cur.fetchone()
if key_row:
workspace_key = key_row[0]
else:
blueprints = version_blueprints(cur, veroeffentlichte_version_id(cur, workspace_id), backend_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("/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
UNION ALL
SELECT icon_content_type FROM gruppen_webapps WHERE icon_filename = %s
UNION ALL
SELECT icon_content_type FROM webapp_templates WHERE icon_filename = %s
LIMIT 1
""",
(safe_name, safe_name, safe_name),
)
row = cur.fetchone()
# Vorlagen-Icons fehlten hier bis 09.10.2026 und kamen als
# application/octet-stream.
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,
agent_sha256 = COALESCE(%(agent_sha256)s, agent_sha256),
ansible_weg = COALESCE(%(ansible_weg)s, ansible_weg)
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,
"agent_sha256": (
payload.agent_sha256
if payload.agent_sha256 and re.fullmatch(r"[0-9a-f]{64}", payload.agent_sha256)
else None
),
"ansible_weg": payload.ansible_weg if payload.ansible_weg in ANSIBLE_WEGE 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_repo_fuer_geraet(payload.device_id),
# ADR-0046: Stand als Archiv von anode. Neuere Agenten nehmen ihn
# statt "ansible_repo", ältere ignorieren das Feld.
"ansible_stand": ansible_stand_fuer_geraet(payload.device_id),
# Persönliche Konten (ADR-0041, Schritt 3). Ältere Agenten ignorieren
# das Feld; die Rolle fasst nur hier genannte Konten an.
"persoenliche_konten": fetch_persoenliche_konten_fuer_agent(payload.device_id),
"poll_interval_seconds": effektives_intervall,
}
@app.get("/api/v1/agent/ansible/{datei}")
def agent_ansible_archiv(
datei: str,
authorization: str | None = Header(default=None),
x_tuxflotte_geraet: str | None = Header(default=None),
):
"""Archiv eines ansible-content-Stands (ADR-0046) - nur ein Stand, der der
Ausrollstufe dieses Geräts zusteht."""
device_id = _geraet_aus_kopfzeilen(authorization, x_tuxflotte_geraet)
treffer = re.fullmatch(r"([0-9a-f]{40})\.tar\.gz", datei)
stufe = ausrollstufe_fuer_geraet(device_id)
with _spiegel_lock:
erlaubt = _spiegel_staende.get(stufe, {}).get("erlaubt", [])
if not treffer or treffer.group(1) not in erlaubt:
raise HTTPException(status_code=404, detail="Unbekannter Stand.")
archiv = SPIEGEL_DIR / "archive" / stufe / datei
if not archiv.is_file():
raise HTTPException(status_code=404, detail="Unbekannter Stand.")
return FileResponse(archiv, media_type="application/gzip")
def freigegebene_agent_fassung(stufe: str) -> str | None:
"""Commit aus roles/agent/defaults/main.yml im aktuellen Stand der Stufe
(tuxflotte_agent_commit, ADR-0044). None, wenn nicht ermittelbar."""
with _spiegel_lock:
stand = _spiegel_staende.get(stufe, {}).get("commit")
if not stand:
return None
try:
defaults = _spiegel_git("-C", str(SPIEGEL_DIR / f"{stufe}.git"), "show", f"{stand}:roles/agent/defaults/main.yml")
except (subprocess.SubprocessError, OSError):
return None
treffer = re.search(r'^tuxflotte_agent_commit:\s*"([0-9a-f]{40})"', defaults, re.MULTILINE)
return treffer.group(1) if treffer else None
@app.get("/api/v1/agent/fassung/{commit}/{datei}")
def agent_fassung(
commit: str,
datei: str,
authorization: str | None = Header(default=None),
x_tuxflotte_geraet: str | None = Header(default=None),
):
"""Eine Datei des Agenten in einer bestimmten Fassung (ADR-0046) - nur
Commits auf dem Hauptzweig von provisioning-agent, nur feste Dateien.
"freigegeben" statt eines Commits: die Fassung, die die Rolle "agent" in
der Ausrollstufe dieses Geräts festlegt (Neuinstallation, postinstall.sh)."""
device_id = _geraet_aus_kopfzeilen(authorization, x_tuxflotte_geraet)
if commit == "freigegeben":
commit = freigegebene_agent_fassung(ausrollstufe_fuer_geraet(device_id)) or ""
pfad = SPIEGEL_DIR / "agent.git"
if not re.fullmatch(r"[0-9a-f]{40}", commit) or datei not in AGENT_FASSUNG_DATEIEN or not (pfad / "HEAD").exists():
raise HTTPException(status_code=404, detail="Unbekannte Fassung.")
try:
_spiegel_git("-C", str(pfad), "merge-base", "--is-ancestor", commit, "HEAD")
inhalt = _spiegel_git("-C", str(pfad), "show", f"{commit}:{datei}", binaer=True)
except (subprocess.SubprocessError, OSError):
raise HTTPException(status_code=404, detail="Unbekannte Fassung.")
return Response(content=inhalt, media_type="text/plain; charset=utf-8")
@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 = {}
# Standardbrowser- und Org-Ressourcen-Auftrag sind keine Merkmale
# und haben keine Ereigniszeile (siehe STANDARDBROWSER_AUFTRAG,
# ORG_RESSOURCEN_AUFTRAG).
payload.results = [
e for e in payload.results
if e.merkmal not in (STANDARDBROWSER_AUFTRAG, ORG_RESSOURCEN_AUFTRAG)
]
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)
# Kunden-ISO-Test 02.10.2026 (R6): der automatische Debug-Modus beim
# allerersten agent/bootstrap (siehe dort) greift bei der
# Neuinstallation eines schon bekannten Geraets nicht - dann blieb es
# beim 6h-Poll-Intervall. Jede abgeschlossene Installation schaltet ihn
# deshalb ebenfalls ein, damit Nachzuegler-Auftraege zeitnah ankommen.
if payload.event_type == "installation_completed":
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE devices SET debug_mode_until = %s WHERE id = %s",
(datetime.now(timezone.utc) + timedelta(hours=DEBUG_MODE_DURATION_HOURS), device_id),
)
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.",
}
if not kundengruppe_gueltig(payload.kundengruppe_id):
return {"success": False, "error": "invalid_request", "message": "Unbekannte Kundengruppe."}
return {"success": True, "organization": create_organization(payload.name, payload.kundengruppe_id)}
@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}
class OrgWlanRequest(BaseModel):
ssid: str = ""
passwort: str = ""
offen: bool = False
@app.get("/api/v1/organizations/{organization_id}/org-wlan")
def get_org_wlan_endpoint(organization_id: str, mit_passwort: bool = False, 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, "org_wlan": fetch_org_wlan(organization_id, mit_passwort)}
@app.put("/api/v1/organizations/{organization_id}/org-wlan")
def put_org_wlan_endpoint(organization_id: str, payload: OrgWlanRequest, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
fehler = set_org_wlan(organization_id, payload.ssid, payload.passwort, payload.offen)
if fehler:
return {"success": False, "error": "ungueltig", "message": fehler}
return {"success": True, "org_wlan": fetch_org_wlan(organization_id)}
class SetAusrollstufeRequest(BaseModel):
ausrollstufe: str
@app.put("/api/v1/organizations/{organization_id}/ausrollstufe")
def put_ausrollstufe(
organization_id: str,
payload: SetAusrollstufeRequest,
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.ausrollstufe not in AUSROLLSTUFEN:
return {"success": False, "error": "invalid_request", "message": "Unbekannte Ausrollstufe."}
if not set_ausrollstufe(organization_id, payload.ausrollstufe):
return {"success": False, "error": "organization_not_found", "message": "Die Organisation wurde nicht gefunden."}
return {"success": True}
class SetKundengruppeRequest(BaseModel):
kundengruppe_id: str | None = None
@app.put("/api/v1/organizations/{organization_id}/kundengruppe")
def put_kundengruppe(
organization_id: str,
payload: SetKundengruppeRequest,
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 kundengruppe_gueltig(payload.kundengruppe_id):
return {"success": False, "error": "invalid_request", "message": "Unbekannte Kundengruppe."}
if not set_kundengruppe(organization_id, payload.kundengruppe_id):
return {"success": False, "error": "organization_not_found", "message": "Die Organisation wurde nicht gefunden."}
return {"success": True}
@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}/aktivitaeten")
def list_aktivitaeten(organization_id: str, limit: int = AKTIVITAETEN_LIMIT, 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, "aktivitaeten": fetch_aktivitaeten(organization_id, max(1, min(limit, 500)))}
@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,
payload: CreateActivationCodeRequest | None = None,
authorization: str | None = Header(default=None),
):
if not require_service_token(authorization):
return {
"success": False,
"error": "unauthorized",
"message": "Fehlendes oder ungültiges Service-Token.",
}
bezeichnung = (payload.bezeichnung if payload else None) or None
gueltig_tage = payload.gueltig_tage if payload else None
if gueltig_tage is not None and not (1 <= gueltig_tage <= 3650):
return {"success": False, "error": "invalid_gueltigkeit", "message": "Gültigkeit muss zwischen 1 und 3650 Tagen liegen."}
return {"success": True, "activation_code": create_activation_code(organization_id, bezeichnung, gueltig_tage)}
@app.post("/api/v1/organizations/{organization_id}/activation-codes/{code}/status")
def set_activation_code_status_endpoint(
organization_id: str,
code: str,
payload: ActivationCodeStatusRequest,
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_activation_code_active(organization_id, code, payload.active):
return {"success": False, "error": "not_found", "message": "Aktivierungscode nicht gefunden."}
return {"success": True}
@app.delete("/api/v1/organizations/{organization_id}/activation-codes/{code}")
def delete_activation_code_endpoint(
organization_id: str,
code: 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_activation_code(organization_id, code):
return {"success": False, "error": "not_found", "message": "Aktivierungscode nicht gefunden."}
return {"success": True}
@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.post("/api/v1/merkmale/{merkmal_id}/icon/aktualisieren")
def merkmal_icon_aktualisieren_endpoint(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."}
ergebnis = aktualisiere_merkmal_icon(merkmal_id)
if ergebnis is None:
return {"success": False, "error": "not_found", "message": "Merkmal wurde nicht gefunden."}
return {"success": True, **ergebnis}
@app.get("/merkmal-icons/{filename}")
def get_merkmal_icon(filename: str):
# Gleiches Muster wie get_webapp_icon() oben (Path-Traversal-Schutz,
# unauthentifiziert - wird vom Kundenplattform-Proxy ohne Session-
# Kontext abgerufen).
safe_name = Path(filename).name
if safe_name != filename:
raise HTTPException(status_code=400, detail="invalid_filename")
file_path = MERKMAL_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 merkmale 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.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, payload.art),
}
@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/assistent")
def create_workspace_assistent_endpoint(
payload: CreateWorkspaceAssistentRequest, 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, fehler = create_workspace_mit_inhalt(
payload.name, payload.description, payload.organization_id, payload.backend_id,
payload.merkmal_ids, payload.webapp_template_ids,
)
if fehler:
return {"success": False, "error": fehler[0], "message": fehler[1]}
return {"success": True, "workspace": workspace}
@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, payload.backend_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/kundengruppen")
def list_kundengruppen(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, "kundengruppen": fetch_kundengruppen()}
@app.put("/api/v1/workspaces/{workspace_id}")
def update_workspace_endpoint(
workspace_id: str, payload: WorkspaceAktualisierenRequest, 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.name.strip():
return {"success": False, "error": "invalid_request", "message": "Bitte einen Namen angeben."}
if not workspace_aktualisieren(workspace_id, payload.name, payload.description):
return {"success": False, "error": "not_found", "message": "Workspace wurde nicht gefunden."}
return {"success": True}
@app.delete("/api/v1/workspaces/{workspace_id}")
def delete_workspace_endpoint(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."}
_ok, fehler = workspace_loeschen(workspace_id)
if fehler:
return {"success": False, "error": fehler[0], "message": fehler[1]}
return {"success": True}
@app.get("/api/v1/workspaces/{workspace_id}/versionen")
def list_workspace_versionen(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, "versionen": workspace_versionen(workspace_id)}
@app.post("/api/v1/workspaces/{workspace_id}/entwurf")
def create_workspace_entwurf(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."}
vid = workspace_entwurf_anlegen(workspace_id)
if vid is None:
return {"success": False, "error": "not_found", "message": "Workspace wurde nicht gefunden."}
return {"success": True, "version_id": vid}
@app.delete("/api/v1/workspaces/{workspace_id}/entwurf")
def delete_workspace_entwurf(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."}
if not workspace_entwurf_verwerfen(workspace_id):
return {"success": False, "error": "kein_entwurf", "message": "Es gibt keinen Entwurf."}
return {"success": True}
@app.post("/api/v1/workspaces/{workspace_id}/veroeffentlichen")
def publish_workspace_entwurf(
workspace_id: str, payload: WorkspaceVeroeffentlichenRequest, authorization: str | None = Header(default=None)
):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
nummer, fehler = workspace_veroeffentlichen(workspace_id, payload.notiz)
if fehler:
return {"success": False, "error": fehler[0], "message": fehler[1]}
return {"success": True, "nummer": nummer}
@app.get("/api/v1/workspaces/{workspace_id}/nutzung")
def get_workspace_nutzung(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, "nutzung": workspace_nutzung(workspace_id)}
@app.get("/api/v1/workspaces/{workspace_id}/wirkung")
def get_workspace_wirkung(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."}
wirkung = workspace_wirkung(workspace_id)
if wirkung is None:
return {"success": False, "error": "kein_entwurf", "message": "Es gibt keinen Entwurf."}
return {"success": True, "wirkung": wirkung}
@app.put("/api/v1/workspaces/{workspace_id}/vorlage")
def set_workspace_vorlage(
workspace_id: str, payload: WorkspaceVorlageRequest, authorization: str | None = Header(default=None)
):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
_ok, fehler = workspace_vorlage_setzen(
workspace_id, payload.ist_vorlage, payload.sichtbarkeit, payload.kundengruppe_ids, payload.organization_ids
)
if fehler:
return {"success": False, "error": fehler[0], "message": fehler[1]}
return {"success": True}
@app.get("/api/v1/workspaces/{workspace_id}/kopie-pruefung")
def check_workspace_kopie(workspace_id: str, backend_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."}
fehlend = workspace_kopie_pruefen(workspace_id, backend_id)
if fehlend is None:
return {"success": False, "error": "not_found", "message": "Workspace wurde nicht gefunden."}
return {"success": True, "weggefallen": fehlend}
@app.post("/api/v1/workspaces/{workspace_id}/kopie")
def copy_workspace_endpoint(
workspace_id: str, payload: WorkspaceKopieRequest, authorization: str | None = Header(default=None)
):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
kopie, fehler = workspace_kopieren(workspace_id, payload.name, payload.backend_id, payload.organization_id)
if fehler:
return {"success": False, "error": fehler[0], "message": fehler[1]}
return {"success": True, "workspace": kopie}
@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."}
workspace.update(workspace_freigaben(workspace_id))
return {"success": True, "workspace": workspace}
def _nur_im_entwurf(workspace_id: str, webapp_id: str | None = None, merkmal_id: str | None = None):
"""
Inhaltliche Änderungen gehen seit Schritt E (07.10.2026) nur in einen
Entwurf - die veröffentlichte Version ist eingefroren. Prüft zusätzlich,
dass eine Webapp zu diesem Entwurf gehört und ein neues Merkmal auf der
Distro des Workspace umgesetzt ist. Gibt eine Fehlerantwort oder None.
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
vid = entwurf_id(cur, workspace_id)
if vid is None:
return {"success": False, "error": "kein_entwurf",
"message": "Änderungen gehen nur in einem Entwurf - bitte zuerst einen Entwurf anlegen."}
if webapp_id is not None:
cur.execute(
"SELECT 1 FROM workspace_webapps WHERE id = %s AND workspace_version_id = %s",
(webapp_id, vid),
)
if cur.fetchone() is None:
return {"success": False, "error": "not_found", "message": "Webapp gehört nicht zum Entwurf."}
if merkmal_id is not None:
cur.execute(
"""
SELECT m.name, w.backend_id IS NULL OR bp.id IS NOT NULL
FROM merkmale m JOIN workspaces w ON w.id = %s
LEFT JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = w.backend_id
WHERE m.id = %s
""",
(workspace_id, merkmal_id),
)
row = cur.fetchone()
if row is None:
return {"success": False, "error": "not_found", "message": "Merkmal wurde nicht gefunden."}
if not row[1]:
return {"success": False, "error": "distro_konflikt",
"message": f"{row[0]} ist auf der Distro dieses Workspace nicht umgesetzt."}
return None
@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."}
if fehler := _nur_im_entwurf(workspace_id, merkmal_id=merkmal_id):
return fehler
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."}
if fehler := _nur_im_entwurf(workspace_id):
return fehler
remove_merkmal_from_workspace(workspace_id, merkmal_id)
return {"success": True}
class SetStandardbrowserRequest(BaseModel):
browser: str | None = None
@app.put("/api/v1/workspaces/{workspace_id}/standardbrowser")
def put_workspace_standardbrowser(
workspace_id: str,
payload: SetStandardbrowserRequest,
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 fehler := set_workspace_standardbrowser(workspace_id, payload.browser or None):
return {"success": False, "error": "invalid_request", "message": fehler}
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."}
if fehler := _nur_im_entwurf(workspace_id):
return fehler
return {
"success": True,
"webapp": create_webapp(
workspace_id, payload.name, payload.url, payload.sort_order,
payload.kategorie_id, payload.kiosk_modus, payload.auf_desktop,
),
}
@app.put("/api/v1/workspaces/{workspace_id}/webapps/{webapp_id}/desktop")
def set_workspace_webapp_desktop(
workspace_id: str, webapp_id: str, payload: WebappDesktopRequest, authorization: str | None = Header(default=None),
):
"""Nur das Häkchen „auf dem Desktop“ umschalten - ohne Name, URL und
neuen Favicon-Abruf wie beim vollständigen PUT."""
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
if fehler := _nur_im_entwurf(workspace_id, webapp_id=webapp_id):
return fehler
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE workspace_webapps SET auf_desktop = %s, updated_at = now() WHERE id = %s",
(payload.auf_desktop, webapp_id),
)
return {"success": True, "auf_desktop": payload.auf_desktop}
@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."}
if fehler := _nur_im_entwurf(workspace_id, webapp_id=webapp_id):
return fehler
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."}
if fehler := _nur_im_entwurf(workspace_id, webapp_id=webapp_id):
return fehler
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, payload.kategorie_id)}
@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, payload.kategorie_id)}
@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."}
if fehler := _nur_im_entwurf(workspace_id):
return fehler
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()}
def _bereitstellungsvorlage_pruefen(payload) -> tuple[str | None, dict | None]:
"""Gemeinsame Prüfung für Anlegen und Bearbeiten (GUI-Runde 09.10.2026).
Rückgabe: (backend_id, fehler). Seit ADR-0038 hat ein Workspace genau
eine Distro - fehlt backend_id, gilt die des Workspace."""
if not payload.label.strip():
return None, {"success": False, "error": "invalid_request", "message": "Die Bezeichnung darf nicht leer sein."}
if payload.target_disk_rank < 1:
return None, {"success": False, "error": "invalid_request", "message": "target_disk_rank muss mindestens 1 sein."}
if payload.scheme not in ("single", "custom"):
return None, {"success": False, "error": "invalid_request", "message": "scheme muss 'single' oder 'custom' sein."}
if payload.scheme == "custom":
mountpoints = [p.mountpoint for p in payload.extra_partitions]
if len(mountpoints) != len(set(mountpoints)):
return None, {"success": False, "error": "invalid_request", "message": "extra_partitions enthält doppelte Mountpoints."}
if sum(p.percent for p in payload.extra_partitions) >= 100:
return None, {"success": False, "error": "invalid_request", "message": "Die Summe der Partitionsanteile muss unter 100 % bleiben."}
for p in payload.extra_partitions:
if not p.mountpoint.startswith("/"):
return None, {"success": False, "error": "invalid_request", "message": f"Mountpoint '{p.mountpoint}' muss mit '/' beginnen."}
if p.percent <= 0:
return None, {"success": False, "error": "invalid_request", "message": f"Der Anteil für '{p.mountpoint}' muss positiv sein."}
if p.filesystem not in ("ext4", "btrfs"):
return None, {"success": False, "error": "invalid_request", "message": f"Nicht unterstütztes Dateisystem für '{p.mountpoint}': {p.filesystem}"}
elif payload.extra_partitions:
return None, {"success": False, "error": "invalid_request", "message": "Eigene Partitionen gibt es nur bei scheme='custom'."}
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute("SELECT backend_id FROM workspaces WHERE id = %s", (payload.workspace_id,))
ws_row = cur.fetchone()
if ws_row is None:
return None, {"success": False, "error": "workspace_not_found", "message": "Der Workspace wurde nicht gefunden."}
workspace_backend = str(ws_row[0]) if ws_row[0] is not None else None
backend_id = payload.backend_id or workspace_backend
if backend_id is None:
return None, {"success": False, "error": "invalid_request", "message": "Der Workspace hat keine Distro - bitte eine Distro angeben."}
if workspace_backend is not None and workspace_backend != backend_id:
return None, {
"success": False, "error": "distro_konflikt",
"message": "Der Workspace gehört zu einer anderen Distro als das Profil (ein Workspace hat genau eine Distro).",
}
return backend_id, None
@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."}
backend_id, fehler = _bereitstellungsvorlage_pruefen(payload)
if fehler:
return fehler
vorlage = create_bereitstellungsvorlage(
organization_id,
payload.workspace_id,
backend_id,
payload.label,
payload.is_default,
payload.disk_encryption,
payload.root_filesystem,
payload.secure_boot_required,
payload.target_disk_rank,
payload.scheme,
[p.model_dump() for p in payload.extra_partitions],
)
return {"success": True, "bereitstellungsvorlage": vorlage}
def update_bereitstellungsvorlage(organization_id: str, vorlage_id: str, payload, backend_id: str) -> bool:
partitioning = {
"scheme": payload.scheme,
"root_filesystem": payload.root_filesystem,
"target_disk_rank": payload.target_disk_rank,
"extra_partitions": [p.model_dump() for p in payload.extra_partitions],
}
with get_database_connection() as conn:
with conn.cursor() as cur:
if payload.is_default:
cur.execute(
"UPDATE bereitstellungsvorlagen SET is_default = FALSE WHERE organization_id = %s AND is_default AND id <> %s",
(organization_id, vorlage_id),
)
cur.execute(
"""
UPDATE bereitstellungsvorlagen
SET workspace_id = %s, backend_id = %s, label = %s, is_default = %s,
disk_encryption = %s, partitioning = %s, secure_boot_required = %s, updated_at = now()
WHERE id = %s AND organization_id = %s
""",
(
payload.workspace_id, backend_id, payload.label.strip(), payload.is_default,
payload.disk_encryption, Jsonb(partitioning), payload.secure_boot_required,
vorlage_id, organization_id,
),
)
return cur.rowcount == 1
@app.put("/api/v1/organizations/{organization_id}/bereitstellungsvorlagen/{vorlage_id}")
def update_bereitstellungsvorlage_endpoint(
organization_id: str,
vorlage_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."}
backend_id, fehler = _bereitstellungsvorlage_pruefen(payload)
if fehler:
return fehler
if not update_bereitstellungsvorlage(organization_id, vorlage_id, payload, backend_id):
return {"success": False, "error": "not_found", "message": "Die Bereitstellungsvorlage wurde nicht gefunden."}
return {"success": True}
@app.delete("/api/v1/organizations/{organization_id}/bereitstellungsvorlagen/{vorlage_id}")
def delete_bereitstellungsvorlage_endpoint(
organization_id: str,
vorlage_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(
"""
SELECT (SELECT count(*) FROM assignments a JOIN devices d ON d.id = a.device_id
WHERE a.bereitstellungsvorlage_id = %(v)s AND d.archived_at IS NULL)
+ (SELECT count(*) FROM enrollment_sessions
WHERE bereitstellungsvorlage_id = %(v)s AND revoked_at IS NULL AND expires_at > now())
""",
{"v": vorlage_id},
)
(verweise,) = cur.fetchone()
if verweise:
return {
"success": False, "error": "in_benutzung",
"message": "Die Vorlage wird noch von aktiven Geräten oder gültigen Installationsmedien verwendet und kann nicht gelöscht werden.",
}
# Altlasten mit entfernen (09.10.2026): Zuordnungen archivierter
# Geräte und abgelaufene/widerrufene Medien - sonst blockieren die
# Fremdschlüssel. Ein archiviertes Gerät, das sich neu installiert,
# bekommt bei der Installation ohnehin eine neue Zuordnung.
cur.execute(
"""
DELETE FROM assignments a USING devices d
WHERE d.id = a.device_id AND a.bereitstellungsvorlage_id = %s AND d.archived_at IS NOT NULL
""",
(vorlage_id,),
)
cur.execute(
"""
DELETE FROM enrollment_sessions
WHERE bereitstellungsvorlage_id = %s AND (revoked_at IS NOT NULL OR expires_at <= now())
""",
(vorlage_id,),
)
cur.execute(
"DELETE FROM bereitstellungsvorlagen WHERE id = %s AND organization_id = %s",
(vorlage_id, organization_id),
)
if cur.rowcount != 1:
return {"success": False, "error": "not_found", "message": "Die Bereitstellungsvorlage wurde nicht gefunden."}
return {"success": True}
@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, fehler = create_enrollment_session(
organization_id, payload.bereitstellungsvorlage_id, payload.max_devices, payload.expires_at,
payload.neuinstallation_ohne_rueckfrage, payload.ziel_oe_id,
)
if fehler:
return {"success": False, "error": "ziel_oe_konflikt", "message": fehler}
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.delete("/api/v1/organizations/{organization_id}/enrollment-sessions/{session_id}")
def delete_enrollment_session_endpoint(
organization_id: str, 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."}
try:
UUID(session_id)
except ValueError:
return {"success": False, "error": "not_found", "message": "Installationsmedium wurde nicht gefunden."}
ergebnis = delete_enrollment_session(organization_id, session_id)
if ergebnis == "nicht_gefunden":
return {"success": False, "error": "not_found", "message": "Installationsmedium wurde nicht gefunden."}
if ergebnis == "noch_gueltig":
return {
"success": False, "error": "noch_gueltig",
"message": "Das Medium ist noch gültig. Deaktiviere es zuerst, dann kannst du es löschen.",
}
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."}
if payload.root_password_hash and not ROOT_PASSWORD_HASH_PATTERN.match(payload.root_password_hash):
return {"success": False, "error": "invalid_request", "message": "root_password_hash ist kein gültiger SHA-512-crypt-Hash."}
if payload.wifi_psk and "\n" in payload.wifi_psk:
return {"success": False, "error": "invalid_request", "message": "wifi_psk darf keinen Zeilenumbruch enthalten."}
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,
payload.root_password_hash, payload.offline_medium,
),
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
# 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.put("/api/v1/organisationseinheiten/{oe_id}/workspace")
def set_oe_workspace_endpoint(oe_id: str, payload: SetOeWorkspaceRequest, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
_ok, fehler = set_oe_workspace(oe_id, payload.workspace_id, payload.version_id, payload.automatisch)
if fehler:
return {"success": False, "error": fehler[0], "message": fehler[1]}
return {"success": True}
@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 not None and "konflikt" in oe:
return {"success": False, "error": "distro_konflikt", "message": oe["konflikt"]}
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."}
ergebnis = move_device_to_oe(device_id, payload.oe_id)
if isinstance(ergebnis, str):
return {"success": False, "error": "distro_konflikt", "message": ergebnis}
if not ergebnis:
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.get("/api/v1/org-ressourcen/typen")
def list_org_ressourcen_typen(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, "typen": ORG_RESSOURCEN_TYPEN}
@app.get("/api/v1/organizations/{organization_id}/ressourcen")
def list_org_ressourcen(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, **fetch_org_ressourcen(organization_id)}
@app.post("/api/v1/organizations/{organization_id}/ressourcen")
def create_org_ressource(organization_id: str, payload: OrgRessourceRequest, 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, fehler = speichere_org_ressource(
organization_id, None, payload.typ, payload.name, payload.daten,
payload.ganze_organisation, payload.oe_ids, payload.gruppe_ids,
)
if fehler:
return {"success": False, "error": "invalid", "message": fehler}
return {"success": True, "ressource": ergebnis}
@app.put("/api/v1/organizations/{organization_id}/ressourcen/{ressource_id}")
def update_org_ressource(
organization_id: str, ressource_id: str, payload: OrgRessourceRequest,
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, fehler = speichere_org_ressource(
organization_id, ressource_id, payload.typ, payload.name, payload.daten,
payload.ganze_organisation, payload.oe_ids, payload.gruppe_ids,
)
if fehler:
return {"success": False, "error": "invalid", "message": fehler}
return {"success": True, "ressource": ergebnis}
@app.delete("/api/v1/organizations/{organization_id}/ressourcen/{ressource_id}")
def delete_org_ressource(organization_id: str, ressource_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 loesche_org_ressource(organization_id, ressource_id):
return {"success": False, "error": "not_found", "message": "Die Ressource wurde nicht gefunden."}
return {"success": True}
@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.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 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}