feat: Organisations-Einzelabruf + Enrollment-Session-Aufladen (Phase 5)

Zwei neue Endpunkte, die die Kundenplattform-Selfservice-UI (Phase 5)
braucht:

- GET /api/v1/organizations/{id}: Einzelabruf statt der bisherigen
  reinen Liste - die Liste zeigt alle Organisationen uebergreifend
  (bislang nur fuer den organisationsuebergreifenden Admin-Bereich
  genutzt) und waere fuer einen normalen Kunden ein Datenleck.

- PATCH /api/v1/enrollment-sessions/{id}: erhoeht Kontingent
  (additiv, "aufladen" statt "neu setzen") und/oder verlaengert die
  Gueltigkeit einer bestehenden Session. Eine bereits ausgeteilte,
  personalisierte Kunden-ISO bettet die Session-Id fest als
  Aktivierungscode ein - das Aufladen macht sie weiter nutzbar, ohne
  dass eine neue ISO gebaut/neu verteilt werden muss.

Lokal end-to-end verifiziert (siehe kundenplattform-Commit).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Stallinger 2026-08-07 01:36:23 +02:00
parent 9fc2c5f52f
commit 122e9fb499

77
app.py
View File

@ -122,6 +122,11 @@ class CreateEnrollmentSessionRequest(BaseModel):
expires_at: str expires_at: str
class RechargeEnrollmentSessionRequest(BaseModel):
additional_devices: int = 0
expires_at: str | None = None
class CreateIsoBuildRequest(BaseModel): class CreateIsoBuildRequest(BaseModel):
activation_code: str activation_code: str
wifi_ssid: str | None = None wifi_ssid: str | None = None
@ -773,6 +778,20 @@ def fetch_organizations():
for org_id, name, created_at in cur.fetchall() 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): def create_organization(name: str):
organization_id = uuid4() organization_id = uuid4()
@ -1132,6 +1151,31 @@ def revoke_enrollment_session(session_id: str):
) )
return cur.fetchone() is not None 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: def iso_build_row_to_dict(row) -> dict:
(build_id, organization_id, status, output_filename, error_message, (build_id, organization_id, status, output_filename, error_message,
created_at, started_at, finished_at) = row created_at, started_at, finished_at) = row
@ -1659,6 +1703,19 @@ def create_organization_endpoint(
return {"success": True, "organization": create_organization(payload.name)} 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}") @app.patch("/api/v1/organizations/{organization_id}")
def update_organization_endpoint( def update_organization_endpoint(
organization_id: str, organization_id: str,
@ -1887,6 +1944,26 @@ def revoke_enrollment_session_endpoint(session_id: str, authorization: str | Non
return {"success": True} 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") @app.get("/api/v1/organizations/{organization_id}/iso-builds")
def list_iso_builds(organization_id: str, authorization: str | None = Header(default=None)): def list_iso_builds(organization_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization): if not require_service_token(authorization):