From ed38f103f28ea100f9364ac05dfcb50d498c14a0 Mon Sep 17 00:00:00 2001 From: Thomas Stallinger Date: Wed, 5 Aug 2026 08:51:46 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20modulare=20Struktur=20f=C3=BCr=20Admin-?= =?UTF-8?q?Bereich=20(Schritt=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app.py auf APIRouter-Module umgestellt (db.py, templating.py, anode_client.py, auth.py, routers/geraete.py + vier Admin-Router als Platzhalter). benutzer.ist_admin (Migration 0002) + require_admin- Dependency (403 ohne Admin-Flag). Admin-Nav-Link im Header nur für Admin-Konten sichtbar. create_user.py um --admin-Flag erweitert. Die vier Admin-Bereiche (Technik/Datenbank, Geräte, Flows, Kunden) sind als Platzhalterseiten unter /admin/... verdrahtet und bekommen in den nächsten Schritten echte Funktionalität. Co-Authored-By: Claude Sonnet 5 --- anode_client.py | 20 +++ app.py | 204 ++----------------------------- auth.py | 73 +++++++++++ db.py | 13 ++ migrations/0002_admin_rolle.sql | 5 + routers/__init__.py | 0 routers/admin_flows.py | 14 +++ routers/admin_geraete.py | 14 +++ routers/admin_kunden.py | 14 +++ routers/admin_technik.py | 14 +++ routers/geraete.py | 122 ++++++++++++++++++ scripts/create_user.py | 12 +- templates/admin/layout.html | 13 ++ templates/admin/platzhalter.html | 6 + templates/auftragskatalog.html | 2 +- templates/base.html | 6 +- templating.py | 3 + 17 files changed, 335 insertions(+), 200 deletions(-) create mode 100644 anode_client.py create mode 100644 auth.py create mode 100644 db.py create mode 100644 migrations/0002_admin_rolle.sql create mode 100644 routers/__init__.py create mode 100644 routers/admin_flows.py create mode 100644 routers/admin_geraete.py create mode 100644 routers/admin_kunden.py create mode 100644 routers/admin_technik.py create mode 100644 routers/geraete.py create mode 100644 templates/admin/layout.html create mode 100644 templates/admin/platzhalter.html create mode 100644 templating.py diff --git a/anode_client.py b/anode_client.py new file mode 100644 index 0000000..5ca591f --- /dev/null +++ b/anode_client.py @@ -0,0 +1,20 @@ +import os + +import httpx + + +ANODE_URL = os.environ.get("TUXFLOTTE_ANODE_URL", "http://127.0.0.1:8080") +KUNDENPLATTFORM_TOKEN = os.environ.get("TUXFLOTTE_KUNDENPLATTFORM_TOKEN", "") + + +def anode_request(method: str, path: str, **kwargs) -> dict: + response = httpx.request( + method, + ANODE_URL + path, + headers={"Authorization": f"Bearer {KUNDENPLATTFORM_TOKEN}"}, + timeout=15, + **kwargs, + ) + response.raise_for_status() + + return response.json() diff --git a/app.py b/app.py index 529ec07..7d77684 100644 --- a/app.py +++ b/app.py @@ -1,206 +1,22 @@ import os -from uuid import uuid4 -import bcrypt -import httpx -import psycopg -from fastapi import Depends, FastAPI, Form, HTTPException, Request -from fastapi.responses import RedirectResponse +from fastapi import FastAPI from fastapi.staticfiles import StaticFiles -from fastapi.templating import Jinja2Templates from starlette.middleware.sessions import SessionMiddleware +import auth +from routers import admin_flows, admin_geraete, admin_kunden, admin_technik, geraete + -ANODE_URL = os.environ.get("TUXFLOTTE_ANODE_URL", "http://127.0.0.1:8080") -KUNDENPLATTFORM_TOKEN = os.environ.get("TUXFLOTTE_KUNDENPLATTFORM_TOKEN", "") -DATABASE_URL = os.environ.get("KUNDENPLATTFORM_DATABASE_URL") SESSION_SECRET = os.environ.get("KUNDENPLATTFORM_SESSION_SECRET", "") -# Merkmale, für die die UI eine zusätzliche Auswahl-Option anbietet (siehe -# ADR-0010 optionen-Mechanismus). Bewusst hier als kleine, explizite Map -# statt eines generischen Options-Schemas - nur ein Fall existiert aktuell. -OPTIONEN_FELDER = { - "browser-brave": [ - {"key": "standardbrowser", "label": "Als Standardbrowser festlegen"}, - ], -} - app = FastAPI(title="Tuxflotte Kundenplattform") app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET, session_cookie="kundenplattform_session") app.mount("/static", StaticFiles(directory="static"), name="static") -templates = Jinja2Templates(directory="templates") - -def get_database_connection(): - if not DATABASE_URL: - raise RuntimeError("KUNDENPLATTFORM_DATABASE_URL ist nicht gesetzt.") - - return psycopg.connect(DATABASE_URL) - - -def anode_request(method: str, path: str, **kwargs) -> dict: - response = httpx.request( - method, - ANODE_URL + path, - headers={"Authorization": f"Bearer {KUNDENPLATTFORM_TOKEN}"}, - timeout=15, - **kwargs, - ) - response.raise_for_status() - - return response.json() - - -def get_current_user(request: Request) -> dict: - user = request.session.get("user") - - if user is None: - raise HTTPException(status_code=303, headers={"Location": "/login"}) - - return user - - -def find_device_in_organization(device_id: str, organization_id: str) -> dict | None: - """ - Besitz-Validierung (siehe ADR-0011): ein device_id-Wert aus der URL wird - nur akzeptiert, wenn er tatsächlich in der Geräteliste der eigenen - Organisation auftaucht - verhindert, dass ein eingeloggter Kunde per - manipulierter URL auf fremde Geräte zugreift. - """ - - result = anode_request("GET", f"/api/v1/organizations/{organization_id}/devices") - devices = result.get("devices", []) if result.get("success") else [] - - for device in devices: - if device["id"] == device_id: - return device - - return None - - -@app.get("/") -def index(user: dict = Depends(get_current_user)): - return RedirectResponse("/geraete", status_code=303) - - -@app.get("/login") -def login_form(request: Request): - if request.session.get("user") is not None: - return RedirectResponse("/geraete", status_code=303) - - return templates.TemplateResponse(request, "login.html", {"error": None}) - - -@app.post("/login") -def login_submit(request: Request, email: str = Form(...), password: str = Form(...)): - with get_database_connection() as conn: - with conn.cursor() as cur: - cur.execute( - "SELECT id, organization_id, password_hash FROM benutzer WHERE email = %s", - (email,), - ) - row = cur.fetchone() - - if row is None: - return templates.TemplateResponse( - request, "login.html", {"error": "E-Mail oder Passwort ist falsch."}, status_code=401 - ) - - # psycopg liefert TEXT-Spalten hier als bytes zurück, nicht als str. - stored_hash = row[2] if isinstance(row[2], bytes) else row[2].encode("utf-8") - - if not bcrypt.checkpw(password.encode("utf-8"), stored_hash): - return templates.TemplateResponse( - request, "login.html", {"error": "E-Mail oder Passwort ist falsch."}, status_code=401 - ) - - request.session["user"] = { - "id": str(row[0]), - "organization_id": str(row[1]), - "email": email, - } - - return RedirectResponse("/geraete", status_code=303) - - -@app.get("/logout") -def logout(request: Request): - request.session.clear() - - return RedirectResponse("/login", status_code=303) - - -@app.get("/geraete") -def geraete_liste(request: Request, user: dict = Depends(get_current_user)): - result = anode_request("GET", f"/api/v1/organizations/{user['organization_id']}/devices") - devices = result.get("devices", []) if result.get("success") else [] - - return templates.TemplateResponse( - request, "geraete_liste.html", {"user": user, "devices": devices} - ) - - -@app.get("/geraete/{device_id}") -def auftragskatalog_ansicht(request: Request, device_id: str, user: dict = Depends(get_current_user)): - device = find_device_in_organization(device_id, user["organization_id"]) - - if device is None: - raise HTTPException(status_code=404, detail="Gerät wurde nicht gefunden.") - - result = anode_request("GET", f"/api/v1/devices/{device_id}/auftragskatalog") - katalog = result.get("auftragskatalog", []) if result.get("success") else [] - fehler = None if result.get("success") else result.get("message") - - # Bereits nach kategorie.sort_order sortiert (siehe anode-Query) - hier - # nur noch gruppieren, kein erneutes Sortieren (würde die Reihenfolge - # durcheinanderbringen). - kategorien: dict[str, list[dict]] = {} - for eintrag in katalog: - kategorie_name = eintrag["kategorie"]["name"] if eintrag["kategorie"] else "Ohne Kategorie" - kategorien.setdefault(kategorie_name, []).append(eintrag) - - return templates.TemplateResponse( - request, - "auftragskatalog.html", - { - "user": user, - "device": device, - "kategorien": kategorien, - "fehler": fehler, - "optionen_felder": OPTIONEN_FELDER, - }, - ) - - -@app.post("/geraete/{device_id}/auftragskatalog/{merkmal_key}/select") -async def auftrag_select( - request: Request, device_id: str, merkmal_key: str, user: dict = Depends(get_current_user) -): - if find_device_in_organization(device_id, user["organization_id"]) is None: - raise HTTPException(status_code=404, detail="Gerät wurde nicht gefunden.") - - form = await request.form() - optionen = { - feld["key"]: (form.get(f"optionen.{feld['key']}") == "on") - for feld in OPTIONEN_FELDER.get(merkmal_key, []) - } - - anode_request( - "POST", - f"/api/v1/devices/{device_id}/auftragskatalog/{merkmal_key}/select", - json={"optionen": optionen}, - ) - - return RedirectResponse(f"/geraete/{device_id}", status_code=303) - - -@app.post("/geraete/{device_id}/auftragskatalog/{merkmal_key}/deselect") -def auftrag_deselect(device_id: str, merkmal_key: str, user: dict = Depends(get_current_user)): - if find_device_in_organization(device_id, user["organization_id"]) is None: - raise HTTPException(status_code=404, detail="Gerät wurde nicht gefunden.") - - anode_request( - "POST", f"/api/v1/devices/{device_id}/auftragskatalog/{merkmal_key}/deselect" - ) - - return RedirectResponse(f"/geraete/{device_id}", status_code=303) +app.include_router(auth.router) +app.include_router(geraete.router) +app.include_router(admin_technik.router) +app.include_router(admin_geraete.router) +app.include_router(admin_flows.router) +app.include_router(admin_kunden.router) diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..dedfaac --- /dev/null +++ b/auth.py @@ -0,0 +1,73 @@ +import bcrypt +from fastapi import APIRouter, Depends, Form, HTTPException, Request +from fastapi.responses import RedirectResponse + +from db import get_database_connection +from templating import templates + + +router = APIRouter() + + +def get_current_user(request: Request) -> dict: + user = request.session.get("user") + + if user is None: + raise HTTPException(status_code=303, headers={"Location": "/login"}) + + return user + + +def require_admin(user: dict = Depends(get_current_user)) -> dict: + if not user.get("ist_admin"): + raise HTTPException(status_code=403, detail="Kein Admin-Zugriff.") + + return user + + +@router.get("/login") +def login_form(request: Request): + if request.session.get("user") is not None: + return RedirectResponse("/geraete", status_code=303) + + return templates.TemplateResponse(request, "login.html", {"error": None}) + + +@router.post("/login") +def login_submit(request: Request, email: str = Form(...), password: str = Form(...)): + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT id, organization_id, password_hash, ist_admin FROM benutzer WHERE email = %s", + (email,), + ) + row = cur.fetchone() + + if row is None: + return templates.TemplateResponse( + request, "login.html", {"error": "E-Mail oder Passwort ist falsch."}, status_code=401 + ) + + # psycopg liefert TEXT-Spalten hier als bytes zurück, nicht als str. + stored_hash = row[2] if isinstance(row[2], bytes) else row[2].encode("utf-8") + + if not bcrypt.checkpw(password.encode("utf-8"), stored_hash): + return templates.TemplateResponse( + request, "login.html", {"error": "E-Mail oder Passwort ist falsch."}, status_code=401 + ) + + request.session["user"] = { + "id": str(row[0]), + "organization_id": str(row[1]), + "email": email, + "ist_admin": row[3], + } + + return RedirectResponse("/geraete", status_code=303) + + +@router.get("/logout") +def logout(request: Request): + request.session.clear() + + return RedirectResponse("/login", status_code=303) diff --git a/db.py b/db.py new file mode 100644 index 0000000..324ad9f --- /dev/null +++ b/db.py @@ -0,0 +1,13 @@ +import os + +import psycopg + + +DATABASE_URL = os.environ.get("KUNDENPLATTFORM_DATABASE_URL") + + +def get_database_connection(): + if not DATABASE_URL: + raise RuntimeError("KUNDENPLATTFORM_DATABASE_URL ist nicht gesetzt.") + + return psycopg.connect(DATABASE_URL) diff --git a/migrations/0002_admin_rolle.sql b/migrations/0002_admin_rolle.sql new file mode 100644 index 0000000..149e1be --- /dev/null +++ b/migrations/0002_admin_rolle.sql @@ -0,0 +1,5 @@ +BEGIN; + +ALTER TABLE benutzer ADD COLUMN ist_admin BOOLEAN NOT NULL DEFAULT FALSE; + +COMMIT; diff --git a/routers/__init__.py b/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/routers/admin_flows.py b/routers/admin_flows.py new file mode 100644 index 0000000..8aaeae4 --- /dev/null +++ b/routers/admin_flows.py @@ -0,0 +1,14 @@ +from fastapi import APIRouter, Depends, Request + +from auth import require_admin +from templating import templates + + +router = APIRouter(prefix="/admin/flows") + + +@router.get("") +def index(request: Request, user: dict = Depends(require_admin)): + return templates.TemplateResponse( + request, "admin/platzhalter.html", {"user": user, "titel": "Flows"} + ) diff --git a/routers/admin_geraete.py b/routers/admin_geraete.py new file mode 100644 index 0000000..922c51c --- /dev/null +++ b/routers/admin_geraete.py @@ -0,0 +1,14 @@ +from fastapi import APIRouter, Depends, Request + +from auth import require_admin +from templating import templates + + +router = APIRouter(prefix="/admin/geraete") + + +@router.get("") +def index(request: Request, user: dict = Depends(require_admin)): + return templates.TemplateResponse( + request, "admin/platzhalter.html", {"user": user, "titel": "Geräte"} + ) diff --git a/routers/admin_kunden.py b/routers/admin_kunden.py new file mode 100644 index 0000000..a917c8f --- /dev/null +++ b/routers/admin_kunden.py @@ -0,0 +1,14 @@ +from fastapi import APIRouter, Depends, Request + +from auth import require_admin +from templating import templates + + +router = APIRouter(prefix="/admin/kunden") + + +@router.get("") +def index(request: Request, user: dict = Depends(require_admin)): + return templates.TemplateResponse( + request, "admin/platzhalter.html", {"user": user, "titel": "Kunden"} + ) diff --git a/routers/admin_technik.py b/routers/admin_technik.py new file mode 100644 index 0000000..abda331 --- /dev/null +++ b/routers/admin_technik.py @@ -0,0 +1,14 @@ +from fastapi import APIRouter, Depends, Request + +from auth import require_admin +from templating import templates + + +router = APIRouter(prefix="/admin/technik") + + +@router.get("") +def index(request: Request, user: dict = Depends(require_admin)): + return templates.TemplateResponse( + request, "admin/platzhalter.html", {"user": user, "titel": "Technik/Datenbank"} + ) diff --git a/routers/geraete.py b/routers/geraete.py new file mode 100644 index 0000000..ac94312 --- /dev/null +++ b/routers/geraete.py @@ -0,0 +1,122 @@ +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import RedirectResponse + +from anode_client import anode_request +from auth import get_current_user +from templating import templates + + +router = APIRouter() + +# Merkmale, für die die UI eine zusätzliche Auswahl-Option anbietet (siehe +# ADR-0010 optionen-Mechanismus). Bewusst hier als kleine, explizite Map +# statt eines generischen Options-Schemas - nur ein Fall existiert aktuell. +OPTIONEN_FELDER = { + "browser-brave": [ + {"key": "standardbrowser", "label": "Als Standardbrowser festlegen"}, + ], +} + + +def find_device_in_organization(device_id: str, organization_id: str) -> dict | None: + """ + Besitz-Validierung (siehe ADR-0011): ein device_id-Wert aus der URL wird + nur akzeptiert, wenn er tatsächlich in der Geräteliste der eigenen + Organisation auftaucht - verhindert, dass ein eingeloggter Kunde per + manipulierter URL auf fremde Geräte zugreift. + """ + + result = anode_request("GET", f"/api/v1/organizations/{organization_id}/devices") + devices = result.get("devices", []) if result.get("success") else [] + + for device in devices: + if device["id"] == device_id: + return device + + return None + + +def gruppiere_nach_kategorie(katalog: list[dict]) -> dict[str, list[dict]]: + # Bereits nach kategorie.sort_order sortiert (siehe anode-Query) - hier + # nur noch gruppieren, kein erneutes Sortieren (würde die Reihenfolge + # durcheinanderbringen). + kategorien: dict[str, list[dict]] = {} + for eintrag in katalog: + kategorie_name = eintrag["kategorie"]["name"] if eintrag["kategorie"] else "Ohne Kategorie" + kategorien.setdefault(kategorie_name, []).append(eintrag) + + return kategorien + + +@router.get("/") +def index(user: dict = Depends(get_current_user)): + return RedirectResponse("/geraete", status_code=303) + + +@router.get("/geraete") +def geraete_liste(request: Request, user: dict = Depends(get_current_user)): + result = anode_request("GET", f"/api/v1/organizations/{user['organization_id']}/devices") + devices = result.get("devices", []) if result.get("success") else [] + + return templates.TemplateResponse( + request, "geraete_liste.html", {"user": user, "devices": devices} + ) + + +@router.get("/geraete/{device_id}") +def auftragskatalog_ansicht(request: Request, device_id: str, user: dict = Depends(get_current_user)): + device = find_device_in_organization(device_id, user["organization_id"]) + + if device is None: + raise HTTPException(status_code=404, detail="Gerät wurde nicht gefunden.") + + result = anode_request("GET", f"/api/v1/devices/{device_id}/auftragskatalog") + katalog = result.get("auftragskatalog", []) if result.get("success") else [] + fehler = None if result.get("success") else result.get("message") + + return templates.TemplateResponse( + request, + "auftragskatalog.html", + { + "user": user, + "device": device, + "kategorien": gruppiere_nach_kategorie(katalog), + "fehler": fehler, + "optionen_felder": OPTIONEN_FELDER, + "zurueck_url": "/geraete", + }, + ) + + +@router.post("/geraete/{device_id}/auftragskatalog/{merkmal_key}/select") +async def auftrag_select( + request: Request, device_id: str, merkmal_key: str, user: dict = Depends(get_current_user) +): + if find_device_in_organization(device_id, user["organization_id"]) is None: + raise HTTPException(status_code=404, detail="Gerät wurde nicht gefunden.") + + form = await request.form() + optionen = { + feld["key"]: (form.get(f"optionen.{feld['key']}") == "on") + for feld in OPTIONEN_FELDER.get(merkmal_key, []) + } + + anode_request( + "POST", + f"/api/v1/devices/{device_id}/auftragskatalog/{merkmal_key}/select", + json={"optionen": optionen}, + ) + + return RedirectResponse(f"/geraete/{device_id}", status_code=303) + + +@router.post("/geraete/{device_id}/auftragskatalog/{merkmal_key}/deselect") +def auftrag_deselect(device_id: str, merkmal_key: str, user: dict = Depends(get_current_user)): + if find_device_in_organization(device_id, user["organization_id"]) is None: + raise HTTPException(status_code=404, detail="Gerät wurde nicht gefunden.") + + anode_request( + "POST", f"/api/v1/devices/{device_id}/auftragskatalog/{merkmal_key}/deselect" + ) + + return RedirectResponse(f"/geraete/{device_id}", status_code=303) diff --git a/scripts/create_user.py b/scripts/create_user.py index f99f5b1..26d2004 100644 --- a/scripts/create_user.py +++ b/scripts/create_user.py @@ -21,6 +21,10 @@ def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--organization-id", required=True) parser.add_argument("--email", required=True) + parser.add_argument( + "--admin", action="store_true", + help="Konto bekommt organisationsübergreifenden Admin-Zugriff (/admin/...).", + ) args = parser.parse_args() database_url = os.environ.get("KUNDENPLATTFORM_DATABASE_URL") @@ -40,13 +44,13 @@ def main(): with conn.cursor() as cur: cur.execute( """ - INSERT INTO benutzer (id, organization_id, email, password_hash) - VALUES (%s, %s, %s, %s) + INSERT INTO benutzer (id, organization_id, email, password_hash, ist_admin) + VALUES (%s, %s, %s, %s, %s) """, - (uuid4(), args.organization_id, args.email, password_hash), + (uuid4(), args.organization_id, args.email, password_hash, args.admin), ) - print(f"Konto für {args.email} angelegt.") + print(f"Konto für {args.email} angelegt{' (Admin)' if args.admin else ''}.") if __name__ == "__main__": diff --git a/templates/admin/layout.html b/templates/admin/layout.html new file mode 100644 index 0000000..e7bd4b4 --- /dev/null +++ b/templates/admin/layout.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% block content %} + +
+{% block admin_content %}{% endblock %} +{% endblock %} diff --git a/templates/admin/platzhalter.html b/templates/admin/platzhalter.html new file mode 100644 index 0000000..edbca13 --- /dev/null +++ b/templates/admin/platzhalter.html @@ -0,0 +1,6 @@ +{% extends "admin/layout.html" %} +{% block title %}{{ titel }} — Tuxflotte Admin{% endblock %} +{% block admin_content %} +

{{ titel }}

+

Noch nicht umgesetzt — folgt in einem der nächsten Schritte.

+{% endblock %} diff --git a/templates/auftragskatalog.html b/templates/auftragskatalog.html index a7bc60d..49d8240 100644 --- a/templates/auftragskatalog.html +++ b/templates/auftragskatalog.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% block title %}Auftragskatalog {{ device.hostname or device.device_fingerprint }} — Tuxflotte{% endblock %} {% block content %} -

← Geräteliste

+

← Geräteliste

Auftragskatalog: {{ device.hostname or device.device_fingerprint }}

{% if fehler %} diff --git a/templates/base.html b/templates/base.html index 9b0d43e..c561d92 100644 --- a/templates/base.html +++ b/templates/base.html @@ -17,7 +17,11 @@ diff --git a/templating.py b/templating.py new file mode 100644 index 0000000..6176130 --- /dev/null +++ b/templating.py @@ -0,0 +1,3 @@ +from fastapi.templating import Jinja2Templates + +templates = Jinja2Templates(directory="templates")