"""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 base64 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", "manual_url", "troubleshooting_url", "gesperrt_grund"}, "software_seite": { "titel", "kurzbeschreibung", "url", "tutorial_url", "plattform", "app_store_url", "play_store_url", "bild_pfad", }, # spaeter: # "projekt": {"titel", "beschreibung_md"} -- mit Status-Check, Etappe 6 } # =========================================================================== # 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.dashboard")) 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, p.reservieren_freigeschaltet, 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//reservieren-toggle", methods=["POST"]) @admin_required def profil_reservieren_toggle(profil_id): """Lehrer schaltet die Reservierungs-Freigabe pro SuS um.""" conn = get_db() conn.execute( "UPDATE profil SET reservieren_freigeschaltet = " "CASE reservieren_freigeschaltet WHEN 1 THEN 0 ELSE 1 END WHERE id = ?", (profil_id,) ) conn.commit() row = conn.execute( "SELECT vorname, reservieren_freigeschaltet FROM profil WHERE id = ?", (profil_id,) ).fetchone() conn.close() if row: if row["reservieren_freigeschaltet"]: flash(f"{row['vorname']} darf jetzt Drucker reservieren.", "success") else: flash(f"Reservierungs-Freigabe fuer {row['vorname']} aufgehoben.", "success") 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) # =========================================================================== # Slot-Verwaltung (Iteration B2) — Lehrer reserviert, sieht, loescht # =========================================================================== @bp.route("/slots") @admin_required def slot_uebersicht(): """Uebersicht aller bevorstehenden + heute aktiven Slots.""" from datetime import datetime, timedelta conn = get_db() # Heute beendete bleiben sichtbar (manche sind erst gerade fertig) grenze = (datetime.now() - timedelta(hours=2)).isoformat() slots = conn.execute( "SELECT s.id, s.drucker_id, s.profil_id, s.start_zeit, s.ende_zeit, " " s.status, s.notiz, " " d.name AS drucker_name, d.modell, d.freigegeben, " " p.vorname, p.tier " "FROM slot s " "LEFT JOIN drucker d ON d.id = s.drucker_id " "LEFT JOIN profil p ON p.id = s.profil_id " "WHERE s.ende_zeit >= ? " "ORDER BY s.start_zeit ASC", (grenze,) ).fetchall() conn.close() return render_template("admin_slots.html", slots=slots) @bp.route("/slot/neu", methods=["GET", "POST"]) @admin_required def slot_neu_lehrer(): """Lehrer reserviert im Namen eines SuS — ignoriert das Freischalt-Flag.""" from datetime import datetime, timedelta from services.slots import ( parse_datetime_local, validiere_zeitraum, konflikt_check ) conn = get_db() profile = conn.execute( "SELECT id, vorname, tier FROM profil WHERE aktiv = 1 " "ORDER BY vorname COLLATE NOCASE, tier" ).fetchall() drucker_liste = conn.execute( "SELECT id, name, modell, freigegeben FROM drucker " "WHERE aktiv = 1 ORDER BY modell, name" ).fetchall() jetzt = datetime.now() default_start = (jetzt + timedelta(minutes=5)).replace(second=0, microsecond=0) default_ende = default_start + timedelta(hours=1) if request.method == "POST": try: profil_id = int(request.form.get("profil_id") or 0) drucker_id = int(request.form.get("drucker_id") or 0) except ValueError: profil_id = drucker_id = 0 start = parse_datetime_local(request.form.get("start_zeit") or "") ende = parse_datetime_local(request.form.get("ende_zeit") or "") notiz = (request.form.get("notiz") or "").strip() or None # Basis-Validierung if not profil_id: conn.close() flash("Bitte SuS-Profil waehlen.", "error") return redirect(url_for("admin.slot_neu_lehrer")) if not drucker_id: conn.close() flash("Bitte Drucker waehlen.", "error") return redirect(url_for("admin.slot_neu_lehrer")) drucker = conn.execute( "SELECT id, name, freigegeben FROM drucker WHERE id = ? AND aktiv = 1", (drucker_id,) ).fetchone() if not drucker: conn.close() flash("Drucker nicht gefunden.", "error") return redirect(url_for("admin.slot_neu_lehrer")) # Lehrer darf auch fuer gesperrte Drucker reservieren (z.B. nach Reparatur), # aber wir warnen kurz via Flash. if not drucker["freigegeben"]: flash(f"Achtung: '{drucker['name']}' ist gesperrt — der Slot wird trotzdem angelegt.", "success") fehler = validiere_zeitraum(start, ende, jetzt) if fehler: conn.close() flash(fehler, "error") return redirect(url_for("admin.slot_neu_lehrer")) konflikte = konflikt_check(conn, drucker_id, start, ende) if konflikte: k = konflikte[0] conn.close() flash( f"Zeitraum-Konflikt: ein anderer Slot blockiert den Drucker von " f"{k['start_zeit'][11:16]} bis {k['ende_zeit'][11:16]}.", "error" ) return redirect(url_for("admin.slot_neu_lehrer")) conn.execute( "INSERT INTO slot (drucker_id, profil_id, start_zeit, ende_zeit, status, notiz) " "VALUES (?, ?, ?, ?, 'reserviert', ?)", (drucker_id, profil_id, start.isoformat(), ende.isoformat(), notiz) ) conn.commit() conn.close() flash("Slot angelegt.", "success") return redirect(url_for("admin.slot_uebersicht")) # GET — Form conn.close() # Vorauswahl via Query-Param (drucker_id und/oder profil_id) try: vor_drucker = int(request.args.get("drucker_id", 0)) except ValueError: vor_drucker = 0 try: vor_profil = int(request.args.get("profil_id", 0)) except ValueError: vor_profil = 0 return render_template( "admin_slot_neu.html", profile=profile, drucker_liste=drucker_liste, default_start=default_start, default_ende=default_ende, vor_drucker=vor_drucker, vor_profil=vor_profil, ) @bp.route("/slot//loeschen", methods=["POST"]) @admin_required def slot_loeschen(slot_id): """Slot hart loeschen (Lehrer-Override). Ein 'stornieren'-Status haetten wir auch, aber Lehrer-Aufraeumarbeiten sind oft endgueltig. """ conn = get_db() row = conn.execute( "SELECT s.id, p.vorname FROM slot s LEFT JOIN profil p ON p.id = s.profil_id WHERE s.id = ?", (slot_id,) ).fetchone() if not row: conn.close() flash("Slot nicht gefunden.", "error") return redirect(url_for("admin.slot_uebersicht")) conn.execute("DELETE FROM slot WHERE id = ?", (slot_id,)) conn.commit() conn.close() flash(f"Slot von {row['vorname'] or '—'} geloescht.", "success") return redirect(url_for("admin.slot_uebersicht")) # =========================================================================== # Software-Sektion verwalten (Etappe 3) # =========================================================================== KATEGORIEN = ("software", "app", "generator") def _software_clean_form(form): """Liest ein Software-Formular und gibt ein dict zurueck. Leere Strings werden zu None konvertiert (sauberer in der DB). Wirft ValueError mit deutscher Meldung, wenn Pflichtfelder fehlen oder die Kategorie unbekannt ist. """ def s(name): v = (form.get(name) or "").strip() return v or None slug = s("slug") titel = s("titel") kurz = s("kurzbeschreibung") url = s("url") kategorie = s("kategorie") or "software" if not slug: raise ValueError("Slug fehlt.") if not titel: raise ValueError("Titel fehlt.") if not kurz: raise ValueError("Kurzbeschreibung fehlt.") if not url: raise ValueError("Hauptlink (URL) fehlt.") if kategorie not in KATEGORIEN: raise ValueError(f"Kategorie '{kategorie}' unbekannt.") # Sortierung: leer = ans Ende try: sortierung = int(form.get("sortierung") or 100) except ValueError: sortierung = 100 return { "slug": slug, "titel": titel, "kurzbeschreibung": kurz, "url": url, "tutorial_url": s("tutorial_url"), "plattform": s("plattform"), "app_store_url": s("app_store_url"), "play_store_url": s("play_store_url"), "bild_pfad": s("bild_pfad"), "kategorie": kategorie, "sortierung": sortierung, } @bp.route("/software") @admin_required def software_liste(): """Verwaltung der Software-Karten — Liste + Anlegen-Formular auf einer Seite.""" conn = get_db() karten = conn.execute( "SELECT id, slug, titel, kurzbeschreibung, url, tutorial_url, plattform, " " app_store_url, play_store_url, bild_pfad, kategorie, sortierung, aktiv " "FROM software_seite ORDER BY kategorie, sortierung, titel" ).fetchall() conn.close() return render_template("admin_software.html", karten=karten, kategorien=KATEGORIEN) @bp.route("/software/neu", methods=["POST"]) @admin_required def software_neu(): try: daten = _software_clean_form(request.form) except ValueError as e: flash(str(e), "error") return redirect(url_for("admin.software_liste")) conn = get_db() schon_da = conn.execute( "SELECT 1 FROM software_seite WHERE slug = ?", (daten["slug"],) ).fetchone() if schon_da: conn.close() flash(f"Slug '{daten['slug']}' ist bereits vergeben.", "error") return redirect(url_for("admin.software_liste")) conn.execute( """INSERT INTO software_seite (slug, titel, kurzbeschreibung, url, tutorial_url, plattform, app_store_url, play_store_url, bild_pfad, kategorie, sortierung, aktiv) VALUES (:slug, :titel, :kurzbeschreibung, :url, :tutorial_url, :plattform, :app_store_url, :play_store_url, :bild_pfad, :kategorie, :sortierung, 1)""", daten ) conn.commit() conn.close() flash(f"'{daten['titel']}' angelegt.", "success") return redirect(url_for("admin.software_liste")) @bp.route("/software//bearbeiten", methods=["GET", "POST"]) @admin_required def software_bearbeiten(karte_id): """Bearbeiten-Seite fuer eine Software-Karte. Gleiches Form-Layout wie /neu, aber alle Felder vorbefuellt. Slug kann hier NICHT geaendert werden (waere Inkonsistenz, wenn jemand einen Link teilt) — fuer Slug-Umbenennung muesste man loeschen + neu anlegen. """ conn = get_db() karte = conn.execute( "SELECT id, slug, titel, kurzbeschreibung, url, tutorial_url, plattform, " " app_store_url, play_store_url, bild_pfad, kategorie, sortierung, aktiv, " " (bild_blob IS NOT NULL) AS hat_bild_blob " "FROM software_seite WHERE id = ?", (karte_id,) ).fetchone() conn.close() if not karte: flash("Karte nicht gefunden.", "error") return redirect(url_for("admin.software_liste")) if request.method == "POST": try: # Slug aus der bestehenden Karte uebernehmen (kein Edit), Rest aus Form form_daten = dict(request.form) form_daten["slug"] = karte["slug"] daten = _software_clean_form(form_daten) except ValueError as e: flash(str(e), "error") return redirect(url_for("admin.software_bearbeiten", karte_id=karte_id)) conn = get_db() conn.execute( """UPDATE software_seite SET titel = :titel, kurzbeschreibung = :kurzbeschreibung, url = :url, tutorial_url = :tutorial_url, plattform = :plattform, app_store_url = :app_store_url, play_store_url = :play_store_url, bild_pfad = :bild_pfad, kategorie = :kategorie, sortierung = :sortierung WHERE id = :id""", {**daten, "id": karte_id} ) conn.commit() conn.close() flash(f"'{daten['titel']}' gespeichert.", "success") return redirect(url_for("admin.software_liste")) return render_template("admin_software_edit.html", karte=karte, kategorien=KATEGORIEN) @bp.route("/software//aktiv", methods=["POST"]) @admin_required def software_aktiv_toggle(karte_id): conn = get_db() conn.execute( "UPDATE software_seite SET aktiv = CASE aktiv WHEN 1 THEN 0 ELSE 1 END WHERE id = ?", (karte_id,) ) conn.commit() conn.close() return redirect(url_for("admin.software_liste")) @bp.route("/software//loeschen", methods=["POST"]) @admin_required def software_loeschen(karte_id): """Hart loeschen — wir haben keine FK-Beziehungen zu Software-Karten, das ist sicher.""" conn = get_db() row = conn.execute( "SELECT titel FROM software_seite WHERE id = ?", (karte_id,) ).fetchone() if not row: conn.close() flash("Karte nicht gefunden.", "error") return redirect(url_for("admin.software_liste")) conn.execute("DELETE FROM software_seite WHERE id = ?", (karte_id,)) conn.commit() conn.close() flash(f"'{row['titel']}' geloescht.", "success") return redirect(url_for("admin.software_liste")) # =========================================================================== # Projekt-Review (Selbstlern-Loop, Etappe 6) # =========================================================================== @bp.route("/projekte") @admin_required def projekte_uebersicht(): """Alle Projekte aller SuS — mit Status-Filter.""" from services.projekte import alle_projekte, status_zaehler aktueller_filter = (request.args.get("filter") or "alle").strip() if aktueller_filter not in ("alle", "wartet", "in_arbeit", "abgeschlossen", "rueckfrage"): aktueller_filter = "alle" conn = get_db() projekte = alle_projekte(conn, None if aktueller_filter == "alle" else aktueller_filter) zaehler = status_zaehler(conn) conn.close() return render_template( "admin_projekte.html", projekte=projekte, zaehler=zaehler, aktueller_filter=aktueller_filter ) @bp.route("/projekt-freigabe") @admin_required def projekt_freigabe(): """Alias auf die Uebersicht mit Filter=wartet (Kompatibilitaet zu alten Links).""" return redirect(url_for("admin.projekte_uebersicht", filter="wartet")) @bp.route("/projekt/") @admin_required def projekt_review(projekt_id): """Detail-Sicht eines Projekts mit Lehrer-Aktionen.""" from services.projekte import projekt_holen conn = get_db() p = projekt_holen(conn, projekt_id) if not p: conn.close() flash("Projekt nicht gefunden.", "error") return redirect(url_for("admin.projekt_freigabe")) profil = conn.execute( "SELECT id, vorname, tier FROM profil WHERE id = ?", (p["profil_id"],) ).fetchone() rueckfragen = conn.execute( "SELECT id, text, status, erstellt_am, erledigt_am " "FROM projekt_rueckfrage WHERE projekt_id = ? ORDER BY erstellt_am DESC", (projekt_id,) ).fetchall() conn.close() return render_template( "admin_projekt_review.html", p=p, profil=dict(profil) if profil else None, rueckfragen=rueckfragen, ) @bp.route("/projekt//freigeben", methods=["POST"]) @admin_required def projekt_freigeben(projekt_id): """Lehrer setzt freigabe_status = 'freigegeben'.""" conn = get_db() row = conn.execute( "SELECT titel FROM projekt WHERE id = ?", (projekt_id,) ).fetchone() if not row: conn.close() flash("Projekt nicht gefunden.", "error") return redirect(url_for("admin.projekt_freigabe")) conn.execute( "UPDATE projekt SET freigabe_status = 'freigegeben', " "freigegeben_am = CURRENT_TIMESTAMP WHERE id = ?", (projekt_id,) ) conn.commit() conn.close() flash(f"Projekt '{row['titel']}' freigegeben.", "success") return redirect(url_for("admin.projekt_freigabe")) @bp.route("/projekt//rueckfrage", methods=["POST"]) @admin_required def projekt_rueckfrage(projekt_id): """Lehrer stellt Rueckfrage — SuS sieht sie im Cockpit und kann sie beantworten.""" text = (request.form.get("text") or "").strip() if not text: flash("Rueckfrage darf nicht leer sein.", "error") return redirect(url_for("admin.projekt_review", projekt_id=projekt_id)) conn = get_db() row = conn.execute( "SELECT profil_id, titel FROM projekt WHERE id = ?", (projekt_id,) ).fetchone() if not row: conn.close() flash("Projekt nicht gefunden.", "error") return redirect(url_for("admin.projekt_freigabe")) conn.execute( "INSERT INTO projekt_rueckfrage (projekt_id, von_profil_id, text, status) " "VALUES (?, ?, ?, 'offen')", (projekt_id, None, text) # von_profil_id NULL = Lehrer (kein eigenes Profil) ) conn.execute( "UPDATE projekt SET freigabe_status = 'rueckfrage' WHERE id = ?", (projekt_id,) ) conn.commit() conn.close() flash(f"Rueckfrage zu '{row['titel']}' an SuS geschickt.", "success") return redirect(url_for("admin.projekt_freigabe")) @bp.route("/projekt//loeschen", methods=["POST"]) @admin_required def projekt_admin_loeschen(projekt_id): """Lehrer loescht ein Projekt hart (z.B. Penismodelle, Copyright). Audit-Log: in aenderung_log dokumentiert, weil das eine destruktive Aktion ist, die kein SuS aufrufen kann. """ conn = get_db() row = conn.execute( "SELECT titel, profil_id FROM projekt WHERE id = ?", (projekt_id,) ).fetchone() if not row: conn.close() flash("Projekt nicht gefunden.", "error") return redirect(url_for("admin.projekt_freigabe")) conn.execute( "INSERT INTO aenderung_log (tabelle, datensatz_id, feld, alter_wert, neuer_wert) " "VALUES (?, ?, ?, ?, ?)", ("projekt", projekt_id, "DELETE", row["titel"], "") ) conn.execute("DELETE FROM projekt WHERE id = ?", (projekt_id,)) conn.commit() conn.close() flash(f"Projekt '{row['titel']}' geloescht.", "success") return redirect(url_for("admin.projekt_freigabe")) # =========================================================================== # Software-Bild: Crop-Upload + Loeschen # =========================================================================== @bp.route("/software//bild", methods=["GET", "POST"]) @admin_required def software_bild(karte_id): """Bild fuer eine Software-Karte hochladen (Paste + Crop, base64 ueber JSON). GET: zeigt das Crop-Tool (analog admin_bauteil_bild.html aus arduino.lehrstun.de). POST: erwartet JSON {bild_base64: 'data:image/jpeg;base64,...'}, speichert BLOB. """ conn = get_db() karte = conn.execute( "SELECT id, slug, titel, kategorie FROM software_seite WHERE id = ?", (karte_id,) ).fetchone() conn.close() if not karte: if request.method == "POST": return jsonify(error="Karte nicht gefunden"), 404 flash("Karte nicht gefunden.", "error") return redirect(url_for("admin.software_liste")) if request.method == "POST": data = request.get_json(silent=True) or {} bild_str = data.get("bild_base64") if not bild_str: return jsonify(error="Kein Bild empfangen"), 400 # Format: 'data:image/jpeg;base64,/9j/4AAQ...' -> Mime + Roh-Base64 trennen mime = "image/jpeg" if "," in bild_str: header, bild_str = bild_str.split(",", 1) if ":" in header and ";" in header: mime = header.split(":")[1].split(";")[0] try: blob = base64.b64decode(bild_str) except Exception: return jsonify(error="Bild konnte nicht dekodiert werden"), 400 conn = get_db() conn.execute( "UPDATE software_seite SET bild_blob = ?, bild_mime = ? WHERE id = ?", (blob, mime, karte_id) ) conn.commit() conn.close() return jsonify(ok=True) return render_template("admin_software_bild.html", karte=karte) @bp.route("/software//bild/loeschen", methods=["POST"]) @admin_required def software_bild_loeschen(karte_id): conn = get_db() conn.execute( "UPDATE software_seite SET bild_blob = NULL, bild_mime = NULL WHERE id = ?", (karte_id,) ) conn.commit() conn.close() flash("Bild geloescht.", "success") return redirect(url_for("admin.software_bearbeiten", karte_id=karte_id)) # =========================================================================== # Stubs fuer spaetere Etappen # =========================================================================== @bp.route("/dashboard") @admin_required def dashboard(): """Drill-Down-Dashboard (V5). Siehe docs/lehrer-dashboard.md.""" from services.dashboard_status import ( wer_ist_heute_hier, wartet_auf_review, offene_rueckfragen, drucker_status_alle, wer_haengt_fest, dashboard_zahlen, ) from services.projekte import warten_auf_freigabe_anzahl conn = get_db() projekte_warten = warten_auf_freigabe_anzahl(conn) conn.close() return render_template( "admin_dashboard.html", heute=wer_ist_heute_hier(), review=wartet_auf_review(), rueckfragen=offene_rueckfragen(), drucker=drucker_status_alle(), haengen_fest=wer_haengt_fest(), zahlen=dashboard_zahlen(), projekte_warten=projekte_warten, ) @bp.route("/profil//notiz", methods=["POST"]) @admin_required def profil_notiz(profil_id): """V5-Quick-Action: Lehrer hinterlaesst Notiz im SuS-Cockpit.""" text = (request.form.get("text") or "").strip() typ = (request.form.get("typ") or "info").strip() if not text: flash("Notiz darf nicht leer sein.", "error") return redirect(url_for("admin.dashboard")) conn = get_db() conn.execute( "INSERT INTO lehrer_notiz (profil_id, text, typ) VALUES (?, ?, ?)", (profil_id, text, typ) ) conn.commit() conn.close() flash("Notiz hinterlegt — wird im SuS-Cockpit angezeigt.", "success") return redirect(url_for("admin.dashboard")) @bp.route("/lektionen") @admin_required def admin_lektionen(): abort(501) # Etappe 3 @bp.route("/drucker") @admin_required def admin_drucker(): """Drucker-Verwaltung: Status setzen, Freigabe-Toggle, Links pflegen.""" from services.drucker_links import drucker_links conn = get_db() rows = conn.execute( "SELECT d.id, d.name, d.modell, d.geschlossenes_gehaeuse, d.aktiv, " " d.notiz, d.freigegeben, d.gesperrt_grund, d.manual_url, d.troubleshooting_url, " " s.status, s.job_name, s.progress_pct, s.restzeit_min, " " s.fehlermeldung, s.gepollt_am, s.manuell_gesetzt " "FROM drucker d " "LEFT JOIN drucker_status s ON s.drucker_id = d.id " "ORDER BY d.modell, d.name" ).fetchall() conn.close() drucker_view = [] for d in rows: links = drucker_links(d) drucker_view.append({**dict(d), "manual": links["manual"], "troubleshooting": links["troubleshooting"]}) return render_template("admin_drucker.html", drucker_liste=drucker_view, status_optionen=DRUCKER_STATUS_OPTIONEN) # Status-Optionen (Auswahl im Lehrer-UI). Manuelle Pflege bis die Bambu-API live ist. DRUCKER_STATUS_OPTIONEN = ( ("idle", "⚪ frei"), ("printing", "🟢 druckt"), ("paused", "🟡 pausiert"), ("finished", "✅ fertig — Druck abholen"), ("failed", "🔴 Fehler"), ("offline", "⚫ offline"), ) @bp.route("/drucker//status", methods=["POST"]) @admin_required def drucker_status_setzen(drucker_id): """Lehrer setzt den Status eines Druckers manuell.""" from datetime import datetime neuer_status = (request.form.get("status") or "").strip() erlaubt = {s for s, _ in DRUCKER_STATUS_OPTIONEN} if neuer_status and neuer_status not in erlaubt: flash(f"Status '{neuer_status}' ungueltig.", "error") return redirect(url_for("admin.admin_drucker")) job_name = (request.form.get("job_name") or "").strip() or None fehlermeldung = (request.form.get("fehlermeldung") or "").strip() or None conn = get_db() # Upsert in drucker_status (PRIMARY KEY drucker_id) existiert = conn.execute( "SELECT 1 FROM drucker_status WHERE drucker_id = ?", (drucker_id,) ).fetchone() if existiert: conn.execute( "UPDATE drucker_status SET status = ?, job_name = ?, fehlermeldung = ?, " " manuell_gesetzt = 1, gepollt_am = ? " "WHERE drucker_id = ?", (neuer_status or None, job_name, fehlermeldung, datetime.now().isoformat(), drucker_id) ) else: conn.execute( "INSERT INTO drucker_status (drucker_id, status, job_name, fehlermeldung, " " manuell_gesetzt, gepollt_am) " "VALUES (?, ?, ?, ?, 1, ?)", (drucker_id, neuer_status or None, job_name, fehlermeldung, datetime.now().isoformat()) ) conn.commit() conn.close() flash("Status aktualisiert.", "success") return redirect(url_for("admin.admin_drucker")) @bp.route("/drucker//freigabe", methods=["POST"]) @admin_required def drucker_freigabe_toggle(drucker_id): """Drucker fuer Reservierung freigeben oder sperren (mit optionalem Grund).""" neuer_wert = 1 if request.form.get("freigeben") == "1" else 0 grund = (request.form.get("gesperrt_grund") or "").strip() or None # Wenn freigegeben, Grund leeren — wirkt sauberer if neuer_wert == 1: grund = None conn = get_db() conn.execute( "UPDATE drucker SET freigegeben = ?, gesperrt_grund = ? WHERE id = ?", (neuer_wert, grund, drucker_id) ) conn.commit() conn.close() if neuer_wert == 1: flash("Drucker freigegeben.", "success") else: flash("Drucker gesperrt." + (f" Grund: {grund}" if grund else ""), "success") return redirect(url_for("admin.admin_drucker"))