"""Admin-Routen: Login, Profile-Verwaltung, Inline-Edit. Etappe 1: Admin-Login + Profil-Anlage/-Loeschung + PIN-Karten-Druckbogen + Inline-Edit. Etappe 2: Drill-Down-Dashboard (V5). Etappe 6: Projekt-Review-Queue. Etappe 7: Druckauftrag-Freigabe + Slot-Priorisierung. """ import secrets from flask import Blueprint, render_template, request, redirect, url_for, session, flash, jsonify, abort from database import get_db from services.auth import pin_hashen, admin_passwort_pruefen, admin_required from tiere import TIERE, tier_emoji, tier_name bp = Blueprint("admin", __name__, url_prefix="/admin") # =========================================================================== # Whitelist fuer Inline-Edit (Pattern vom Arduino-Kurs) # =========================================================================== ERLAUBTE_INLINE_TABELLEN = { # Tabelle -> Set erlaubter Felder "lektion": {"titel", "aufgabe_md", "tipp_md", "extern_link"}, "einstellung": {"wert"}, "drucker": {"name", "notiz"}, # spaeter: # "projekt": {"titel", "beschreibung_md"} -- mit Status-Check, Etappe 6 # "software_seite": {"titel", "inhalt_md", "url", "quickstart_md"} -- Etappe 3 } # =========================================================================== # Admin-Login # =========================================================================== @bp.route("/login", methods=["GET", "POST"]) def admin_login(): """Passwort-Eingabe fuer Admin-Modus. Passwort aus .env (ADMIN_PASSWORT).""" if request.method == "POST": eingabe = (request.form.get("passwort") or "").strip() if admin_passwort_pruefen(eingabe): session["admin"] = True flash("Admin-Modus aktiv.", "success") return redirect(url_for("admin.profile_liste")) flash("Falsches Passwort.", "error") return render_template("admin_login.html") @bp.route("/logout") def admin_logout(): session.pop("admin", None) flash("Admin-Modus aus.", "success") return redirect(url_for("oeffentlich.index")) # =========================================================================== # Profil-Verwaltung (Etappe 1) # =========================================================================== @bp.route("/profile") @admin_required def profile_liste(): conn = get_db() profile = conn.execute( "SELECT p.id, p.vorname, p.tier, p.aktiv, p.erstellt_am, " " p.ag_id, ag.name AS ag_name " "FROM profil p " "LEFT JOIN ag_mitgliedschaft ag ON p.ag_id = ag.id " "ORDER BY p.aktiv DESC, p.vorname COLLATE NOCASE" ).fetchall() ags = conn.execute("SELECT id, name FROM ag_mitgliedschaft WHERE status = 'aktiv'").fetchall() conn.close() return render_template( "admin_profile.html", profile=profile, ags=ags, tiere=TIERE, ) @bp.route("/profil/neu", methods=["POST"]) @admin_required def profil_neu(): """Neues Profil anlegen. Generiert automatisch eine 4-stellige PIN, zeigt sie einmalig.""" vorname = (request.form.get("vorname") or "").strip() tier = (request.form.get("tier") or "").strip() ag_id = (request.form.get("ag_id") or "").strip() if not (vorname and tier and ag_id): flash("Bitte Vorname, Tier und AG angeben.", "error") return redirect(url_for("admin.profile_liste")) # PIN generieren: 4-stellig, mit fuehrenden Nullen pin = f"{secrets.randbelow(10000):04d}" pin_hash = pin_hashen(pin) conn = get_db() try: cur = conn.execute( "INSERT INTO profil (vorname, tier, pin_hash, ag_id, aktiv) " "VALUES (?, ?, ?, ?, 1)", (vorname, tier, pin_hash, ag_id) ) conn.commit() neue_id = cur.lastrowid except Exception as e: conn.close() flash(f"Fehler beim Anlegen: {e}", "error") return redirect(url_for("admin.profile_liste")) conn.close() # PIN als Flash zeigen — nur EINMAL sichtbar, danach nur Hash in DB flash( f"Profil '{vorname}' {tier_emoji(tier)} angelegt. " f"PIN: {pin} — bitte aufschreiben, sie wird nicht erneut gezeigt.", "success" ) return redirect(url_for("admin.profile_liste")) @bp.route("/profil//aktiv", methods=["POST"]) @admin_required def profil_aktiv_toggle(profil_id): """Profil aktiv/inaktiv schalten (statt loeschen, weil FK-Cascade die ganze Historie killen wuerde).""" conn = get_db() conn.execute( "UPDATE profil SET aktiv = CASE aktiv WHEN 1 THEN 0 ELSE 1 END WHERE id = ?", (profil_id,) ) conn.commit() conn.close() return redirect(url_for("admin.profile_liste")) @bp.route("/profil//pin-reset", methods=["POST"]) @admin_required def profil_pin_reset(profil_id): """Neue PIN generieren, einmalig zeigen.""" conn = get_db() profil = conn.execute( "SELECT id, vorname, tier FROM profil WHERE id = ?", (profil_id,) ).fetchone() if not profil: conn.close() flash("Profil nicht gefunden.", "error") return redirect(url_for("admin.profile_liste")) pin = f"{secrets.randbelow(10000):04d}" pin_hash = pin_hashen(pin) conn.execute("UPDATE profil SET pin_hash = ? WHERE id = ?", (pin_hash, profil_id)) # Lockout zuruecksetzen, falls einer aktiv war conn.execute("DELETE FROM login_lockout WHERE profil_id = ?", (profil_id,)) conn.commit() conn.close() flash( f"Neue PIN fuer '{profil['vorname']}' {tier_emoji(profil['tier'])}: {pin} " f"— bitte sofort aufschreiben.", "success" ) return redirect(url_for("admin.profile_liste")) # =========================================================================== # Inline-Edit (Etappe 1) # =========================================================================== @bp.route("/inline-edit", methods=["POST"]) @admin_required def inline_edit(): """Universal-Endpunkt fuer Inline-Edit. Prueft Whitelist + loggt Aenderung.""" tabelle = (request.form.get("tabelle") or "").strip() datensatz_id = request.form.get("id") feld = (request.form.get("feld") or "").strip() neuer_wert = request.form.get("wert", "") # Whitelist-Pruefung if tabelle not in ERLAUBTE_INLINE_TABELLEN: return jsonify(ok=False, error=f"Tabelle '{tabelle}' nicht erlaubt"), 400 if feld not in ERLAUBTE_INLINE_TABELLEN[tabelle]: return jsonify(ok=False, error=f"Feld '{feld}' nicht erlaubt"), 400 try: datensatz_id = int(datensatz_id) except (TypeError, ValueError): return jsonify(ok=False, error="Ungueltige ID"), 400 conn = get_db() # Alten Wert lesen fuer Log alt_row = conn.execute( f"SELECT {feld} AS alt FROM {tabelle} WHERE id = ?", (datensatz_id,) ).fetchone() if not alt_row: conn.close() return jsonify(ok=False, error="Datensatz nicht gefunden"), 404 alter_wert = alt_row["alt"] # Update conn.execute( f"UPDATE {tabelle} SET {feld} = ? WHERE id = ?", (neuer_wert, datensatz_id) ) # Audit-Log conn.execute( "INSERT INTO aenderung_log (tabelle, datensatz_id, feld, alter_wert, neuer_wert) " "VALUES (?, ?, ?, ?, ?)", (tabelle, datensatz_id, feld, str(alter_wert) if alter_wert is not None else "", neuer_wert) ) conn.commit() conn.close() return jsonify(ok=True, wert=neuer_wert) # =========================================================================== # Druckbogen fuer PIN-Karten (Etappe 1, CSS-Print) # =========================================================================== @bp.route("/profile/karten") @admin_required def profile_karten(): """Druckbogen mit Profil-Karten — Tier, Vorname, Platz fuer PIN handschriftlich.""" conn = get_db() profile = conn.execute( "SELECT id, vorname, tier FROM profil WHERE aktiv = 1 " "ORDER BY vorname COLLATE NOCASE" ).fetchall() conn.close() return render_template("admin_profile_karten.html", profile=profile) # =========================================================================== # Stubs fuer spaetere Etappen # =========================================================================== @bp.route("/dashboard") @admin_required def dashboard(): abort(501) # Etappe 2 @bp.route("/lektionen") @admin_required def admin_lektionen(): abort(501) # Etappe 3 @bp.route("/drucker") @admin_required def admin_drucker(): abort(501) # Etappe 5 @bp.route("/projekt-freigabe") @admin_required def projekt_freigabe(): abort(501) # Etappe 6