feat: modulare Struktur für Admin-Bereich (Schritt 1)

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 <noreply@anthropic.com>
This commit is contained in:
Thomas Stallinger 2026-08-05 08:51:46 +02:00
parent c35145ef8e
commit ed38f103f2
17 changed files with 335 additions and 200 deletions

20
anode_client.py Normal file
View File

@ -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()

204
app.py
View File

@ -1,206 +1,22 @@
import os import os
from uuid import uuid4
import bcrypt from fastapi import FastAPI
import httpx
import psycopg
from fastapi import Depends, FastAPI, Form, HTTPException, Request
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware 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", "") 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 = FastAPI(title="Tuxflotte Kundenplattform")
app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET, session_cookie="kundenplattform_session") app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET, session_cookie="kundenplattform_session")
app.mount("/static", StaticFiles(directory="static"), name="static") app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
app.include_router(auth.router)
def get_database_connection(): app.include_router(geraete.router)
if not DATABASE_URL: app.include_router(admin_technik.router)
raise RuntimeError("KUNDENPLATTFORM_DATABASE_URL ist nicht gesetzt.") app.include_router(admin_geraete.router)
app.include_router(admin_flows.router)
return psycopg.connect(DATABASE_URL) app.include_router(admin_kunden.router)
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)

73
auth.py Normal file
View File

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

13
db.py Normal file
View File

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

View File

@ -0,0 +1,5 @@
BEGIN;
ALTER TABLE benutzer ADD COLUMN ist_admin BOOLEAN NOT NULL DEFAULT FALSE;
COMMIT;

0
routers/__init__.py Normal file
View File

14
routers/admin_flows.py Normal file
View File

@ -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"}
)

14
routers/admin_geraete.py Normal file
View File

@ -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"}
)

14
routers/admin_kunden.py Normal file
View File

@ -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"}
)

14
routers/admin_technik.py Normal file
View File

@ -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"}
)

122
routers/geraete.py Normal file
View File

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

View File

@ -21,6 +21,10 @@ def main():
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--organization-id", required=True) parser.add_argument("--organization-id", required=True)
parser.add_argument("--email", 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() args = parser.parse_args()
database_url = os.environ.get("KUNDENPLATTFORM_DATABASE_URL") database_url = os.environ.get("KUNDENPLATTFORM_DATABASE_URL")
@ -40,13 +44,13 @@ def main():
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute( cur.execute(
""" """
INSERT INTO benutzer (id, organization_id, email, password_hash) INSERT INTO benutzer (id, organization_id, email, password_hash, ist_admin)
VALUES (%s, %s, %s, %s) 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__": if __name__ == "__main__":

View File

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block content %}
<nav>
<ul>
<li><a href="/admin/technik">Technik/Datenbank</a></li>
<li><a href="/admin/geraete">Geräte</a></li>
<li><a href="/admin/flows">Flows</a></li>
<li><a href="/admin/kunden">Kunden</a></li>
</ul>
</nav>
<hr>
{% block admin_content %}{% endblock %}
{% endblock %}

View File

@ -0,0 +1,6 @@
{% extends "admin/layout.html" %}
{% block title %}{{ titel }} — Tuxflotte Admin{% endblock %}
{% block admin_content %}
<h2>{{ titel }}</h2>
<p>Noch nicht umgesetzt — folgt in einem der nächsten Schritte.</p>
{% endblock %}

View File

@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Auftragskatalog {{ device.hostname or device.device_fingerprint }} — Tuxflotte{% endblock %} {% block title %}Auftragskatalog {{ device.hostname or device.device_fingerprint }} — Tuxflotte{% endblock %}
{% block content %} {% block content %}
<p><a href="/geraete">← Geräteliste</a></p> <p><a href="{{ zurueck_url }}">← Geräteliste</a></p>
<h2>Auftragskatalog: {{ device.hostname or device.device_fingerprint }}</h2> <h2>Auftragskatalog: {{ device.hostname or device.device_fingerprint }}</h2>
{% if fehler %} {% if fehler %}

View File

@ -17,7 +17,11 @@
<nav> <nav>
<ul><li><strong><a href="/geraete">Tuxflotte Kundenplattform</a></strong></li></ul> <ul><li><strong><a href="/geraete">Tuxflotte Kundenplattform</a></strong></li></ul>
{% if user %} {% if user %}
<ul><li>{{ user.email }}</li><li><a href="/logout" role="button" class="secondary outline">Abmelden</a></li></ul> <ul>
{% if user.ist_admin %}<li><a href="/admin/technik">Admin</a></li>{% endif %}
<li>{{ user.email }}</li>
<li><a href="/logout" role="button" class="secondary outline">Abmelden</a></li>
</ul>
{% endif %} {% endif %}
</nav> </nav>
</header> </header>

3
templating.py Normal file
View File

@ -0,0 +1,3 @@
from fastapi.templating import Jinja2Templates
templates = Jinja2Templates(directory="templates")