diff --git a/app.py b/app.py index 9145bb0..39564a8 100644 --- a/app.py +++ b/app.py @@ -123,6 +123,15 @@ class ActivationRequest(BaseModel): hardware: dict +class CreateActivationCodeRequest(BaseModel): + bezeichnung: str | None = None + gueltig_tage: int | None = None + + +class ActivationCodeStatusRequest(BaseModel): + active: bool + + class ResolveRequest(BaseModel): device_id: str @@ -627,6 +636,7 @@ def find_activation_code(code: str): FROM activation_codes ac JOIN organizations o ON o.id = ac.organization_id WHERE ac.code = %s AND ac.active + AND (ac.gueltig_bis IS NULL OR ac.gueltig_bis > now()) """, (code,), ) @@ -637,6 +647,23 @@ def find_activation_code(code: str): return {"organization_id": str(row[0]), "organization_name": row[1]} + +def mark_activation_code_used(code: str): + """Nutzung zaehlen (GUI-Runde 09.10.2026) - nur nach erfolgreicher + Aktivierung, damit die Verwaltungsseite zeigt, ob ein Code noch + gebraucht wird.""" + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE activation_codes + SET verwendet_anzahl = verwendet_anzahl + 1, zuletzt_verwendet = now() + WHERE code = %s + """, + (code,), + ) + def find_enrollment_session(code: str): """ Enrollment Sessions haben kein eigenes code-Feld - ihre id dient direkt @@ -2973,7 +3000,9 @@ def fetch_activation_codes(organization_id: str): with conn.cursor() as cur: cur.execute( """ - SELECT code, active, created_at + SELECT code, active, created_at, bezeichnung, gueltig_bis, + verwendet_anzahl, zuletzt_verwendet, + (gueltig_bis IS NOT NULL AND gueltig_bis <= now()) AS abgelaufen FROM activation_codes WHERE organization_id = %s ORDER BY created_at DESC @@ -2981,11 +3010,19 @@ def fetch_activation_codes(organization_id: str): (organization_id,), ) return [ - {"code": code, "active": active, "created_at": created_at.isoformat()} - for code, active, created_at in cur.fetchall() + { + "code": code, "active": active, "created_at": created_at.isoformat(), + "bezeichnung": bezeichnung, + "gueltig_bis": gueltig_bis.isoformat() if gueltig_bis else None, + "verwendet_anzahl": verwendet_anzahl, + "zuletzt_verwendet": zuletzt_verwendet.isoformat() if zuletzt_verwendet else None, + "abgelaufen": abgelaufen, + } + for code, active, created_at, bezeichnung, gueltig_bis, verwendet_anzahl, + zuletzt_verwendet, abgelaufen in cur.fetchall() ] -def create_activation_code(organization_id: str): +def create_activation_code(organization_id: str, bezeichnung: str | None = None, gueltig_tage: int | None = None): code = "-".join( secrets.token_hex(3).upper()[i:i + 3] for i in range(0, 6, 3) ) @@ -2993,12 +3030,35 @@ def create_activation_code(organization_id: str): 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), + """ + INSERT INTO activation_codes (code, organization_id, bezeichnung, gueltig_bis) + VALUES (%s, %s, %s, CASE WHEN %s::int IS NULL THEN NULL ELSE now() + make_interval(days => %s::int) END) + """, + (code, organization_id, bezeichnung or None, gueltig_tage, gueltig_tage), ) return {"code": code, "active": True} + +def set_activation_code_active(organization_id: str, code: str, active: bool) -> bool: + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "UPDATE activation_codes SET active = %s WHERE code = %s AND organization_id = %s", + (active, code, organization_id), + ) + return cur.rowcount == 1 + + +def delete_activation_code(organization_id: str, code: str) -> bool: + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "DELETE FROM activation_codes WHERE code = %s AND organization_id = %s", + (code, organization_id), + ) + return cur.rowcount == 1 + def fetch_merkmale(): with get_database_connection() as conn: with conn.cursor() as cur: @@ -4891,6 +4951,9 @@ def activate(payload: ActivationRequest, request: Request): payload.hardware, ) + if activation is not None: + mark_activation_code_used(payload.activation_code) + if enrollment_session is not None: consume_enrollment_session(enrollment_session["session_id"]) if enrollment_session.get("ziel_oe_id"): @@ -5644,6 +5707,7 @@ def list_activation_codes( @app.post("/api/v1/organizations/{organization_id}/activation-codes") def create_activation_code_endpoint( organization_id: str, + payload: CreateActivationCodeRequest | None = None, authorization: str | None = Header(default=None), ): if not require_service_token(authorization): @@ -5653,7 +5717,43 @@ def create_activation_code_endpoint( "message": "Fehlendes oder ungültiges Service-Token.", } - return {"success": True, "activation_code": create_activation_code(organization_id)} + bezeichnung = (payload.bezeichnung if payload else None) or None + gueltig_tage = payload.gueltig_tage if payload else None + if gueltig_tage is not None and not (1 <= gueltig_tage <= 3650): + return {"success": False, "error": "invalid_gueltigkeit", "message": "Gültigkeit muss zwischen 1 und 3650 Tagen liegen."} + + return {"success": True, "activation_code": create_activation_code(organization_id, bezeichnung, gueltig_tage)} + + +@app.post("/api/v1/organizations/{organization_id}/activation-codes/{code}/status") +def set_activation_code_status_endpoint( + organization_id: str, + code: str, + payload: ActivationCodeStatusRequest, + authorization: str | None = Header(default=None), +): + if not require_service_token(authorization): + return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} + + if not set_activation_code_active(organization_id, code, payload.active): + return {"success": False, "error": "not_found", "message": "Aktivierungscode nicht gefunden."} + + return {"success": True} + + +@app.delete("/api/v1/organizations/{organization_id}/activation-codes/{code}") +def delete_activation_code_endpoint( + organization_id: str, + code: str, + authorization: str | None = Header(default=None), +): + if not require_service_token(authorization): + return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} + + if not delete_activation_code(organization_id, code): + return {"success": False, "error": "not_found", "message": "Aktivierungscode nicht gefunden."} + + return {"success": True} @app.get("/api/v1/merkmale") diff --git a/migrations/0056_aktivierungscodes_verwaltung.sql b/migrations/0056_aktivierungscodes_verwaltung.sql new file mode 100644 index 0000000..7382de3 --- /dev/null +++ b/migrations/0056_aktivierungscodes_verwaltung.sql @@ -0,0 +1,12 @@ +BEGIN; + +-- GUI-Runde 09.10.2026 (platform-docs/roadmap/gui-runde-2026-10.md, Punkt 2): +-- Aktivierungscodes verwalten. Bisher nur code/active/created_at - weder +-- Ablauf noch Nutzung sichtbar. Alle neuen Spalten optional, bestehende +-- Codes bleiben unbegrenzt gültig. +ALTER TABLE activation_codes ADD COLUMN bezeichnung TEXT; +ALTER TABLE activation_codes ADD COLUMN gueltig_bis TIMESTAMPTZ; +ALTER TABLE activation_codes ADD COLUMN verwendet_anzahl INTEGER NOT NULL DEFAULT 0; +ALTER TABLE activation_codes ADD COLUMN zuletzt_verwendet TIMESTAMPTZ; + +COMMIT;