Thomas Stallinger c4825acd61 feat: initiale Kundenplattform (Login + Auftragskatalog-Auswahl)
Neuer, von provisioning-server (anode) getrennter Dienst, siehe ADR-0011:
FastAPI + Jinja2, eigene Postgres-DB (benutzer-Tabelle), eigenes
Service-Token (TUXFLOTTE_KUNDENPLATTFORM_TOKEN) für die Kommunikation mit
anode. Org-Zugehörigkeits-Prüfung passiert hier in der Anwendung vor jedem
Aufruf gegen anode.

Umfang v1: Login, Geräteliste, Auftragskatalog geräteweise aus-/abwählen
(inkl. der standardbrowser-Option für Brave). systemd-Unit läuft unter
eigenem unprivilegiertem System-User statt root.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 17:00:03 +02:00

197 lines
6.6 KiB
Python

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.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")
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 or not bcrypt.checkpw(password.encode("utf-8"), row[2].encode("utf-8")):
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)