9689 lines
399 KiB
Python
9689 lines
399 KiB
Python
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}
|