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.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from starlette.middleware.sessions import SessionMiddleware 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)