Thomas Stallinger c35145ef8e feat: Pico.css für Dark/Light-Mode und Responsivität
Klassenloses CSS-Framework (self-hosted unter static/, kein CDN), passt
zum bestehenden Ansatz ohne JS-Build-Schritt. Bringt automatisches
Dark/Light-Mode (prefers-color-scheme) und Responsivität ohne
Zusatzaufwand. Templates auf Picos semantische Konventionen umgestellt
(label/input-Paare, article für die Login-Karte, figure um Tabellen für
horizontales Scrollen). Eigene CSS-Regeln auf die zwei Farb-Utility-
Klassen (state-present/state-absent) reduziert, Rest kommt von Pico.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 08:16:43 +02:00

207 lines
7.0 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.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)