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>
984 lines
35 KiB
Python
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"))
|
|
|
|
|