resolve_katalog_overrides() liefert jetzt zusätzlich quelle_id (konkrete oe_id/gruppe_id, die eine Auftragskatalog-Einstellung geliefert hat) statt nur den Herkunfts-Typ - Grundlage für anklickbare Herkunfts-Links im Kundenplattform-Auftragskatalog. fetch_auftragskatalog_listing() gibt quelle_id mit aus, fetch_auftragskatalog_state() (Agent-Pfad) ignoriert sie weiterhin. fetch_all_devices() (Admin-Geräteliste) bekommt denselben OE-Join wie fetch_devices_for_organization() letzte Session - Grundlage für OE-Spalte in der Admin-Geräteliste. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
3345 lines
119 KiB
Python
3345 lines
119 KiB
Python
from pathlib import Path
|
|
from datetime import datetime, timezone
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import os
|
|
import secrets
|
|
import subprocess
|
|
import threading
|
|
from uuid import UUID, uuid4
|
|
|
|
import psycopg
|
|
from psycopg.types.json import Jsonb
|
|
|
|
from fastapi import FastAPI, Header, Request
|
|
from pydantic import BaseModel
|
|
from fastapi.responses import FileResponse
|
|
|
|
|
|
LOG_PATH = Path("activation.log")
|
|
DATABASE_URL = os.environ.get("TUXFLOTTE_DATABASE_URL")
|
|
AGENT_POLL_INTERVAL_SECONDS = int(os.environ.get("TUXFLOTTE_AGENT_POLL_INTERVAL", "300"))
|
|
ANSIBLE_CONTENT_REPO = os.environ.get("TUXFLOTTE_ANSIBLE_CONTENT_REPO", "")
|
|
ADMIN_TOKEN = os.environ.get("TUXFLOTTE_ADMIN_TOKEN", "")
|
|
KUNDENPLATTFORM_TOKEN = os.environ.get("TUXFLOTTE_KUNDENPLATTFORM_TOKEN", "")
|
|
AGENT_REPORTABLE_EVENT_TYPES = {"applied", "apply_failed", "removed", "remove_failed"}
|
|
# Name der pro Organisation automatisch angelegten Standard-OE (siehe
|
|
# Konfigurationsgruppen-Plan) - Landeplatz fuer neu aktivierte Geraete.
|
|
STANDARD_OE_NAME = "Neue Geräte"
|
|
|
|
# Phase 4 des Self-Service-ISO-Plans (siehe tuxflotte-installer/scripts/
|
|
# build_customer_iso.sh). TUXFLOTTE_INSTALLER_DIR ist ein separat auf anode
|
|
# geklontes Repo (nicht Teil von provisioning-server selbst), MINT_ISO_PATH
|
|
# das einmalig von der offiziellen Downloadseite geholte Quellabbild.
|
|
TUXFLOTTE_INSTALLER_DIR = os.environ.get("TUXFLOTTE_INSTALLER_DIR", "/root/tuxflotte-installer")
|
|
# Absolut aufloesen: run_iso_build() startet build_customer_iso.sh mit
|
|
# cwd=TUXFLOTTE_INSTALLER_DIR, ein relativer Pfad wuerde also dort statt
|
|
# relativ zum eigenen Arbeitsverzeichnis (WorkingDirectory des Service)
|
|
# gesucht - genau das hat den ersten Live-Test auf anode fehlschlagen lassen.
|
|
MINT_ISO_PATH = str(Path(os.environ.get("TUXFLOTTE_MINT_ISO_PATH", "data/upstream/linuxmint-22.3-cinnamon-64bit.iso")).resolve())
|
|
# Aus demselben Grund wie MINT_ISO_PATH absolut aufloesen: der als $2 an
|
|
# build_customer_iso.sh uebergebene Ausgabepfad wird von xorriso relativ
|
|
# zum Subprocess-cwd (TUXFLOTTE_INSTALLER_DIR) interpretiert, nicht relativ
|
|
# zum eigenen Arbeitsverzeichnis.
|
|
ISO_BUILD_OUTPUT_DIR = Path(os.environ.get("TUXFLOTTE_ISO_BUILD_OUTPUT_DIR", "data/iso-builds")).resolve()
|
|
ISO_BUILD_TIMEOUT_SECONDS = 1800
|
|
|
|
app = FastAPI(title="Provisioning Activation Server", version="0.1.0")
|
|
|
|
|
|
class ActivationRequest(BaseModel):
|
|
activation_code: str
|
|
device_fingerprint: str
|
|
hostname: str | None = None
|
|
machine_id: str | None = None
|
|
client_version: str | None = None
|
|
hardware: dict
|
|
|
|
|
|
class ResolveRequest(BaseModel):
|
|
device_id: str
|
|
|
|
|
|
class AgentBootstrapRequest(BaseModel):
|
|
device_id: str
|
|
|
|
|
|
class AgentCheckinRequest(BaseModel):
|
|
device_id: str
|
|
|
|
|
|
class AgentReportEntry(BaseModel):
|
|
merkmal: str
|
|
event_type: str
|
|
detail: dict | None = None
|
|
|
|
|
|
class AgentReportRequest(BaseModel):
|
|
device_id: str
|
|
results: list[AgentReportEntry]
|
|
|
|
|
|
class AuftragSelectRequest(BaseModel):
|
|
optionen: dict = {}
|
|
|
|
|
|
class CreateOrganizationRequest(BaseModel):
|
|
name: str
|
|
|
|
|
|
class UpdateOrganizationRequest(BaseModel):
|
|
name: str
|
|
|
|
|
|
class CreateMerkmalRequest(BaseModel):
|
|
key: str
|
|
name: str
|
|
description: str | None = None
|
|
|
|
|
|
class UpdateMerkmalRequest(BaseModel):
|
|
name: str
|
|
description: str | None = None
|
|
kategorie_id: str | None = None
|
|
im_auftragskatalog: bool = False
|
|
|
|
|
|
class CreateKategorieRequest(BaseModel):
|
|
key: str
|
|
name: str
|
|
description: str | None = None
|
|
sort_order: int = 0
|
|
|
|
|
|
class CreateWorkspaceRequest(BaseModel):
|
|
key: str
|
|
name: str
|
|
description: str | None = None
|
|
organization_id: str | None = None
|
|
|
|
|
|
class CreateBereitstellungsvorlageRequest(BaseModel):
|
|
workspace_id: str
|
|
backend_id: str
|
|
label: str
|
|
is_default: bool = False
|
|
disk_encryption: bool = False
|
|
root_filesystem: str = "ext4"
|
|
secure_boot_required: bool = False
|
|
|
|
|
|
class CreateEnrollmentSessionRequest(BaseModel):
|
|
bereitstellungsvorlage_id: str
|
|
max_devices: int
|
|
expires_at: str
|
|
|
|
|
|
class RechargeEnrollmentSessionRequest(BaseModel):
|
|
additional_devices: int = 0
|
|
expires_at: str | None = None
|
|
|
|
|
|
class CreateIsoBuildRequest(BaseModel):
|
|
activation_code: str
|
|
wifi_ssid: str | None = None
|
|
wifi_psk: str | None = None
|
|
|
|
|
|
class CreateOrganisationseinheitRequest(BaseModel):
|
|
name: str
|
|
parent_id: str | None = None
|
|
|
|
|
|
class UpdateOrganisationseinheitRequest(BaseModel):
|
|
name: str
|
|
parent_id: str | None = None
|
|
|
|
|
|
class MoveDeviceOeRequest(BaseModel):
|
|
oe_id: str
|
|
|
|
|
|
class CreateGruppeRequest(BaseModel):
|
|
name: str
|
|
|
|
|
|
class UpdateGruppeRequest(BaseModel):
|
|
name: str
|
|
|
|
|
|
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."
|
|
)
|
|
cur.execute(
|
|
"""
|
|
UPDATE devices
|
|
SET
|
|
hostname = %s,
|
|
last_seen = CURRENT_TIMESTAMP
|
|
WHERE id = %s
|
|
RETURNING
|
|
id,
|
|
organization_id,
|
|
device_fingerprint,
|
|
hostname,
|
|
created_at,
|
|
last_seen
|
|
""",
|
|
(hostname, row[0]),
|
|
)
|
|
|
|
row = cur.fetchone()
|
|
created = False
|
|
else:
|
|
device_id = uuid4()
|
|
|
|
cur.execute(
|
|
"SELECT id FROM organisationseinheiten WHERE organization_id = %s AND ist_standard",
|
|
(organization_id,),
|
|
)
|
|
standard_oe_row = cur.fetchone()
|
|
standard_oe_id = standard_oe_row[0] if standard_oe_row is not None else None
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO devices (
|
|
id,
|
|
organization_id,
|
|
device_fingerprint,
|
|
hostname,
|
|
oe_id
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s)
|
|
RETURNING
|
|
id,
|
|
organization_id,
|
|
device_fingerprint,
|
|
hostname,
|
|
created_at,
|
|
last_seen
|
|
""",
|
|
(
|
|
device_id,
|
|
organization_id,
|
|
device_fingerprint,
|
|
hostname,
|
|
standard_oe_id,
|
|
),
|
|
)
|
|
|
|
row = cur.fetchone()
|
|
created = True
|
|
|
|
device = {
|
|
"id": str(row[0]),
|
|
"organization_id": str(row[1]),
|
|
"device_fingerprint": row[2],
|
|
"hostname": row[3],
|
|
"created_at": row[4].isoformat(),
|
|
"last_seen": row[5].isoformat(),
|
|
}
|
|
|
|
return device, created
|
|
|
|
def store_hardware_snapshot(
|
|
device_id: str,
|
|
hardware: dict,
|
|
) -> str:
|
|
snapshot_id = uuid4()
|
|
|
|
identity = hardware.get("identity", {})
|
|
system = hardware.get("system", {})
|
|
mainboard = hardware.get("mainboard", {})
|
|
firmware = hardware.get("firmware", {})
|
|
security = hardware.get("security", {})
|
|
cpu = system.get("cpu", {})
|
|
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO hardware_snapshots (
|
|
id,
|
|
device_id,
|
|
architecture,
|
|
manufacturer,
|
|
product_name,
|
|
product_version,
|
|
system_uuid,
|
|
system_serial,
|
|
board_vendor,
|
|
board_name,
|
|
board_serial,
|
|
bios_vendor,
|
|
bios_version,
|
|
boot_mode,
|
|
secure_boot,
|
|
tpm_version,
|
|
cpu_model,
|
|
cpu_logical_count,
|
|
memory_bytes
|
|
)
|
|
VALUES (
|
|
%s, %s, %s, %s, %s,
|
|
%s, %s, %s, %s, %s,
|
|
%s, %s, %s, %s, %s,
|
|
%s, %s, %s, %s
|
|
)
|
|
""",
|
|
(
|
|
snapshot_id,
|
|
device_id,
|
|
system["architecture"],
|
|
system.get("manufacturer"),
|
|
system.get("product_name"),
|
|
system.get("product_version"),
|
|
identity.get("system_uuid"),
|
|
identity.get("system_serial"),
|
|
mainboard.get("vendor"),
|
|
mainboard.get("name"),
|
|
identity.get("board_serial"),
|
|
firmware.get("bios_vendor"),
|
|
firmware.get("bios_version"),
|
|
firmware.get("boot_mode"),
|
|
firmware.get("secure_boot"),
|
|
security.get("tpm_version"),
|
|
cpu.get("model"),
|
|
cpu.get("logical_count"),
|
|
system.get("memory_bytes"),
|
|
),
|
|
)
|
|
|
|
for interface in hardware.get("network_interfaces", []):
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO network_interfaces (
|
|
id,
|
|
hardware_snapshot_id,
|
|
name,
|
|
type,
|
|
mac_address
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s)
|
|
""",
|
|
(
|
|
uuid4(),
|
|
snapshot_id,
|
|
interface["name"],
|
|
interface["type"],
|
|
interface["mac"],
|
|
),
|
|
)
|
|
|
|
for storage_device in hardware.get("storage_devices", []):
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO storage_devices (
|
|
id,
|
|
hardware_snapshot_id,
|
|
name,
|
|
model,
|
|
serial,
|
|
size_bytes,
|
|
transport
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
""",
|
|
(
|
|
uuid4(),
|
|
snapshot_id,
|
|
storage_device["name"],
|
|
storage_device.get("model"),
|
|
storage_device.get("serial"),
|
|
storage_device["size_bytes"],
|
|
storage_device.get("transport"),
|
|
),
|
|
)
|
|
|
|
return str(snapshot_id)
|
|
|
|
def find_activation_code(code: str):
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT ac.organization_id, o.name
|
|
FROM activation_codes ac
|
|
JOIN organizations o ON o.id = ac.organization_id
|
|
WHERE ac.code = %s AND ac.active
|
|
""",
|
|
(code,),
|
|
)
|
|
row = cur.fetchone()
|
|
|
|
if row is None:
|
|
return None
|
|
|
|
return {"organization_id": str(row[0]), "organization_name": row[1]}
|
|
|
|
def find_enrollment_session(code: str):
|
|
"""
|
|
Enrollment Sessions haben kein eigenes code-Feld - ihre id dient direkt
|
|
als Bearer-Credential (unratbare UUID, siehe migrations/0012). Nur
|
|
gültige Sessions (nicht widerrufen, nicht abgelaufen, Kontingent nicht
|
|
erschöpft) werden zurückgegeben; jede andere Bedingung lässt /activate
|
|
denselben "invalid_activation_code"-Fehlschlag melden wie ein
|
|
ungültiger klassischer Aktivierungscode - das bricht den Installer
|
|
bereits beim Server-Handshake (Schritt 15) ab, lange vor jeder
|
|
destruktiven Aktion (siehe ADR-0008).
|
|
"""
|
|
|
|
try:
|
|
session_id = UUID(code)
|
|
except ValueError:
|
|
return None
|
|
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT es.id, es.organization_id, o.name, es.bereitstellungsvorlage_id,
|
|
es.max_devices, es.devices_used, es.expires_at, es.revoked_at
|
|
FROM enrollment_sessions es
|
|
JOIN organizations o ON o.id = es.organization_id
|
|
WHERE es.id = %s
|
|
""",
|
|
(session_id,),
|
|
)
|
|
row = cur.fetchone()
|
|
|
|
if row is None:
|
|
return None
|
|
|
|
(
|
|
session_id, organization_id, organization_name, bereitstellungsvorlage_id,
|
|
max_devices, devices_used, expires_at, revoked_at,
|
|
) = row
|
|
|
|
if revoked_at is not None:
|
|
return None
|
|
if expires_at <= datetime.now(timezone.utc):
|
|
return None
|
|
if devices_used >= max_devices:
|
|
return None
|
|
|
|
return {
|
|
"session_id": str(session_id),
|
|
"organization_id": str(organization_id),
|
|
"organization_name": organization_name,
|
|
"bereitstellungsvorlage_id": str(bereitstellungsvorlage_id),
|
|
}
|
|
|
|
def consume_enrollment_session(session_id: str) -> None:
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"UPDATE enrollment_sessions SET devices_used = devices_used + 1 WHERE id = %s",
|
|
(session_id,),
|
|
)
|
|
|
|
def fetch_templates(organization_id: str) -> list[dict]:
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
bv.id, bv.label, bv.is_default,
|
|
w.id, w.name,
|
|
b.id, b.name, b.version
|
|
FROM bereitstellungsvorlagen bv
|
|
JOIN workspaces w ON w.id = bv.workspace_id
|
|
JOIN backends b ON b.id = bv.backend_id
|
|
WHERE bv.organization_id = %s
|
|
ORDER BY bv.is_default DESC, bv.label
|
|
""",
|
|
(organization_id,),
|
|
)
|
|
rows = cur.fetchall()
|
|
|
|
return [
|
|
{
|
|
"id": str(row[0]),
|
|
"label": row[1],
|
|
"workspace": {"id": str(row[3]), "name": row[4]},
|
|
"backend": {"id": str(row[5]), "name": row[6], "version": row[7]},
|
|
"is_default": row[2],
|
|
}
|
|
for row in rows
|
|
]
|
|
|
|
def create_bereitstellungsvorlage(
|
|
organization_id: str,
|
|
workspace_id: str,
|
|
backend_id: str,
|
|
label: str,
|
|
is_default: bool,
|
|
disk_encryption: bool,
|
|
root_filesystem: str,
|
|
secure_boot_required: bool,
|
|
) -> dict:
|
|
"""
|
|
Bislang gab es dafuer keinen API-/UI-Weg - Bereitstellungsvorlagen kamen
|
|
nur per Seed-Migration (0005/0011) fuer die "Default Lab"-Organisation
|
|
zustande (siehe Phase 5 des Self-Service-ISO-Plans, dort als offene
|
|
Luecke notiert). partitioning bekommt bewusst nur eine einfache
|
|
"root_filesystem"-Wahl im single-scheme (siehe Migration 0015 fuer die
|
|
volle Form) - ein custom-Schema mit extra_partitions ist Sache der
|
|
direkten DB-Pflege, nicht dieses Formulars.
|
|
"""
|
|
|
|
vorlage_id = uuid4()
|
|
partitioning = {"scheme": "single", "root_filesystem": root_filesystem}
|
|
|
|
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))
|
|
|
|
def fetch_assigned_blueprints(device_id: str):
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT bv.workspace_id, bv.backend_id
|
|
FROM assignments a
|
|
JOIN bereitstellungsvorlagen bv ON bv.id = a.bereitstellungsvorlage_id
|
|
WHERE a.device_id = %s
|
|
""",
|
|
(device_id,),
|
|
)
|
|
assignment_row = cur.fetchone()
|
|
|
|
if assignment_row is None:
|
|
return []
|
|
|
|
workspace_id, backend_id = assignment_row
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT m.key, bp.ansible_role
|
|
FROM workspace_merkmale wm
|
|
JOIN merkmale m ON m.id = wm.merkmal_id
|
|
JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s
|
|
WHERE wm.workspace_id = %s
|
|
""",
|
|
(backend_id, workspace_id),
|
|
)
|
|
return [
|
|
{"merkmal": key, "ansible_role": ansible_role}
|
|
for key, ansible_role in cur.fetchall()
|
|
]
|
|
|
|
def 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 FROM oe_merkmale WHERE oe_id = ANY(%s)",
|
|
(oe_chain,),
|
|
)
|
|
rows_by_oe = {}
|
|
for row_oe_id, merkmal_id, aktiv in cur.fetchall():
|
|
rows_by_oe.setdefault(row_oe_id, {})[merkmal_id] = aktiv
|
|
|
|
for oe_id_in_chain in oe_chain:
|
|
for merkmal_id, aktiv in rows_by_oe.get(oe_id_in_chain, {}).items():
|
|
oe_aktiv_by_merkmal.setdefault(merkmal_id, (aktiv, oe_id_in_chain))
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT gm.merkmal_id, gm.gruppe_id, gm.aktiv
|
|
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 in cur.fetchall():
|
|
gruppen_stimmen_by_merkmal.setdefault(merkmal_id, []).append((gruppe_id, aktiv))
|
|
|
|
ergebnis = {}
|
|
for merkmal_id in set(oe_aktiv_by_merkmal) | set(gruppen_stimmen_by_merkmal):
|
|
oe_aktiv, oe_id_gewinner = oe_aktiv_by_merkmal.get(merkmal_id, (None, None))
|
|
gruppen_stimmen = gruppen_stimmen_by_merkmal.get(merkmal_id, [])
|
|
aktive_gruppen = [gruppe_id for gruppe_id, aktiv in gruppen_stimmen if aktiv]
|
|
inaktive_gruppen = [gruppe_id for gruppe_id, aktiv 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]
|
|
ergebnis[merkmal_id] = (True, quelle, quelle_id)
|
|
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 fetch_auftragskatalog_state(device_id: str):
|
|
"""
|
|
Voller Soll-Zustand (present/absent) aller katalogfähigen Merkmale für
|
|
das Backend des Device (siehe ADR-0010, erweitert um OEs/Gruppen siehe
|
|
Konfigurationsgruppen-Plan). Zustandslos aus der aktuellen Auswahl
|
|
abgeleitet, keine Historie nötig.
|
|
|
|
Auflösungsreihenfolge: Workspace-Default → OE-/Gruppen-Zwischenschicht
|
|
(resolve_katalog_overrides) → device_merkmale-Override, der gewinnt
|
|
immer, unabhängig davon was OE-Kette oder Gruppen sagen.
|
|
"""
|
|
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT bv.workspace_id, bv.backend_id
|
|
FROM assignments a
|
|
JOIN bereitstellungsvorlagen bv ON bv.id = a.bereitstellungsvorlage_id
|
|
WHERE a.device_id = %s
|
|
""",
|
|
(device_id,),
|
|
)
|
|
assignment_row = cur.fetchone()
|
|
|
|
if assignment_row is None:
|
|
return []
|
|
|
|
workspace_id, backend_id = assignment_row
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT m.id, m.key, bp.ansible_role,
|
|
dm.aktiv,
|
|
(wm.merkmal_id IS NOT NULL) AS workspace_default,
|
|
COALESCE(dm.optionen, '{}'::jsonb)
|
|
FROM merkmale m
|
|
JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s
|
|
LEFT JOIN device_merkmale dm
|
|
ON dm.merkmal_id = m.id AND dm.device_id = %s
|
|
LEFT JOIN workspace_merkmale wm
|
|
ON wm.merkmal_id = m.id AND wm.workspace_id = %s
|
|
WHERE m.im_auftragskatalog
|
|
""",
|
|
(backend_id, device_id, workspace_id),
|
|
)
|
|
rows = cur.fetchall()
|
|
|
|
overrides = resolve_katalog_overrides(device_id)
|
|
|
|
ergebnis = []
|
|
for merkmal_id, key, ansible_role, device_override, workspace_default, optionen in rows:
|
|
if device_override is not None:
|
|
aktiv = device_override
|
|
elif merkmal_id in overrides:
|
|
aktiv, _quelle, _quelle_id = overrides[merkmal_id]
|
|
else:
|
|
aktiv = workspace_default
|
|
|
|
ergebnis.append({
|
|
"merkmal": key,
|
|
"ansible_role": ansible_role,
|
|
"state": "present" if aktiv else "absent",
|
|
"optionen": optionen,
|
|
})
|
|
|
|
return ergebnis
|
|
|
|
def fetch_auftragskatalog_listing(device_id: str):
|
|
"""
|
|
Wie fetch_auftragskatalog_state, aber für die Auswahl-API angereichert um
|
|
Anzeige-relevante Felder (Name, Beschreibung, Kategorie, Herkunft der
|
|
Einstellung) statt nur die für den Agenten nötigen (Rollenname, State).
|
|
"""
|
|
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT bv.workspace_id, bv.backend_id
|
|
FROM assignments a
|
|
JOIN bereitstellungsvorlagen bv ON bv.id = a.bereitstellungsvorlage_id
|
|
WHERE a.device_id = %s
|
|
""",
|
|
(device_id,),
|
|
)
|
|
assignment_row = cur.fetchone()
|
|
|
|
if assignment_row is None:
|
|
return None
|
|
|
|
workspace_id, backend_id = assignment_row
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT m.id, m.key, m.name, m.description,
|
|
k.key, k.name, k.sort_order,
|
|
dm.aktiv,
|
|
(wm.merkmal_id IS NOT NULL) AS workspace_default,
|
|
COALESCE(dm.optionen, '{}'::jsonb)
|
|
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 device_merkmale dm
|
|
ON dm.merkmal_id = m.id AND dm.device_id = %s
|
|
LEFT JOIN workspace_merkmale wm
|
|
ON wm.merkmal_id = m.id AND wm.workspace_id = %s
|
|
WHERE m.im_auftragskatalog
|
|
ORDER BY k.sort_order NULLS LAST, m.name
|
|
""",
|
|
(backend_id, device_id, workspace_id),
|
|
)
|
|
rows = cur.fetchall()
|
|
|
|
overrides = resolve_katalog_overrides(device_id)
|
|
|
|
ergebnis = []
|
|
for (
|
|
merkmal_id, key, name, description,
|
|
kat_key, kat_name, sort_order,
|
|
device_override, workspace_default, optionen,
|
|
) in rows:
|
|
if device_override is not None:
|
|
aktiv, quelle, quelle_id = device_override, "geraet", None
|
|
elif merkmal_id in overrides:
|
|
aktiv, quelle, quelle_id = overrides[merkmal_id]
|
|
else:
|
|
aktiv, quelle, quelle_id = 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,
|
|
"optionen": optionen,
|
|
})
|
|
|
|
return ergebnis
|
|
|
|
def set_auftrag_selection(device_id: str, merkmal_key: str, aktiv: bool, optionen: dict):
|
|
"""
|
|
Setzt die geräteweise Auswahl eines katalogfähigen Merkmals (Upsert in
|
|
device_merkmale) und protokolliert die Änderung als selected/deselected-
|
|
Event. Gibt False zurück, wenn kein katalogfähiges Merkmal mit diesem Key
|
|
existiert (unbekannter Key oder im_auftragskatalog = false).
|
|
"""
|
|
|
|
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]
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO device_merkmale (id, device_id, merkmal_id, aktiv, optionen)
|
|
VALUES (%s, %s, %s, %s, %s)
|
|
ON CONFLICT (device_id, merkmal_id) DO UPDATE
|
|
SET aktiv = EXCLUDED.aktiv,
|
|
optionen = EXCLUDED.optionen,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
""",
|
|
(uuid4(), device_id, merkmal_id, aktiv, Jsonb(optionen)),
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO device_merkmal_events (id, device_id, merkmal_id, event_type)
|
|
VALUES (%s, %s, %s, %s)
|
|
""",
|
|
(uuid4(), device_id, merkmal_id, "selected" if aktiv else "deselected"),
|
|
)
|
|
|
|
return True
|
|
|
|
def fetch_organisationseinheiten(organization_id: str):
|
|
"""
|
|
Flache Liste aller OEs einer Organisation (inkl. parent_id) - der Baum
|
|
wird im Kundenportal-Template aus parent_id aufgebaut, nicht hier.
|
|
"""
|
|
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT id, parent_id, name, ist_standard
|
|
FROM organisationseinheiten
|
|
WHERE organization_id = %s
|
|
ORDER BY name
|
|
""",
|
|
(organization_id,),
|
|
)
|
|
return [
|
|
{
|
|
"id": str(oe_id),
|
|
"parent_id": str(parent_id) if parent_id is not None else None,
|
|
"name": name,
|
|
"ist_standard": ist_standard,
|
|
}
|
|
for oe_id, parent_id, name, ist_standard in cur.fetchall()
|
|
]
|
|
|
|
def create_organisationseinheit(organization_id: str, name: str, parent_id: str | None):
|
|
"""
|
|
Legt eine neue OE an. Gibt None zurück, wenn parent_id gesetzt ist, aber
|
|
nicht zur selben Organisation gehört (verhindert Cross-Org-
|
|
Verschachtelung).
|
|
"""
|
|
|
|
oe_id = uuid4()
|
|
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
if parent_id is not None:
|
|
cur.execute(
|
|
"SELECT id FROM organisationseinheiten WHERE id = %s AND organization_id = %s",
|
|
(parent_id, organization_id),
|
|
)
|
|
if cur.fetchone() is None:
|
|
return None
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO organisationseinheiten (id, organization_id, parent_id, name)
|
|
VALUES (%s, %s, %s, %s)
|
|
""",
|
|
(oe_id, organization_id, parent_id, name),
|
|
)
|
|
|
|
return {"id": str(oe_id), "parent_id": parent_id, "name": name, "ist_standard": False}
|
|
|
|
def update_organisationseinheit(oe_id: str, name: str, parent_id: str | None):
|
|
"""
|
|
Benennt um und/oder verschiebt eine OE. Gibt None zurück, wenn die OE
|
|
nicht existiert, parent_id nicht zur selben Organisation gehört, oder
|
|
die Verschiebung einen Zyklus im Baum erzeugen würde (OE würde zu ihrem
|
|
eigenen Nachfahren).
|
|
"""
|
|
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"SELECT organization_id FROM organisationseinheiten WHERE id = %s",
|
|
(oe_id,),
|
|
)
|
|
row = cur.fetchone()
|
|
|
|
if row is None:
|
|
return None
|
|
|
|
organization_id = row[0]
|
|
|
|
if parent_id is not None:
|
|
if parent_id == oe_id:
|
|
return None
|
|
|
|
cur.execute(
|
|
"SELECT id, parent_id FROM organisationseinheiten WHERE organization_id = %s",
|
|
(organization_id,),
|
|
)
|
|
parent_by_id = {
|
|
str(r[0]): (str(r[1]) if r[1] is not None else None)
|
|
for r in cur.fetchall()
|
|
}
|
|
|
|
if parent_id not in parent_by_id:
|
|
return None
|
|
|
|
current = parent_id
|
|
while current is not None:
|
|
if current == oe_id:
|
|
return None
|
|
current = parent_by_id.get(current)
|
|
|
|
cur.execute(
|
|
"""
|
|
UPDATE organisationseinheiten
|
|
SET name = %s, parent_id = %s
|
|
WHERE id = %s
|
|
RETURNING id, parent_id, name, ist_standard
|
|
""",
|
|
(name, parent_id, oe_id),
|
|
)
|
|
updated = cur.fetchone()
|
|
|
|
return {
|
|
"id": str(updated[0]),
|
|
"parent_id": str(updated[1]) if updated[1] is not None else None,
|
|
"name": updated[2],
|
|
"ist_standard": updated[3],
|
|
}
|
|
|
|
def delete_organisationseinheit(oe_id: str):
|
|
"""
|
|
Löscht eine OE. Gibt (True, None) bei Erfolg zurück, sonst
|
|
(False, Fehlermeldung) - die Standard-OE, OEs mit Kind-OEs und OEs mit
|
|
zugeordneten Geräten sind geschützt (freundliche Fehlermeldung statt dem
|
|
rohen DB-RESTRICT-Fehler).
|
|
"""
|
|
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"SELECT ist_standard FROM organisationseinheiten WHERE id = %s",
|
|
(oe_id,),
|
|
)
|
|
row = cur.fetchone()
|
|
|
|
if row is None:
|
|
return False, "Die OE wurde nicht gefunden."
|
|
|
|
if row[0]:
|
|
return False, "Die Standard-OE 'Neue Geräte' kann nicht gelöscht werden."
|
|
|
|
cur.execute("SELECT count(*) FROM organisationseinheiten WHERE parent_id = %s", (oe_id,))
|
|
if cur.fetchone()[0] > 0:
|
|
return False, "Die OE hat noch untergeordnete OEs - diese zuerst verschieben oder löschen."
|
|
|
|
cur.execute("SELECT count(*) FROM devices WHERE oe_id = %s", (oe_id,))
|
|
if cur.fetchone()[0] > 0:
|
|
return False, "Der OE sind noch Geräte zugeordnet - diese zuerst verschieben."
|
|
|
|
cur.execute("DELETE FROM organisationseinheiten WHERE id = %s", (oe_id,))
|
|
|
|
return True, None
|
|
|
|
def set_oe_auswahl(oe_id: str, merkmal_key: str, aktiv: bool, optionen: dict):
|
|
"""Setzt die OE-weite Auswahl eines katalogfähigen Merkmals (Upsert in oe_merkmale), analog zu set_auftrag_selection."""
|
|
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"SELECT id FROM merkmale WHERE key = %s AND im_auftragskatalog",
|
|
(merkmal_key,),
|
|
)
|
|
merkmal_row = cur.fetchone()
|
|
|
|
if merkmal_row is None:
|
|
return False
|
|
|
|
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_row[0], 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
|
|
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,
|
|
}
|
|
for key, name, description, kat_key, kat_name, sort_order, aktiv in cur.fetchall()
|
|
]
|
|
|
|
def move_device_to_oe(device_id: str, oe_id: str):
|
|
"""
|
|
Verschiebt ein Gerät in eine andere OE derselben Organisation. Gibt
|
|
False zurück, wenn Gerät/OE nicht existieren oder zu unterschiedlichen
|
|
Organisationen gehören.
|
|
"""
|
|
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
UPDATE devices
|
|
SET oe_id = %s
|
|
WHERE id = %s
|
|
AND organization_id = (SELECT organization_id FROM organisationseinheiten WHERE id = %s)
|
|
RETURNING id
|
|
""",
|
|
(oe_id, device_id, oe_id),
|
|
)
|
|
return cur.fetchone() is not None
|
|
|
|
def fetch_gruppen(organization_id: str):
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"SELECT id, name FROM gruppen WHERE organization_id = %s ORDER BY 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
|
|
|
|
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_row[0], 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
|
|
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,
|
|
}
|
|
for key, name, description, kat_key, kat_name, sort_order, aktiv 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.
|
|
"""
|
|
|
|
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()]
|
|
|
|
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(
|
|
"""
|
|
SELECT d.id, d.hostname, d.device_fingerprint, d.agent_last_checkin,
|
|
d.deprovisioned_at, d.oe_id, oe.name
|
|
FROM devices d
|
|
LEFT JOIN organisationseinheiten oe ON oe.id = d.oe_id
|
|
WHERE d.organization_id = %s AND d.archived_at IS NULL
|
|
ORDER BY d.hostname NULLS LAST, d.created_at
|
|
""",
|
|
(organization_id,),
|
|
)
|
|
return [
|
|
{
|
|
"id": str(device_id),
|
|
"hostname": hostname,
|
|
"device_fingerprint": fingerprint,
|
|
"agent_last_checkin": (
|
|
last_checkin.isoformat() if last_checkin is not None else None
|
|
),
|
|
"deprovisioned_at": (
|
|
deprovisioned_at.isoformat() if deprovisioned_at is not None else None
|
|
),
|
|
"oe_id": str(oe_id) if oe_id is not None else None,
|
|
"oe_name": oe_name,
|
|
}
|
|
for device_id, hostname, fingerprint, last_checkin, deprovisioned_at, oe_id, oe_name in cur.fetchall()
|
|
]
|
|
|
|
def fetch_all_devices():
|
|
"""
|
|
Organisationsübergreifende Geräteliste für den Kundenplattform-Admin-
|
|
Bereich (Verwaltung -> Geräte) - Pendant zu fetch_devices_for_organization,
|
|
ohne Org-Filter, mit Organisationsname gejoint.
|
|
"""
|
|
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
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
|
|
FROM devices d
|
|
JOIN organizations o ON o.id = d.organization_id
|
|
LEFT JOIN organisationseinheiten oe ON oe.id = d.oe_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,
|
|
}
|
|
for (
|
|
device_id, hostname, fingerprint, last_checkin,
|
|
organization_id, organization_name, deprovisioned_at,
|
|
oe_id, oe_name,
|
|
) in cur.fetchall()
|
|
]
|
|
|
|
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 deprovision_device(device_id: str) -> bool:
|
|
"""
|
|
Serverseitiger Entzug (siehe Kundenportal-Geräteaktionen-Plan): macht das
|
|
aktuelle Agent-Secret ungültig, ohne den Geräte-Datensatz oder dessen
|
|
Historie anzutasten. Der Agent auf dem Gerät selbst wird nicht aktiv
|
|
kontaktiert - er bekommt beim nächsten Check-in schlicht 401 und bleibt
|
|
bis zur Reprovisionierung untätig liegen (siehe agent.py Self-Heal).
|
|
"""
|
|
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
UPDATE devices
|
|
SET agent_secret_hash = NULL, agent_secret_issued_at = NULL,
|
|
deprovisioned_at = CURRENT_TIMESTAMP
|
|
WHERE id = %s AND archived_at IS NULL
|
|
RETURNING id
|
|
""",
|
|
(device_id,),
|
|
)
|
|
return cur.fetchone() is not None
|
|
|
|
def reprovision_device(device_id: str) -> bool:
|
|
"""
|
|
Hebt die Deprovisionierung auf. Erzeugt bewusst kein neues Agent-Secret -
|
|
das holt sich der Agent beim nächsten Poll-Zyklus selbst über den
|
|
bestehenden /api/v1/agent/bootstrap-Endpunkt (401 auf checkin loest im
|
|
Agenten einen Re-Bootstrap aus, siehe agent.py).
|
|
"""
|
|
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
UPDATE devices
|
|
SET deprovisioned_at = NULL
|
|
WHERE id = %s AND archived_at IS NULL
|
|
RETURNING id
|
|
""",
|
|
(device_id,),
|
|
)
|
|
return cur.fetchone() is not None
|
|
|
|
def archive_device(device_id: str) -> bool:
|
|
"""
|
|
Kundenportal-Aktion "Gerät löschen": Soft Delete. Das Gerät verschwindet
|
|
aus fetch_devices_for_organization()/fetch_all_devices(), Datensatz und
|
|
Historie (hardware_snapshots, device_merkmal_events) bleiben in der DB
|
|
erhalten.
|
|
"""
|
|
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
UPDATE devices
|
|
SET archived_at = CURRENT_TIMESTAMP
|
|
WHERE id = %s AND archived_at IS NULL
|
|
RETURNING id
|
|
""",
|
|
(device_id,),
|
|
)
|
|
return cur.fetchone() is not None
|
|
|
|
def fetch_organizations():
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"SELECT id, name, created_at FROM organizations ORDER BY name"
|
|
)
|
|
return [
|
|
{"id": str(org_id), "name": name, "created_at": created_at.isoformat()}
|
|
for org_id, name, created_at in cur.fetchall()
|
|
]
|
|
|
|
def fetch_organization(organization_id: str):
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"SELECT id, name, created_at FROM organizations WHERE id = %s",
|
|
(organization_id,),
|
|
)
|
|
row = cur.fetchone()
|
|
|
|
if row is None:
|
|
return None
|
|
|
|
return {"id": str(row[0]), "name": row[1], "created_at": row[2].isoformat()}
|
|
|
|
def create_organization(name: str):
|
|
organization_id = uuid4()
|
|
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"INSERT INTO organizations (id, name) VALUES (%s, %s)",
|
|
(organization_id, name),
|
|
)
|
|
|
|
# Jede Organisation bekommt von Geburt an eine Standard-OE (siehe
|
|
# Konfigurationsgruppen-Plan) - Landeplatz fuer neu aktivierte
|
|
# Geraete, geschuetzt vor Loeschen (siehe delete_organisationseinheit).
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO organisationseinheiten (id, organization_id, parent_id, name, ist_standard)
|
|
VALUES (%s, %s, NULL, %s, TRUE)
|
|
""",
|
|
(uuid4(), organization_id, STANDARD_OE_NAME),
|
|
)
|
|
|
|
return {"id": str(organization_id), "name": name}
|
|
|
|
def update_organization(organization_id: str, name: str):
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"UPDATE organizations SET name = %s WHERE id = %s RETURNING id, name",
|
|
(name, organization_id),
|
|
)
|
|
row = cur.fetchone()
|
|
|
|
if row is None:
|
|
return None
|
|
|
|
return {"id": str(row[0]), "name": row[1]}
|
|
|
|
def fetch_activation_codes(organization_id: str):
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT code, active, created_at
|
|
FROM activation_codes
|
|
WHERE organization_id = %s
|
|
ORDER BY created_at DESC
|
|
""",
|
|
(organization_id,),
|
|
)
|
|
return [
|
|
{"code": code, "active": active, "created_at": created_at.isoformat()}
|
|
for code, active, created_at in cur.fetchall()
|
|
]
|
|
|
|
def create_activation_code(organization_id: str):
|
|
code = "-".join(
|
|
secrets.token_hex(3).upper()[i:i + 3] for i in range(0, 6, 3)
|
|
)
|
|
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"INSERT INTO activation_codes (code, organization_id) VALUES (%s, %s)",
|
|
(code, organization_id),
|
|
)
|
|
|
|
return {"code": code, "active": True}
|
|
|
|
def fetch_merkmale():
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT m.id, m.key, m.name, m.description, m.im_auftragskatalog,
|
|
m.kategorie_id, k.name
|
|
FROM merkmale m
|
|
LEFT JOIN kategorien k ON k.id = m.kategorie_id
|
|
ORDER BY m.key
|
|
"""
|
|
)
|
|
return [
|
|
{
|
|
"id": str(mid),
|
|
"key": key,
|
|
"name": name,
|
|
"description": description,
|
|
"im_auftragskatalog": im_auftragskatalog,
|
|
"kategorie_id": str(kategorie_id) if kategorie_id else None,
|
|
"kategorie_name": kategorie_name,
|
|
}
|
|
for (
|
|
mid, key, name, description, im_auftragskatalog,
|
|
kategorie_id, kategorie_name,
|
|
) in cur.fetchall()
|
|
]
|
|
|
|
def create_merkmal(key: str, name: str, description: str | None):
|
|
merkmal_id = uuid4()
|
|
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"INSERT INTO merkmale (id, key, name, description) VALUES (%s, %s, %s, %s)",
|
|
(merkmal_id, key, name, description),
|
|
)
|
|
|
|
return {"id": str(merkmal_id), "key": key, "name": name}
|
|
|
|
def update_merkmal(merkmal_id: str, name: str, description: str | None, kategorie_id: str | None, im_auftragskatalog: bool):
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
UPDATE merkmale
|
|
SET name = %s, description = %s, kategorie_id = %s, im_auftragskatalog = %s
|
|
WHERE id = %s
|
|
RETURNING id
|
|
""",
|
|
(name, description, kategorie_id, im_auftragskatalog, merkmal_id),
|
|
)
|
|
return cur.fetchone() is not None
|
|
|
|
def fetch_kategorien():
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"SELECT id, key, name, description, sort_order FROM kategorien ORDER BY sort_order, name"
|
|
)
|
|
return [
|
|
{
|
|
"id": str(kid),
|
|
"key": key,
|
|
"name": name,
|
|
"description": description,
|
|
"sort_order": sort_order,
|
|
}
|
|
for kid, key, name, description, sort_order in cur.fetchall()
|
|
]
|
|
|
|
def create_kategorie(key: str, name: str, description: str | None, sort_order: int):
|
|
kategorie_id = uuid4()
|
|
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO kategorien (id, key, name, description, sort_order)
|
|
VALUES (%s, %s, %s, %s, %s)
|
|
""",
|
|
(kategorie_id, key, name, description, sort_order),
|
|
)
|
|
|
|
return {"id": str(kategorie_id), "key": key, "name": name}
|
|
|
|
def fetch_workspaces():
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT w.id, w.key, w.name, w.description, w.organization_id, o.name
|
|
FROM workspaces w
|
|
LEFT JOIN organizations o ON o.id = w.organization_id
|
|
ORDER BY o.name NULLS FIRST, w.name
|
|
"""
|
|
)
|
|
return [
|
|
{
|
|
"id": str(wid),
|
|
"key": key,
|
|
"name": name,
|
|
"description": description,
|
|
"organization_id": str(organization_id) if organization_id else None,
|
|
"organization_name": organization_name,
|
|
}
|
|
for (
|
|
wid, key, name, description, organization_id, organization_name,
|
|
) in cur.fetchall()
|
|
]
|
|
|
|
def create_workspace(key: str, name: str, description: str | None, organization_id: str | None):
|
|
workspace_id = uuid4()
|
|
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO workspaces (id, organization_id, key, name, description)
|
|
VALUES (%s, %s, %s, %s, %s)
|
|
""",
|
|
(workspace_id, organization_id, key, name, description),
|
|
)
|
|
|
|
return {"id": str(workspace_id), "key": key, "name": name}
|
|
|
|
def fetch_workspace_detail(workspace_id: str):
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT w.id, w.key, w.name, w.description, w.organization_id, o.name
|
|
FROM workspaces w
|
|
LEFT JOIN organizations o ON o.id = w.organization_id
|
|
WHERE w.id = %s
|
|
""",
|
|
(workspace_id,),
|
|
)
|
|
row = cur.fetchone()
|
|
|
|
if row is None:
|
|
return None
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT m.id, m.key, m.name
|
|
FROM workspace_merkmale wm
|
|
JOIN merkmale m ON m.id = wm.merkmal_id
|
|
WHERE wm.workspace_id = %s
|
|
ORDER BY m.key
|
|
""",
|
|
(workspace_id,),
|
|
)
|
|
merkmale = [
|
|
{"id": str(mid), "key": key, "name": name}
|
|
for mid, key, name in cur.fetchall()
|
|
]
|
|
|
|
return {
|
|
"id": str(row[0]),
|
|
"key": row[1],
|
|
"name": row[2],
|
|
"description": row[3],
|
|
"organization_id": str(row[4]) if row[4] else None,
|
|
"organization_name": row[5],
|
|
"merkmale": merkmale,
|
|
}
|
|
|
|
def add_merkmal_to_workspace(workspace_id: str, merkmal_id: str):
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO workspace_merkmale (workspace_id, merkmal_id)
|
|
VALUES (%s, %s)
|
|
ON CONFLICT DO NOTHING
|
|
""",
|
|
(workspace_id, merkmal_id),
|
|
)
|
|
|
|
def remove_merkmal_from_workspace(workspace_id: str, merkmal_id: str):
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"DELETE FROM workspace_merkmale WHERE workspace_id = %s AND merkmal_id = %s",
|
|
(workspace_id, merkmal_id),
|
|
)
|
|
|
|
def fetch_backends():
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute("SELECT id, key, name, installer_type, version FROM backends ORDER BY key")
|
|
return [
|
|
{"id": str(bid), "key": key, "name": name, "installer_type": installer_type, "version": version}
|
|
for bid, key, name, installer_type, version in cur.fetchall()
|
|
]
|
|
|
|
def fetch_blueprints():
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT m.key, b.key, bp.ansible_role
|
|
FROM blueprints bp
|
|
JOIN merkmale m ON m.id = bp.merkmal_id
|
|
JOIN backends b ON b.id = bp.backend_id
|
|
ORDER BY m.key, b.key
|
|
"""
|
|
)
|
|
return [
|
|
{"merkmal": merkmal_key, "backend": backend_key, "ansible_role": ansible_role}
|
|
for merkmal_key, backend_key, ansible_role in cur.fetchall()
|
|
]
|
|
|
|
def fetch_unassigned_devices(organization_id: str):
|
|
"""
|
|
Geräte einer Organisation ohne Bereitstellungsvorlagen-Zuweisung - die
|
|
Kandidaten für die Neugerät-Bestätigung (ADR-0008) im Flows-Bereich.
|
|
"""
|
|
|
|
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
|
|
ORDER BY d.created_at
|
|
""",
|
|
(organization_id,),
|
|
)
|
|
return [
|
|
{
|
|
"id": str(device_id),
|
|
"hostname": hostname,
|
|
"device_fingerprint": fingerprint,
|
|
"created_at": created_at.isoformat(),
|
|
}
|
|
for device_id, hostname, fingerprint, created_at in cur.fetchall()
|
|
]
|
|
|
|
def fetch_enrollment_sessions(organization_id: str):
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT es.id, bv.label, es.max_devices, es.devices_used,
|
|
es.expires_at, es.revoked_at, es.created_at
|
|
FROM enrollment_sessions es
|
|
JOIN bereitstellungsvorlagen bv ON bv.id = es.bereitstellungsvorlage_id
|
|
WHERE es.organization_id = %s
|
|
ORDER BY es.created_at DESC
|
|
""",
|
|
(organization_id,),
|
|
)
|
|
return [
|
|
{
|
|
"id": str(sid),
|
|
"bereitstellungsvorlage_label": label,
|
|
"max_devices": max_devices,
|
|
"devices_used": devices_used,
|
|
"expires_at": expires_at.isoformat(),
|
|
"revoked_at": revoked_at.isoformat() if revoked_at else None,
|
|
"created_at": created_at.isoformat(),
|
|
}
|
|
for (
|
|
sid, label, max_devices, devices_used,
|
|
expires_at, revoked_at, created_at,
|
|
) in cur.fetchall()
|
|
]
|
|
|
|
def create_enrollment_session(organization_id: str, bereitstellungsvorlage_id: str, max_devices: int, expires_at: str):
|
|
session_id = uuid4()
|
|
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO enrollment_sessions
|
|
(id, organization_id, bereitstellungsvorlage_id, max_devices, expires_at)
|
|
VALUES (%s, %s, %s, %s, %s)
|
|
""",
|
|
(session_id, organization_id, bereitstellungsvorlage_id, max_devices, expires_at),
|
|
)
|
|
|
|
return {"id": str(session_id)}
|
|
|
|
def revoke_enrollment_session(session_id: str):
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
UPDATE enrollment_sessions
|
|
SET revoked_at = CURRENT_TIMESTAMP
|
|
WHERE id = %s AND revoked_at IS NULL
|
|
RETURNING id
|
|
""",
|
|
(session_id,),
|
|
)
|
|
return cur.fetchone() is not None
|
|
|
|
def recharge_enrollment_session(session_id: str, additional_devices: int, expires_at: str | None):
|
|
"""
|
|
Phase 5 (Kundenplattform-Selfservice): eine bereits ausgeteilte,
|
|
personalisierte Kunden-ISO bettet die Enrollment-Session-Id fest als
|
|
Aktivierungscode ein (siehe build_customer_iso.sh) - Kontingent/
|
|
Gueltigkeit dieser Session zu erhoehen macht die ISO weiter nutzbar,
|
|
ohne dass eine neue gebaut/neu verteilt werden muss. additional_devices
|
|
wird auf max_devices addiert (nicht ersetzt - "aufladen", nicht
|
|
"neu setzen"), expires_at ersetzt den bisherigen Wert, wenn angegeben.
|
|
"""
|
|
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
UPDATE enrollment_sessions
|
|
SET max_devices = max_devices + %s,
|
|
expires_at = COALESCE(%s, expires_at)
|
|
WHERE id = %s AND revoked_at IS NULL
|
|
RETURNING id
|
|
""",
|
|
(additional_devices, expires_at, session_id),
|
|
)
|
|
return cur.fetchone() is not None
|
|
|
|
def iso_build_row_to_dict(row) -> dict:
|
|
(build_id, organization_id, status, output_filename, error_message,
|
|
created_at, started_at, finished_at) = 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,
|
|
}
|
|
|
|
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
|
|
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
|
|
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,
|
|
mark_started: bool = False,
|
|
mark_finished: bool = False,
|
|
):
|
|
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
|
|
{", started_at = CURRENT_TIMESTAMP" if mark_started else ""}
|
|
{", finished_at = CURRENT_TIMESTAMP" if mark_finished else ""}
|
|
WHERE id = %s
|
|
""",
|
|
(status, output_filename, error_message, 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 run_iso_build(build_id, organization_id: str, activation_code: str, wifi_ssid: str | None, wifi_psk: str | None) -> None:
|
|
output_path = ISO_BUILD_OUTPUT_DIR / f"{build_id}.iso"
|
|
|
|
update_iso_build(build_id, status="running", mark_started=True)
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
[
|
|
"bash",
|
|
f"{TUXFLOTTE_INSTALLER_DIR}/scripts/build_customer_iso.sh",
|
|
MINT_ISO_PATH,
|
|
str(output_path),
|
|
activation_code,
|
|
wifi_ssid or "",
|
|
wifi_psk or "",
|
|
],
|
|
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
|
|
|
|
update_iso_build(build_id, status="completed", output_filename=output_path.name, mark_finished=True)
|
|
cleanup_old_iso_builds(organization_id, build_id)
|
|
|
|
@app.get("/health")
|
|
def health():
|
|
return {
|
|
"status": "ok",
|
|
"service": "provisioning-server",
|
|
"version": "0.1.0"
|
|
}
|
|
|
|
|
|
@app.post("/api/v1/activate")
|
|
def activate(payload: ActivationRequest, request: Request):
|
|
# Enrollment Sessions zuerst versuchen (Auto-Modus-ISOs, siehe
|
|
# tuxflotte-installer Phase 2/3) - ihre id ist selbst der Code, kollidiert
|
|
# nicht mit klassischen Aktivierungscodes (eigene, meist sprechende
|
|
# Strings wie "LAB-2026-START"). Fällt bei Nicht-UUID sofort auf den
|
|
# klassischen Pfad zurück.
|
|
enrollment_session = find_enrollment_session(payload.activation_code)
|
|
activation = None
|
|
|
|
if enrollment_session is not None:
|
|
organization_id = enrollment_session["organization_id"]
|
|
organization_name = enrollment_session["organization_name"]
|
|
success = True
|
|
else:
|
|
activation = find_activation_code(payload.activation_code)
|
|
success = activation is not None
|
|
|
|
if success:
|
|
organization_id = activation["organization_id"]
|
|
organization_name = activation["organization_name"]
|
|
|
|
device = None
|
|
device_created = False
|
|
hardware_snapshot_id = None
|
|
|
|
if success:
|
|
device, device_created = load_or_create_device(
|
|
organization_id,
|
|
payload.device_fingerprint,
|
|
payload.hostname,
|
|
)
|
|
hardware_snapshot_id = store_hardware_snapshot(
|
|
device["id"],
|
|
payload.hardware,
|
|
)
|
|
|
|
if enrollment_session is not None:
|
|
consume_enrollment_session(enrollment_session["session_id"])
|
|
|
|
write_log({
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"event": "activate",
|
|
"success": success,
|
|
"remote_ip": request.client.host if request.client else None,
|
|
"activation_code": payload.activation_code,
|
|
"hostname": payload.hostname,
|
|
"machine_id": payload.machine_id,
|
|
"client_version": payload.client_version
|
|
})
|
|
|
|
if not success:
|
|
return {
|
|
"success": False,
|
|
"error": "invalid_activation_code",
|
|
"message": "Der Aktivierungscode ist ungültig."
|
|
}
|
|
|
|
if enrollment_session is not None:
|
|
# Die Bereitstellungsvorlage steht durch die Enrollment Session schon
|
|
# fest - nicht die volle Organisationsliste zurückgeben (20_profile_
|
|
# selection.sh würde im Auto-Modus sonst nichts Eindeutiges zum
|
|
# Auswählen haben), sondern nur diese eine, als is_default markiert
|
|
# (dieselbe Erkennung, die der Installer schon für den regulären
|
|
# Standard-Vorlagen-Fall benutzt - keine Installer-Änderung nötig).
|
|
templates = [
|
|
{**template, "is_default": True}
|
|
for template in fetch_templates(organization_id)
|
|
if template["id"] == enrollment_session["bereitstellungsvorlage_id"]
|
|
]
|
|
else:
|
|
templates = fetch_templates(organization_id)
|
|
|
|
return {
|
|
"success": True,
|
|
|
|
"device": {
|
|
"id": device["id"],
|
|
"fingerprint": device["device_fingerprint"],
|
|
"hostname": device["hostname"],
|
|
"registration_status": (
|
|
"registered"
|
|
if device_created
|
|
else "existing"
|
|
),
|
|
"hardware_snapshot_id": hardware_snapshot_id,
|
|
},
|
|
|
|
"customer": {
|
|
"id": organization_id,
|
|
"organization_id": organization_id,
|
|
"name": organization_name,
|
|
},
|
|
"templates": templates,
|
|
}
|
|
|
|
|
|
@app.post("/api/v1/templates/{template_id}/resolve")
|
|
def resolve_template(template_id: str, payload: ResolveRequest):
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT bv.workspace_id, bv.backend_id, w.key, b.key,
|
|
bv.disk_encryption, bv.partitioning, bv.secure_boot_required
|
|
FROM bereitstellungsvorlagen bv
|
|
JOIN workspaces w ON w.id = bv.workspace_id
|
|
JOIN backends b ON b.id = bv.backend_id
|
|
WHERE bv.id = %s
|
|
""",
|
|
(template_id,),
|
|
)
|
|
template_row = cur.fetchone()
|
|
|
|
if template_row is None:
|
|
return {
|
|
"success": False,
|
|
"error": "template_not_found",
|
|
"message": "Die angeforderte Bereitstellungsvorlage wurde nicht gefunden."
|
|
}
|
|
|
|
(
|
|
workspace_id, backend_id, workspace_key, backend_key,
|
|
disk_encryption, partitioning, secure_boot_required,
|
|
) = template_row
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT m.key, bp.ansible_role
|
|
FROM workspace_merkmale wm
|
|
JOIN merkmale m ON m.id = wm.merkmal_id
|
|
JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s
|
|
WHERE wm.workspace_id = %s
|
|
""",
|
|
(backend_id, workspace_id),
|
|
)
|
|
blueprints = [
|
|
{"merkmal": key, "ansible_role": ansible_role}
|
|
for key, ansible_role in cur.fetchall()
|
|
]
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO assignments (id, device_id, bereitstellungsvorlage_id)
|
|
VALUES (%s, %s, %s)
|
|
ON CONFLICT (device_id) DO UPDATE
|
|
SET bereitstellungsvorlage_id = EXCLUDED.bereitstellungsvorlage_id
|
|
""",
|
|
(uuid4(), payload.device_id, template_id),
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"runtime_blueprint": {
|
|
"workspace_id": workspace_key,
|
|
"backend_id": backend_key,
|
|
"blueprints": blueprints,
|
|
"installation_directives": {
|
|
"disk_encryption": disk_encryption,
|
|
"partitioning": partitioning,
|
|
"secure_boot_required": secure_boot_required,
|
|
},
|
|
},
|
|
}
|
|
|
|
@app.get("/installers/fedora-workstation/ks.cfg")
|
|
def get_fedora_kickstart():
|
|
return FileResponse(
|
|
"installers/fedora-workstation/ks.cfg",
|
|
media_type="text/plain"
|
|
)
|
|
|
|
|
|
@app.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:
|
|
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()
|
|
|
|
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.",
|
|
}
|
|
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"UPDATE devices SET agent_last_checkin = %s WHERE id = %s",
|
|
(datetime.now(timezone.utc), payload.device_id),
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"blueprints": fetch_assigned_blueprints(payload.device_id),
|
|
"auftraege": fetch_auftragskatalog_state(payload.device_id),
|
|
"ansible_repo": ANSIBLE_CONTENT_REPO,
|
|
"poll_interval_seconds": AGENT_POLL_INTERVAL_SECONDS,
|
|
}
|
|
|
|
|
|
@app.post("/api/v1/agent/report")
|
|
def agent_report(
|
|
payload: AgentReportRequest,
|
|
authorization: str | None = Header(default=None),
|
|
):
|
|
if authorization is None or not authorization.startswith("Bearer "):
|
|
return {
|
|
"success": False,
|
|
"error": "unauthorized",
|
|
"message": "Fehlendes oder ungültiges Authorization-Header.",
|
|
}
|
|
|
|
provided_secret = authorization.removeprefix("Bearer ")
|
|
|
|
if not verify_agent_secret(payload.device_id, provided_secret):
|
|
return {
|
|
"success": False,
|
|
"error": "unauthorized",
|
|
"message": "Ungültiges Agent-Secret.",
|
|
}
|
|
|
|
for entry in payload.results:
|
|
if entry.event_type not in AGENT_REPORTABLE_EVENT_TYPES:
|
|
return {
|
|
"success": False,
|
|
"error": "invalid_event_type",
|
|
"message": f"Kein vom Agenten meldbarer event_type: {entry.event_type}",
|
|
}
|
|
|
|
with get_database_connection() as conn:
|
|
with conn.cursor() as cur:
|
|
merkmal_ids = {}
|
|
|
|
for entry in payload.results:
|
|
if entry.merkmal in merkmal_ids:
|
|
continue
|
|
|
|
cur.execute(
|
|
"SELECT id FROM merkmale WHERE key = %s",
|
|
(entry.merkmal,),
|
|
)
|
|
merkmal_row = cur.fetchone()
|
|
|
|
if merkmal_row is None:
|
|
return {
|
|
"success": False,
|
|
"error": "unknown_merkmal",
|
|
"message": f"Unbekanntes Merkmal: {entry.merkmal}",
|
|
}
|
|
|
|
merkmal_ids[entry.merkmal] = merkmal_row[0]
|
|
|
|
for entry in payload.results:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO device_merkmal_events (
|
|
id, device_id, merkmal_id, event_type, detail
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s)
|
|
""",
|
|
(
|
|
uuid4(),
|
|
payload.device_id,
|
|
merkmal_ids[entry.merkmal],
|
|
entry.event_type,
|
|
Jsonb(entry.detail) if entry.detail is not None else None,
|
|
),
|
|
)
|
|
|
|
return {"success": True}
|
|
|
|
|
|
@app.get("/api/v1/organizations/{organization_id}/devices")
|
|
def get_organization_devices(
|
|
organization_id: str,
|
|
authorization: str | None = Header(default=None),
|
|
):
|
|
if not require_service_token(authorization):
|
|
return {
|
|
"success": False,
|
|
"error": "unauthorized",
|
|
"message": "Fehlendes oder ungültiges Service-Token.",
|
|
}
|
|
|
|
return {"success": True, "devices": fetch_devices_for_organization(organization_id)}
|
|
|
|
|
|
@app.get("/api/v1/devices")
|
|
def list_all_devices(authorization: str | None = Header(default=None)):
|
|
if not require_service_token(authorization):
|
|
return {
|
|
"success": False,
|
|
"error": "unauthorized",
|
|
"message": "Fehlendes oder ungültiges Service-Token.",
|
|
}
|
|
|
|
return {"success": True, "devices": fetch_all_devices()}
|
|
|
|
|
|
@app.get("/api/v1/devices/{device_id}/hardware")
|
|
def get_device_hardware(device_id: str, authorization: str | None = Header(default=None)):
|
|
if not require_service_token(authorization):
|
|
return {
|
|
"success": False,
|
|
"error": "unauthorized",
|
|
"message": "Fehlendes oder ungültiges Service-Token.",
|
|
}
|
|
|
|
snapshot = fetch_latest_hardware_snapshot(device_id)
|
|
|
|
if snapshot is None:
|
|
return {
|
|
"success": False,
|
|
"error": "no_snapshot",
|
|
"message": "Für dieses Gerät liegt noch kein Hardware-Snapshot vor.",
|
|
}
|
|
|
|
return {"success": True, "hardware": snapshot}
|
|
|
|
|
|
@app.get("/api/v1/devices/{device_id}/events")
|
|
def get_device_events(device_id: str, authorization: str | None = Header(default=None)):
|
|
if not require_service_token(authorization):
|
|
return {
|
|
"success": False,
|
|
"error": "unauthorized",
|
|
"message": "Fehlendes oder ungültiges Service-Token.",
|
|
}
|
|
|
|
return {"success": True, "events": fetch_device_events(device_id)}
|
|
|
|
|
|
@app.post("/api/v1/devices/{device_id}/deprovision")
|
|
def deprovision(device_id: str, authorization: str | None = Header(default=None)):
|
|
if not require_service_token(authorization):
|
|
return {
|
|
"success": False,
|
|
"error": "unauthorized",
|
|
"message": "Fehlendes oder ungültiges Service-Token.",
|
|
}
|
|
|
|
if not deprovision_device(device_id):
|
|
return {
|
|
"success": False,
|
|
"error": "device_not_found",
|
|
"message": "Das angegebene Gerät wurde nicht gefunden.",
|
|
}
|
|
|
|
return {"success": True}
|
|
|
|
|
|
@app.post("/api/v1/devices/{device_id}/reprovision")
|
|
def reprovision(device_id: str, authorization: str | None = Header(default=None)):
|
|
if not require_service_token(authorization):
|
|
return {
|
|
"success": False,
|
|
"error": "unauthorized",
|
|
"message": "Fehlendes oder ungültiges Service-Token.",
|
|
}
|
|
|
|
if not reprovision_device(device_id):
|
|
return {
|
|
"success": False,
|
|
"error": "device_not_found",
|
|
"message": "Das angegebene Gerät wurde nicht gefunden.",
|
|
}
|
|
|
|
return {"success": True}
|
|
|
|
|
|
@app.post("/api/v1/devices/{device_id}/archive")
|
|
def archive(device_id: str, authorization: str | None = Header(default=None)):
|
|
if not require_service_token(authorization):
|
|
return {
|
|
"success": False,
|
|
"error": "unauthorized",
|
|
"message": "Fehlendes oder ungültiges Service-Token.",
|
|
}
|
|
|
|
if not archive_device(device_id):
|
|
return {
|
|
"success": False,
|
|
"error": "device_not_found",
|
|
"message": "Das angegebene Gerät wurde nicht gefunden.",
|
|
}
|
|
|
|
return {"success": True}
|
|
|
|
|
|
@app.get("/api/v1/organizations")
|
|
def list_organizations(authorization: str | None = Header(default=None)):
|
|
if not require_service_token(authorization):
|
|
return {
|
|
"success": False,
|
|
"error": "unauthorized",
|
|
"message": "Fehlendes oder ungültiges Service-Token.",
|
|
}
|
|
|
|
return {"success": True, "organizations": fetch_organizations()}
|
|
|
|
|
|
@app.post("/api/v1/organizations")
|
|
def create_organization_endpoint(
|
|
payload: CreateOrganizationRequest,
|
|
authorization: str | None = Header(default=None),
|
|
):
|
|
if not require_service_token(authorization):
|
|
return {
|
|
"success": False,
|
|
"error": "unauthorized",
|
|
"message": "Fehlendes oder ungültiges Service-Token.",
|
|
}
|
|
|
|
return {"success": True, "organization": create_organization(payload.name)}
|
|
|
|
|
|
@app.get("/api/v1/organizations/{organization_id}")
|
|
def get_organization_endpoint(organization_id: str, authorization: str | None = Header(default=None)):
|
|
if not require_service_token(authorization):
|
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
|
|
|
organization = fetch_organization(organization_id)
|
|
|
|
if organization is None:
|
|
return {"success": False, "error": "organization_not_found", "message": "Die Organisation wurde nicht gefunden."}
|
|
|
|
return {"success": True, "organization": organization}
|
|
|
|
|
|
@app.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.",
|
|
}
|
|
|
|
organization = update_organization(organization_id, payload.name)
|
|
|
|
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}/activation-codes")
|
|
def list_activation_codes(
|
|
organization_id: str,
|
|
authorization: str | None = Header(default=None),
|
|
):
|
|
if not require_service_token(authorization):
|
|
return {
|
|
"success": False,
|
|
"error": "unauthorized",
|
|
"message": "Fehlendes oder ungültiges Service-Token.",
|
|
}
|
|
|
|
return {"success": True, "activation_codes": fetch_activation_codes(organization_id)}
|
|
|
|
|
|
@app.post("/api/v1/organizations/{organization_id}/activation-codes")
|
|
def create_activation_code_endpoint(
|
|
organization_id: str,
|
|
authorization: str | None = Header(default=None),
|
|
):
|
|
if not require_service_token(authorization):
|
|
return {
|
|
"success": False,
|
|
"error": "unauthorized",
|
|
"message": "Fehlendes oder ungültiges Service-Token.",
|
|
}
|
|
|
|
return {"success": True, "activation_code": create_activation_code(organization_id)}
|
|
|
|
|
|
@app.get("/api/v1/merkmale")
|
|
def list_merkmale(authorization: str | None = Header(default=None)):
|
|
if not require_service_token(authorization):
|
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
|
|
|
return {"success": True, "merkmale": fetch_merkmale()}
|
|
|
|
|
|
@app.post("/api/v1/merkmale")
|
|
def create_merkmal_endpoint(payload: CreateMerkmalRequest, authorization: str | None = Header(default=None)):
|
|
if not require_service_token(authorization):
|
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
|
|
|
return {"success": True, "merkmal": create_merkmal(payload.key, payload.name, payload.description)}
|
|
|
|
|
|
@app.patch("/api/v1/merkmale/{merkmal_id}")
|
|
def update_merkmal_endpoint(
|
|
merkmal_id: str, payload: UpdateMerkmalRequest, authorization: str | None = Header(default=None)
|
|
):
|
|
if not require_service_token(authorization):
|
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
|
|
|
updated = update_merkmal(
|
|
merkmal_id, payload.name, payload.description, payload.kategorie_id, payload.im_auftragskatalog
|
|
)
|
|
|
|
if not updated:
|
|
return {"success": False, "error": "not_found", "message": "Merkmal wurde nicht gefunden."}
|
|
|
|
return {"success": True}
|
|
|
|
|
|
@app.get("/api/v1/kategorien")
|
|
def list_kategorien(authorization: str | None = Header(default=None)):
|
|
if not require_service_token(authorization):
|
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
|
|
|
return {"success": True, "kategorien": fetch_kategorien()}
|
|
|
|
|
|
@app.post("/api/v1/kategorien")
|
|
def create_kategorie_endpoint(payload: CreateKategorieRequest, authorization: str | None = Header(default=None)):
|
|
if not require_service_token(authorization):
|
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
|
|
|
return {
|
|
"success": True,
|
|
"kategorie": create_kategorie(payload.key, payload.name, payload.description, payload.sort_order),
|
|
}
|
|
|
|
|
|
@app.get("/api/v1/workspaces")
|
|
def list_workspaces(authorization: str | None = Header(default=None)):
|
|
if not require_service_token(authorization):
|
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
|
|
|
return {"success": True, "workspaces": fetch_workspaces()}
|
|
|
|
|
|
@app.post("/api/v1/workspaces")
|
|
def create_workspace_endpoint(payload: CreateWorkspaceRequest, authorization: str | None = Header(default=None)):
|
|
if not require_service_token(authorization):
|
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
|
|
|
return {
|
|
"success": True,
|
|
"workspace": create_workspace(payload.key, payload.name, payload.description, payload.organization_id),
|
|
}
|
|
|
|
|
|
@app.get("/api/v1/workspaces/{workspace_id}")
|
|
def get_workspace(workspace_id: str, authorization: str | None = Header(default=None)):
|
|
if not require_service_token(authorization):
|
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
|
|
|
workspace = fetch_workspace_detail(workspace_id)
|
|
|
|
if workspace is None:
|
|
return {"success": False, "error": "not_found", "message": "Workspace wurde nicht gefunden."}
|
|
|
|
return {"success": True, "workspace": workspace}
|
|
|
|
|
|
@app.post("/api/v1/workspaces/{workspace_id}/merkmale/{merkmal_id}")
|
|
def add_workspace_merkmal(workspace_id: str, merkmal_id: str, authorization: str | None = Header(default=None)):
|
|
if not require_service_token(authorization):
|
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
|
|
|
add_merkmal_to_workspace(workspace_id, merkmal_id)
|
|
|
|
return {"success": True}
|
|
|
|
|
|
@app.delete("/api/v1/workspaces/{workspace_id}/merkmale/{merkmal_id}")
|
|
def remove_workspace_merkmal(workspace_id: str, merkmal_id: str, authorization: str | None = Header(default=None)):
|
|
if not require_service_token(authorization):
|
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
|
|
|
remove_merkmal_from_workspace(workspace_id, merkmal_id)
|
|
|
|
return {"success": True}
|
|
|
|
|
|
@app.get("/api/v1/backends")
|
|
def list_backends(authorization: str | None = Header(default=None)):
|
|
if not require_service_token(authorization):
|
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
|
|
|
return {"success": True, "backends": fetch_backends()}
|
|
|
|
|
|
@app.get("/api/v1/blueprints")
|
|
def list_blueprints(authorization: str | None = Header(default=None)):
|
|
if not require_service_token(authorization):
|
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
|
|
|
return {"success": True, "blueprints": fetch_blueprints()}
|
|
|
|
|
|
@app.get("/api/v1/organizations/{organization_id}/bereitstellungsvorlagen")
|
|
def list_bereitstellungsvorlagen(organization_id: str, authorization: str | None = Header(default=None)):
|
|
if not require_service_token(authorization):
|
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
|
|
|
return {"success": True, "bereitstellungsvorlagen": fetch_templates(organization_id)}
|
|
|
|
|
|
@app.post("/api/v1/organizations/{organization_id}/bereitstellungsvorlagen")
|
|
def create_bereitstellungsvorlage_endpoint(
|
|
organization_id: str,
|
|
payload: CreateBereitstellungsvorlageRequest,
|
|
authorization: str | None = Header(default=None),
|
|
):
|
|
if not require_service_token(authorization):
|
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
|
|
|
if not payload.label.strip():
|
|
return {"success": False, "error": "invalid_request", "message": "label darf nicht leer sein."}
|
|
|
|
vorlage = create_bereitstellungsvorlage(
|
|
organization_id,
|
|
payload.workspace_id,
|
|
payload.backend_id,
|
|
payload.label,
|
|
payload.is_default,
|
|
payload.disk_encryption,
|
|
payload.root_filesystem,
|
|
payload.secure_boot_required,
|
|
)
|
|
|
|
return {"success": True, "bereitstellungsvorlage": vorlage}
|
|
|
|
|
|
@app.get("/api/v1/organizations/{organization_id}/devices/unassigned")
|
|
def list_unassigned_devices(organization_id: str, authorization: str | None = Header(default=None)):
|
|
if not require_service_token(authorization):
|
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
|
|
|
return {"success": True, "devices": fetch_unassigned_devices(organization_id)}
|
|
|
|
|
|
@app.get("/api/v1/organizations/{organization_id}/enrollment-sessions")
|
|
def list_enrollment_sessions(organization_id: str, authorization: str | None = Header(default=None)):
|
|
if not require_service_token(authorization):
|
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
|
|
|
return {"success": True, "enrollment_sessions": fetch_enrollment_sessions(organization_id)}
|
|
|
|
|
|
@app.post("/api/v1/organizations/{organization_id}/enrollment-sessions")
|
|
def create_enrollment_session_endpoint(
|
|
organization_id: str,
|
|
payload: CreateEnrollmentSessionRequest,
|
|
authorization: str | None = Header(default=None),
|
|
):
|
|
if not require_service_token(authorization):
|
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
|
|
|
session = create_enrollment_session(
|
|
organization_id, payload.bereitstellungsvorlage_id, payload.max_devices, payload.expires_at
|
|
)
|
|
|
|
return {"success": True, "enrollment_session": session}
|
|
|
|
|
|
@app.post("/api/v1/enrollment-sessions/{session_id}/revoke")
|
|
def revoke_enrollment_session_endpoint(session_id: str, authorization: str | None = Header(default=None)):
|
|
if not require_service_token(authorization):
|
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
|
|
|
revoked = revoke_enrollment_session(session_id)
|
|
|
|
if not revoked:
|
|
return {"success": False, "error": "not_found", "message": "Enrollment Session wurde nicht gefunden oder ist bereits widerrufen."}
|
|
|
|
return {"success": True}
|
|
|
|
|
|
@app.patch("/api/v1/enrollment-sessions/{session_id}")
|
|
def recharge_enrollment_session_endpoint(
|
|
session_id: str,
|
|
payload: RechargeEnrollmentSessionRequest,
|
|
authorization: str | None = Header(default=None),
|
|
):
|
|
if not require_service_token(authorization):
|
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
|
|
|
if payload.additional_devices < 0:
|
|
return {"success": False, "error": "invalid_request", "message": "additional_devices darf nicht negativ sein."}
|
|
|
|
recharged = recharge_enrollment_session(session_id, payload.additional_devices, payload.expires_at)
|
|
|
|
if not recharged:
|
|
return {"success": False, "error": "not_found", "message": "Enrollment Session wurde nicht gefunden oder ist widerrufen."}
|
|
|
|
return {"success": True}
|
|
|
|
|
|
@app.get("/api/v1/organizations/{organization_id}/iso-builds")
|
|
def list_iso_builds(organization_id: str, authorization: str | None = Header(default=None)):
|
|
if not require_service_token(authorization):
|
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
|
|
|
return {"success": True, "iso_builds": fetch_iso_builds(organization_id)}
|
|
|
|
|
|
@app.post("/api/v1/organizations/{organization_id}/iso-builds")
|
|
def create_iso_build_endpoint(
|
|
organization_id: str,
|
|
payload: CreateIsoBuildRequest,
|
|
authorization: str | None = Header(default=None),
|
|
):
|
|
if not require_service_token(authorization):
|
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
|
|
|
if not payload.activation_code.strip():
|
|
return {"success": False, "error": "invalid_request", "message": "activation_code darf nicht leer sein."}
|
|
|
|
ISO_BUILD_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
iso_build = create_iso_build(organization_id)
|
|
|
|
thread = threading.Thread(
|
|
target=run_iso_build,
|
|
args=(UUID(iso_build["id"]), organization_id, payload.activation_code, payload.wifi_ssid, payload.wifi_psk),
|
|
daemon=True,
|
|
)
|
|
thread.start()
|
|
|
|
return {"success": True, "iso_build": iso_build}
|
|
|
|
|
|
@app.get("/api/v1/iso-builds/{build_id}")
|
|
def get_iso_build_endpoint(build_id: str, authorization: str | None = Header(default=None)):
|
|
if not require_service_token(authorization):
|
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
|
|
|
iso_build = fetch_iso_build(build_id)
|
|
|
|
if iso_build is None:
|
|
return {"success": False, "error": "not_found", "message": "ISO-Build wurde nicht gefunden."}
|
|
|
|
return {"success": True, "iso_build": iso_build}
|
|
|
|
|
|
@app.get("/api/v1/iso-builds/{build_id}/download")
|
|
def download_iso_build(build_id: str, authorization: str | None = Header(default=None)):
|
|
if not require_service_token(authorization):
|
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
|
|
|
iso_build = fetch_iso_build(build_id)
|
|
|
|
if iso_build is None:
|
|
return {"success": False, "error": "not_found", "message": "ISO-Build wurde nicht gefunden."}
|
|
|
|
if iso_build["status"] != "completed" or not iso_build["output_filename"]:
|
|
return {"success": False, "error": "not_ready", "message": "Der ISO-Build ist noch nicht abgeschlossen."}
|
|
|
|
output_path = ISO_BUILD_OUTPUT_DIR / iso_build["output_filename"]
|
|
|
|
if not output_path.is_file():
|
|
return {"success": False, "error": "file_missing", "message": "Die ISO-Datei wurde nicht gefunden."}
|
|
|
|
return FileResponse(
|
|
output_path,
|
|
media_type="application/octet-stream",
|
|
filename="tuxflotte-installationsmedium.iso",
|
|
)
|
|
|
|
|
|
@app.get("/api/v1/devices/{device_id}/auftragskatalog")
|
|
def get_device_auftragskatalog(
|
|
device_id: str,
|
|
authorization: str | None = Header(default=None),
|
|
):
|
|
if not require_service_token(authorization):
|
|
return {
|
|
"success": False,
|
|
"error": "unauthorized",
|
|
"message": "Fehlendes oder ungültiges Admin-Token.",
|
|
}
|
|
|
|
katalog = fetch_auftragskatalog_listing(device_id)
|
|
|
|
if katalog is None:
|
|
return {
|
|
"success": False,
|
|
"error": "device_not_assigned",
|
|
"message": "Das Gerät hat keine aktive Bereitstellungsvorlagen-Zuweisung.",
|
|
}
|
|
|
|
return {"success": True, "auftragskatalog": katalog}
|
|
|
|
|
|
@app.post("/api/v1/devices/{device_id}/auftragskatalog/{merkmal_key}/select")
|
|
def select_auftrag(
|
|
device_id: str,
|
|
merkmal_key: str,
|
|
payload: AuftragSelectRequest,
|
|
authorization: str | None = Header(default=None),
|
|
):
|
|
if not require_service_token(authorization):
|
|
return {
|
|
"success": False,
|
|
"error": "unauthorized",
|
|
"message": "Fehlendes oder ungültiges Admin-Token.",
|
|
}
|
|
|
|
if not set_auftrag_selection(device_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/devices/{device_id}/auftragskatalog/{merkmal_key}/deselect")
|
|
def deselect_auftrag(
|
|
device_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 Admin-Token.",
|
|
}
|
|
|
|
if not set_auftrag_selection(device_id, merkmal_key, False, {}):
|
|
return {
|
|
"success": False,
|
|
"error": "unknown_merkmal",
|
|
"message": f"Kein katalogfähiges Merkmal mit Key: {merkmal_key}",
|
|
}
|
|
|
|
return {"success": True}
|
|
|
|
|
|
# --- Organisationseinheiten (OEs) ---
|
|
|
|
@app.get("/api/v1/organizations/{organization_id}/organisationseinheiten")
|
|
def list_organisationseinheiten(organization_id: str, authorization: str | None = Header(default=None)):
|
|
if not require_service_token(authorization):
|
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
|
|
|
return {"success": True, "organisationseinheiten": fetch_organisationseinheiten(organization_id)}
|
|
|
|
|
|
@app.post("/api/v1/organizations/{organization_id}/organisationseinheiten")
|
|
def create_organisationseinheit_endpoint(
|
|
organization_id: str,
|
|
payload: CreateOrganisationseinheitRequest,
|
|
authorization: str | None = Header(default=None),
|
|
):
|
|
if not require_service_token(authorization):
|
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
|
|
|
oe = create_organisationseinheit(organization_id, payload.name, payload.parent_id)
|
|
|
|
if oe is None:
|
|
return {
|
|
"success": False,
|
|
"error": "invalid_parent",
|
|
"message": "Die übergeordnete OE wurde nicht gefunden oder gehört zu einer anderen Organisation.",
|
|
}
|
|
|
|
return {"success": True, "organisationseinheit": oe}
|
|
|
|
|
|
@app.patch("/api/v1/organisationseinheiten/{oe_id}")
|
|
def update_organisationseinheit_endpoint(
|
|
oe_id: str,
|
|
payload: UpdateOrganisationseinheitRequest,
|
|
authorization: str | None = Header(default=None),
|
|
):
|
|
if not require_service_token(authorization):
|
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
|
|
|
oe = update_organisationseinheit(oe_id, payload.name, payload.parent_id)
|
|
|
|
if oe is None:
|
|
return {
|
|
"success": False,
|
|
"error": "invalid_move",
|
|
"message": "OE nicht gefunden, übergeordnete OE gehört zu einer anderen Organisation, oder das würde einen Zyklus erzeugen.",
|
|
}
|
|
|
|
return {"success": True, "organisationseinheit": oe}
|
|
|
|
|
|
@app.delete("/api/v1/organisationseinheiten/{oe_id}")
|
|
def delete_organisationseinheit_endpoint(oe_id: str, authorization: str | None = Header(default=None)):
|
|
if not require_service_token(authorization):
|
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
|
|
|
deleted, fehler = delete_organisationseinheit(oe_id)
|
|
|
|
if not deleted:
|
|
return {"success": False, "error": "delete_blocked", "message": fehler}
|
|
|
|
return {"success": True}
|
|
|
|
|
|
@app.post("/api/v1/organisationseinheiten/{oe_id}/merkmale/{merkmal_key}/select")
|
|
def select_oe_merkmal(
|
|
oe_id: str,
|
|
merkmal_key: str,
|
|
authorization: str | None = Header(default=None),
|
|
):
|
|
# Kein Payload-Body wie beim geräteweisen select (siehe
|
|
# AuftragSelectRequest/OPTIONEN_FELDER in kundenplattform) - die
|
|
# OE-/Gruppen-Editoren bieten keine Merkmal-Optionen an, nur
|
|
# aktiv/inaktiv/nicht festgelegt.
|
|
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, {}):
|
|
return {"success": False, "error": "unknown_merkmal", "message": f"Kein katalogfähiges Merkmal mit Key: {merkmal_key}"}
|
|
|
|
return {"success": True}
|
|
|
|
|
|
@app.post("/api/v1/organisationseinheiten/{oe_id}/merkmale/{merkmal_key}/deselect")
|
|
def deselect_oe_merkmal(oe_id: str, merkmal_key: str, authorization: str | None = Header(default=None)):
|
|
if not require_service_token(authorization):
|
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
|
|
|
if not set_oe_auswahl(oe_id, merkmal_key, False, {}):
|
|
return {"success": False, "error": "unknown_merkmal", "message": f"Kein katalogfähiges Merkmal mit Key: {merkmal_key}"}
|
|
|
|
return {"success": True}
|
|
|
|
|
|
@app.post("/api/v1/organisationseinheiten/{oe_id}/merkmale/{merkmal_key}/unset")
|
|
def unset_oe_merkmal(oe_id: str, merkmal_key: str, authorization: str | None = Header(default=None)):
|
|
if not require_service_token(authorization):
|
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
|
|
|
if not clear_oe_auswahl(oe_id, merkmal_key):
|
|
return {"success": False, "error": "unknown_merkmal", "message": f"Kein katalogfähiges Merkmal mit Key: {merkmal_key}"}
|
|
|
|
return {"success": True}
|
|
|
|
|
|
@app.get("/api/v1/organisationseinheiten/{oe_id}/merkmale")
|
|
def get_oe_merkmale(oe_id: str, authorization: str | None = Header(default=None)):
|
|
if not require_service_token(authorization):
|
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
|
|
|
return {"success": True, "merkmale": fetch_oe_merkmale_listing(oe_id)}
|
|
|
|
|
|
@app.post("/api/v1/devices/{device_id}/oe")
|
|
def move_device_to_oe_endpoint(device_id: str, payload: MoveDeviceOeRequest, authorization: str | None = Header(default=None)):
|
|
if not require_service_token(authorization):
|
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
|
|
|
if not move_device_to_oe(device_id, payload.oe_id):
|
|
return {
|
|
"success": False,
|
|
"error": "invalid_move",
|
|
"message": "Gerät oder OE nicht gefunden, oder sie gehören zu unterschiedlichen Organisationen.",
|
|
}
|
|
|
|
return {"success": True}
|
|
|
|
|
|
# --- Gruppen ---
|
|
|
|
@app.get("/api/v1/organizations/{organization_id}/gruppen")
|
|
def list_gruppen(organization_id: str, authorization: str | None = Header(default=None)):
|
|
if not require_service_token(authorization):
|
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
|
|
|
return {"success": True, "gruppen": fetch_gruppen(organization_id)}
|
|
|
|
|
|
@app.post("/api/v1/organizations/{organization_id}/gruppen")
|
|
def create_gruppe_endpoint(organization_id: str, payload: CreateGruppeRequest, authorization: str | None = Header(default=None)):
|
|
if not require_service_token(authorization):
|
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
|
|
|
return {"success": True, "gruppe": create_gruppe(organization_id, payload.name)}
|
|
|
|
|
|
@app.patch("/api/v1/gruppen/{gruppe_id}")
|
|
def update_gruppe_endpoint(gruppe_id: str, payload: UpdateGruppeRequest, authorization: str | None = Header(default=None)):
|
|
if not require_service_token(authorization):
|
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
|
|
|
gruppe = update_gruppe(gruppe_id, payload.name)
|
|
|
|
if gruppe is None:
|
|
return {"success": False, "error": "gruppe_not_found", "message": "Die Gruppe wurde nicht gefunden."}
|
|
|
|
return {"success": True, "gruppe": gruppe}
|
|
|
|
|
|
@app.delete("/api/v1/gruppen/{gruppe_id}")
|
|
def delete_gruppe_endpoint(gruppe_id: str, authorization: str | None = Header(default=None)):
|
|
if not require_service_token(authorization):
|
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
|
|
|
if not delete_gruppe(gruppe_id):
|
|
return {"success": False, "error": "gruppe_not_found", "message": "Die Gruppe wurde nicht gefunden."}
|
|
|
|
return {"success": True}
|
|
|
|
|
|
@app.post("/api/v1/gruppen/{gruppe_id}/merkmale/{merkmal_key}/select")
|
|
def select_gruppe_merkmal(
|
|
gruppe_id: str,
|
|
merkmal_key: str,
|
|
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, {}):
|
|
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/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."}
|
|
|
|
if not add_device_to_gruppe(device_id, gruppe_id):
|
|
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}
|