Files
3d.lehrstun.de/routes/admin.py
Hans Puettmann ae68da996f Externe Datei-Links + Power-User-Rolle + PAROL6-Demo
Block A — Externe Links:
  - projekt_datei.extern_url: eine "Datei" kann Upload (BLOB) ODER Link sein
  - Link-Route /cockpit/projekt/<id>/link, Werkstatt-UI mit Link-Feld
  - Katalog + Detail rendern Links als "Oeffnen ↗", Uploads als Download
  - Upload-Limit 30 -> 60 MB (grosse Bambu-3MF); darueber: externer Link

Block B — Power-User-Rolle:
  - profil.rolle ('schueler'|'power'); Login setzt Session-Rolle
  - Decorator power_oder_admin; ist_power-Helper
  - Login-Kacheln: Power mit 🔧 statt Tier; Admin-Profilanlage mit Rollen-Dropdown
    (Tier nur bei schueler Pflicht, Power kriegt Platzhalter-Slug 'power')
  - Cockpit: kein Check-In fuer Power, stattdessen "Power-Werkzeuge"-Karte
  - Power-Projekt-Regeln: mehrere parallel erlaubt, Abschluss direkt 'freigegeben'
  - Rechte power_oder_admin: Drucker-Verwaltung, Software-Katalog,
    Slot-fuer-SuS, inline-edit (beschraenkt auf drucker+software_seite)
  - Bleibt admin-only: SuS-Profile, Projekt-Freigaben, Admin-Dashboard
  - Power-relevante Admin-Templates: Zurueck-Link rollenabhaengig (Cockpit statt Dashboard)

Block C — PAROL6-Demo:
  - scripts/seed_demo_parol6.py: Power-Profil 'Herb' + PAROL6-Roboterarm-Projekt
  - GitHub-Repo als Link, Bauanleitung-PDF als BLOB, Cover aus 3MF-Render
  - abgeschlossen+freigegeben -> im Katalog, als Vorlage waehlbar

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 17:12:45 +02:00

984 lines
35 KiB
Python

"""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, power_oder_admin
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, p.rolle, 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.rolle 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()
rolle = (request.form.get("rolle") or "schueler").strip()
if rolle not in ("schueler", "power"):
rolle = "schueler"
# Power-User brauchen kein Tier (Login zeigt 🔧). Platzhalter-Slug,
# damit der NOT-NULL- + UNIQUE-Constraint erfuellt ist.
if rolle == "power" and not tier:
tier = "power"
if not (vorname and tier and ag_id):
flash("Bitte Vorname, Tier (bei SuS) 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, rolle) "
"VALUES (?, ?, ?, ?, 1, ?)",
(vorname, tier, pin_hash, ag_id, rolle)
)
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/<int:profil_id>/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/<int:profil_id>/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/<int:profil_id>/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)
# ===========================================================================
# Tabellen, die Power-User (ohne Admin-Session) inline editieren duerfen.
# Lektion + Einstellung bleiben Lehrer-Sache.
POWER_INLINE_TABELLEN = {"drucker", "software_seite"}
@bp.route("/inline-edit", methods=["POST"])
@power_oder_admin
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
# Power-User (nicht Admin) duerfen nur Drucker + Software inline editieren
if not session.get("admin") and tabelle not in POWER_INLINE_TABELLEN:
return jsonify(ok=False, error="Dafuer fehlen dir die Rechte"), 403
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")
@power_oder_admin
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"])
@power_oder_admin
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/<int:slot_id>/loeschen", methods=["POST"])
@power_oder_admin
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")
@power_oder_admin
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"])
@power_oder_admin
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/<int:karte_id>/bearbeiten", methods=["GET", "POST"])
@power_oder_admin
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/<int:karte_id>/aktiv", methods=["POST"])
@power_oder_admin
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/<int:karte_id>/loeschen", methods=["POST"])
@power_oder_admin
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/<int:projekt_id>")
@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/<int:projekt_id>/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/<int:projekt_id>/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/<int:projekt_id>/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/<int:karte_id>/bild", methods=["GET", "POST"])
@power_oder_admin
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/<int:karte_id>/bild/loeschen", methods=["POST"])
@power_oder_admin
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/<int:profil_id>/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")
@power_oder_admin
def admin_drucker():
"""Drucker-Verwaltung: Status setzen, Freigabe-Toggle, Links pflegen.
Power-User (fortgeschrittene SuS / Herb) duerfen das mitverwalten.
"""
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/<int:drucker_id>/status", methods=["POST"])
@power_oder_admin
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/<int:drucker_id>/freigabe", methods=["POST"])
@power_oder_admin
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"))