From 34f9c9b2ef25b3d4d0388e26bf8024682d17e107 Mon Sep 17 00:00:00 2001 From: Thomas Stallinger Date: Wed, 5 Aug 2026 09:20:53 +0200 Subject: [PATCH] feat: Merkmale/Kategorien/Workspaces verwalten (Kundenplattform-Admin) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRUD-Endpoints für den Admin-Bereich "Verwaltung -> Technik/Datenbank": Merkmale (inkl. Kategorie-Zuordnung, im_auftragskatalog-Flag), Kategorien, Workspaces samt Zusammensetzung aus Merkmalen (workspace_merkmale hinzufügen/entfernen - Kernbaustein des später geplanten Workspace-Creators). Backends/Blueprints nur lesend, da deren Änderung echten Ansible-Rollen-Code voraussetzt. Co-Authored-By: Claude Sonnet 5 --- app.py | 361 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 361 insertions(+) diff --git a/app.py b/app.py index 8c0698b..8b5929d 100644 --- a/app.py +++ b/app.py @@ -65,6 +65,33 @@ class CreateOrganizationRequest(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 + + 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") @@ -692,6 +719,220 @@ def create_activation_code(organization_id: str): 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() + ] + @app.get("/health") def health(): return { @@ -1066,6 +1307,126 @@ def create_activation_code_endpoint( 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/devices/{device_id}/auftragskatalog") def get_device_auftragskatalog( device_id: str,