feat: Merkmale/Kategorien/Workspaces verwalten (Kundenplattform-Admin)

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 <noreply@anthropic.com>
This commit is contained in:
Thomas Stallinger 2026-08-05 09:20:53 +02:00
parent d7956bf8f7
commit 34f9c9b2ef

361
app.py
View File

@ -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,