Software-Sektion + Drucker-Verwaltung + Slot-System + Selbstlern-Loop
Vier zusammenhaengende Iterationen, die das Funktions-Geruest der AG aufbauen.
Alle 6 Migrationen sind idempotent und legen Schema-Erweiterungen + initiale
Seeds an (Mock-Projekte nur, falls Profil 100 existiert — auf prod nicht).
Software-Sektion (Etappe 3):
- software_seite-Tabelle: schlanker Karten-Katalog (slug, titel, urls,
plattform, kategorie, sortierung, Cover-BLOB)
- 7 Initial-Karten (Bambu Studio/Handy, Printables, MakerWorld,
TinkerCAD, Onshape, Gridfinity-Generator)
- Admin-Verwaltung: Liste, Anlegen-Form, Bearbeiten, Aktiv-Toggle,
Hart-Loeschen, Bild-Upload via Crop-Tool (Strg+V/Drag&Drop, 400x400)
Drucker-Verwaltung (Etappe 5-vorgezogen):
- drucker.freigegeben + gesperrt_grund (Lehrer-Sperre fuer Reparatur)
- drucker.manual_url + troubleshooting_url (Override fuer Modell-Defaults)
- SuS-View /drucker (eingeloggt): Kacheln + Sperre-Banner + Belegung
- Admin /admin/drucker: Status-Override (idle/printing/failed/...),
Sperren/Freigeben mit Grund, Inline-Edit fuer URLs
Slot-System / Reservierung (Etappe 7-vorgezogen):
- profil.reservieren_freigeschaltet (Lehrer schaltet manuell frei,
ersetzt L1-Gate-Automatismus)
- services/slots.py: Konflikt-Check, Belegungs-Helper
- SuS-Routen /cockpit/slot/neu (mit Konflikt-Check), /cockpit/slots,
Stornieren
- Admin /admin/slots Uebersicht + /admin/slot/neu (Lehrer reserviert
fuer SuS) + Hart-Loeschen + Profil-Freigabe-Toggle
Selbstlern-Loop (Etappe 6 — Kern):
- projekt-Spalten: 3-Achsen-Eval (Gesamt + Planung + Ausfuehrung),
was_klappte_nicht, cover_blob, modellier_software_slug,
drucker_id_used, makerworld_url
- Regel: max. 1 aktives Projekt pro SuS, neues nur nach Freigabe
(auto bei zufrieden/mittel, Lehrer-Review bei gelernt)
- SuS-Werkstatt-Sektion: Titel + Beschreibung + Software-Dropdown +
Drucker-Dropdown + MakerWorld-URL + Files-Upload (drag&drop,
base64, max 30 MB)
- Cover-Bild via Crop-Tool (Re-use admin_software_bild-Pattern)
- Abschluss-Block: Pflicht Cover + mindestens 1 Datei
- Eval-Form: visuelle 3x3 Radio-Karten + 2 Freitexte
- Lehrer-Review: /admin/projekte mit Filter (wartet/in_arbeit/
abgeschlossen/rueckfrage), Aktionen freigeben/rueckfrage/loeschen
- Oeffentlicher Katalog /projekte: Sortier-Tabelle (Titel/Person/
Status/Erfolg/Datum), aufklappbare Detail mit Cover/Beschreibung/
Datei-Downloads/MakerWorld-Link
- Wiederhol-Logik: "Sowas mache ich auch" via Verknuepfung
(vorlage_projekt_id), bidirektionale Lineage-Banner
Cockpit-Refactoring:
- Projekt-Karte ganz oben (vor Onboarding) mit "Hier weitermachen"
- Check-In-Quick-Actions: nach Auswahl direkt zu /cockpit/projekt/neu,
/drucker, /lektionen, /cockpit/projekt/<aktiv>
- "Modus aendern"-Button im Eingecheckt-Banner
- Admin-Dashboard-Hub mit Karten: SuS-Profile, Software, Lektionen,
Drucker, Reservierungen, Projekte (Badge bei wartenden), Alph
Bug-Fixes:
- Admin-Login redirected jetzt auf /admin/dashboard statt /admin/profile
- app.py: neue Jinja-Filter iso_format + dauer_min
- .gitignore: .claude/ ausschliessen (lokale launch.json)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
684
routes/admin.py
684
routes/admin.py
@@ -5,6 +5,7 @@ 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
|
||||
@@ -24,10 +25,13 @@ ERLAUBTE_INLINE_TABELLEN = {
|
||||
# Tabelle -> Set erlaubter Felder
|
||||
"lektion": {"titel", "aufgabe_md", "tipp_md", "extern_link"},
|
||||
"einstellung": {"wert"},
|
||||
"drucker": {"name", "notiz"},
|
||||
"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
|
||||
# "software_seite": {"titel", "inhalt_md", "url", "quickstart_md"} -- Etappe 3
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +47,7 @@ def admin_login():
|
||||
if admin_passwort_pruefen(eingabe):
|
||||
session["admin"] = True
|
||||
flash("Admin-Modus aktiv.", "success")
|
||||
return redirect(url_for("admin.profile_liste"))
|
||||
return redirect(url_for("admin.dashboard"))
|
||||
flash("Falsches Passwort.", "error")
|
||||
return render_template("admin_login.html")
|
||||
|
||||
@@ -65,7 +69,7 @@ def profile_liste():
|
||||
conn = get_db()
|
||||
profile = conn.execute(
|
||||
"SELECT p.id, p.vorname, p.tier, p.aktiv, p.erstellt_am, "
|
||||
" p.ag_id, ag.name AS ag_name "
|
||||
" 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"
|
||||
@@ -134,6 +138,29 @@ def profil_aktiv_toggle(profil_id):
|
||||
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):
|
||||
@@ -231,6 +258,556 @@ def profile_karten():
|
||||
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/<int:slot_id>/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/<int:karte_id>/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/<int:karte_id>/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/<int:karte_id>/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/<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"])
|
||||
@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/<int:karte_id>/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
|
||||
# ===========================================================================
|
||||
@@ -247,6 +824,10 @@ def dashboard():
|
||||
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(),
|
||||
@@ -255,6 +836,7 @@ def dashboard():
|
||||
drucker=drucker_status_alle(),
|
||||
haengen_fest=wer_haengt_fest(),
|
||||
zahlen=dashboard_zahlen(),
|
||||
projekte_warten=projekte_warten,
|
||||
)
|
||||
|
||||
|
||||
@@ -287,10 +869,96 @@ def admin_lektionen():
|
||||
@bp.route("/drucker")
|
||||
@admin_required
|
||||
def admin_drucker():
|
||||
abort(501) # Etappe 5
|
||||
"""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)
|
||||
|
||||
|
||||
@bp.route("/projekt-freigabe")
|
||||
# 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"])
|
||||
@admin_required
|
||||
def projekt_freigabe():
|
||||
abort(501) # Etappe 6
|
||||
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"])
|
||||
@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"))
|
||||
|
||||
|
||||
|
||||
@@ -4,11 +4,79 @@ Etappe 5: /api/drucker/status (Live-Status fuer /drucker)
|
||||
Etappe 6: /api/projekte/suche (Filter-Live-Suche im Katalog)
|
||||
Etappe 5: /api/slots/heute (Slot-Polling)
|
||||
"""
|
||||
from flask import Blueprint, jsonify, abort
|
||||
import base64
|
||||
from flask import Blueprint, jsonify, abort, Response
|
||||
|
||||
from database import get_db
|
||||
|
||||
bp = Blueprint("api", __name__, url_prefix="/api")
|
||||
|
||||
|
||||
# 1x1 transparentes PNG als Default-Bild fuer Karten ohne BLOB
|
||||
_LEER_PNG = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQI12NgAAIABQABnil7QQAAAAlFTkSuQmCC"
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/software/<int:karte_id>/bild")
|
||||
def software_bild(karte_id):
|
||||
"""Liefert das Bild einer Software-Karte aus (BLOB aus DB).
|
||||
|
||||
Wird vom oeffentlichen Karten-Template + vom Admin-Edit-Preview genutzt.
|
||||
Wenn kein BLOB hinterlegt ist, liefert 1x1-PNG zurueck (kein 404, damit
|
||||
img-Tags nicht broken sind).
|
||||
"""
|
||||
conn = get_db()
|
||||
row = conn.execute(
|
||||
"SELECT bild_blob, bild_mime FROM software_seite WHERE id = ?", (karte_id,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
|
||||
if not row or not row["bild_blob"]:
|
||||
return Response(_LEER_PNG, mimetype="image/png")
|
||||
return Response(row["bild_blob"], mimetype=row["bild_mime"] or "image/jpeg")
|
||||
|
||||
|
||||
@bp.route("/projekt/<int:projekt_id>/datei/<int:datei_id>")
|
||||
def projekt_datei_download(projekt_id, datei_id):
|
||||
"""Datei-Download (BLOB-Stream).
|
||||
|
||||
Liefert mit Content-Disposition: attachment, damit der Browser einen
|
||||
Download-Dialog zeigt (statt das STL inline anzuzeigen).
|
||||
"""
|
||||
conn = get_db()
|
||||
row = conn.execute(
|
||||
"SELECT dateiname, mime_type, inhalt FROM projekt_datei "
|
||||
"WHERE id = ? AND projekt_id = ?",
|
||||
(datei_id, projekt_id)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if not row or not row["inhalt"]:
|
||||
abort(404)
|
||||
resp = Response(row["inhalt"], mimetype=row["mime_type"] or "application/octet-stream")
|
||||
# Dateiname url-sicher kodieren — sonst Stolperfalle bei Umlauten
|
||||
from urllib.parse import quote
|
||||
resp.headers["Content-Disposition"] = f'attachment; filename="{quote(row["dateiname"])}"'
|
||||
return resp
|
||||
|
||||
|
||||
@bp.route("/projekt/<int:projekt_id>/cover")
|
||||
def projekt_cover(projekt_id):
|
||||
"""Cover-Foto eines Projekts (BLOB) ausliefern.
|
||||
|
||||
Wird vom Cockpit + Katalog + Lehrer-Review genutzt. Wenn kein BLOB
|
||||
hinterlegt ist, liefert 1x1-PNG, damit img-Tags nicht broken sind.
|
||||
"""
|
||||
conn = get_db()
|
||||
row = conn.execute(
|
||||
"SELECT cover_blob, cover_mime FROM projekt WHERE id = ?", (projekt_id,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if not row or not row["cover_blob"]:
|
||||
return Response(_LEER_PNG, mimetype="image/png")
|
||||
return Response(row["cover_blob"], mimetype=row["cover_mime"] or "image/jpeg")
|
||||
|
||||
|
||||
@bp.route("/drucker/status")
|
||||
def drucker_status():
|
||||
"""Etappe 5: JSON aller 4 Drucker mit Status/Restzeit."""
|
||||
|
||||
@@ -4,7 +4,10 @@ Etappe 1: nur Startseite + Error-Handler portiert.
|
||||
Etappe 3: Lektionen, Software, Projekt-Katalog, Troubleshooting.
|
||||
Etappe 5: Drucker-Live-View.
|
||||
"""
|
||||
from flask import Blueprint, render_template, abort
|
||||
from flask import Blueprint, render_template, abort, session
|
||||
|
||||
from services.auth import login_required
|
||||
from services.drucker_links import drucker_links
|
||||
|
||||
bp = Blueprint("oeffentlich", __name__)
|
||||
|
||||
@@ -47,22 +50,53 @@ def lektion_detail(nummer):
|
||||
|
||||
@bp.route("/software")
|
||||
def software():
|
||||
abort(501)
|
||||
"""Software-Sektion: Karten-Uebersicht, gruppiert nach Kategorie.
|
||||
|
||||
Etappe 3 — Karten-Konzept (Inhalt: 1-2 Saetze + Links). Die SuS lernen
|
||||
den Umgang mit den Tools in den Lektionen (siehe docs/lektionen-plan.md),
|
||||
hier nur der Katalog.
|
||||
"""
|
||||
from database import get_db
|
||||
conn = get_db()
|
||||
karten = conn.execute(
|
||||
"SELECT id, slug, titel, kurzbeschreibung, url, tutorial_url, "
|
||||
" plattform, app_store_url, play_store_url, bild_pfad, kategorie, "
|
||||
" (bild_blob IS NOT NULL) AS hat_bild_blob "
|
||||
"FROM software_seite WHERE aktiv = 1 "
|
||||
"ORDER BY kategorie, sortierung, titel"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
@bp.route("/software/<slug>")
|
||||
def software_detail(slug):
|
||||
abort(501)
|
||||
# Nach Kategorie gruppieren, damit das Template einfach iterieren kann
|
||||
gruppen = {"software": [], "app": [], "generator": []}
|
||||
for k in karten:
|
||||
gruppen.setdefault(k["kategorie"], []).append(k)
|
||||
|
||||
return render_template("software.html", gruppen=gruppen)
|
||||
|
||||
|
||||
@bp.route("/projekte")
|
||||
@login_required
|
||||
def projekte():
|
||||
abort(501)
|
||||
|
||||
|
||||
@bp.route("/projekte/<int:projekt_id>")
|
||||
def projekt_detail(projekt_id):
|
||||
abort(501)
|
||||
"""Oeffentlicher Katalog (fuer eingeloggte SuS)."""
|
||||
from database import get_db
|
||||
from services.projekte import katalog_projekte, projekt_dateien_fuer_katalog, darf_neues_anlegen
|
||||
eigene_id = session["profil_id"]
|
||||
conn = get_db()
|
||||
projekte_roh = katalog_projekte(conn)
|
||||
darf_wiederholen, sperr_grund = darf_neues_anlegen(conn, eigene_id)
|
||||
projekte_view = []
|
||||
for p in projekte_roh:
|
||||
dateien = projekt_dateien_fuer_katalog(conn, p["id"])
|
||||
projekte_view.append({**dict(p), "dateien": [dict(d) for d in dateien]})
|
||||
conn.close()
|
||||
return render_template(
|
||||
"projekte_katalog.html",
|
||||
projekte=projekte_view,
|
||||
eigene_profil_id=eigene_id,
|
||||
darf_wiederholen=darf_wiederholen,
|
||||
wiederhol_sperr_grund=sperr_grund,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/troubleshooting")
|
||||
@@ -71,5 +105,47 @@ def troubleshooting():
|
||||
|
||||
|
||||
@bp.route("/drucker")
|
||||
@login_required
|
||||
def drucker():
|
||||
abort(501)
|
||||
"""Drucker-Uebersicht fuer eingeloggte SuS.
|
||||
|
||||
Zeigt alle aktiven Drucker mit Status, Manual-/Troubleshooting-Links
|
||||
und einem Banner bei Sperrung. Belegung-Anzeige (heute) folgt mit
|
||||
Iteration B (Slot-Reservierung).
|
||||
"""
|
||||
from database import get_db
|
||||
from services.slots import aktuelle_belegung, naechste_slots, profil_darf_reservieren
|
||||
|
||||
conn = get_db()
|
||||
drucker_liste = conn.execute(
|
||||
"SELECT d.id, d.name, d.modell, d.geschlossenes_gehaeuse, 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 "
|
||||
"WHERE d.aktiv = 1 "
|
||||
"ORDER BY d.modell, d.name"
|
||||
).fetchall()
|
||||
darf_reservieren = profil_darf_reservieren(conn, session.get("profil_id"))
|
||||
|
||||
# Pro Drucker: Links + aktuelle Belegung + naechste 3 Slots
|
||||
drucker_view = []
|
||||
for d in drucker_liste:
|
||||
links = drucker_links(d)
|
||||
belegung = aktuelle_belegung(conn, d["id"])
|
||||
kommend = naechste_slots(conn, d["id"], n=3)
|
||||
drucker_view.append({
|
||||
**dict(d),
|
||||
"manual": links["manual"],
|
||||
"troubleshooting": links["troubleshooting"],
|
||||
"belegung": dict(belegung) if belegung else None,
|
||||
"kommend": [dict(s) for s in kommend],
|
||||
})
|
||||
conn.close()
|
||||
|
||||
return render_template(
|
||||
"drucker.html",
|
||||
drucker_liste=drucker_view,
|
||||
darf_reservieren=darf_reservieren,
|
||||
)
|
||||
|
||||
653
routes/profil.py
653
routes/profil.py
@@ -5,7 +5,8 @@ Etappe 2: Cockpit progressiv (V1), Check-In (V3 + E-021), Lektion-Status, Paarun
|
||||
Etappe 6: Projekt-Workflow.
|
||||
Etappe 7: Druckauftraege + Slots.
|
||||
"""
|
||||
from datetime import date
|
||||
import base64
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
from flask import (
|
||||
Blueprint, render_template, request, redirect, url_for,
|
||||
@@ -137,6 +138,43 @@ def check_in():
|
||||
(session["profil_id"], date.today().isoformat(), modus, freitext)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# Quick-Action: je nach Modus direkt zum passenden Ort weiterleiten.
|
||||
# Falls die Aktion blockiert ist (z.B. neues_projekt waehrend Lehrer-Review-Wartung),
|
||||
# leitet die jeweilige Route auf /cockpit mit Flash-Meldung weiter.
|
||||
profil_id = session["profil_id"]
|
||||
if modus == "neues_projekt":
|
||||
conn.close()
|
||||
return redirect(url_for("profil.projekt_neu"))
|
||||
if modus == "drucken":
|
||||
conn.close()
|
||||
return redirect(url_for("oeffentlich.drucker"))
|
||||
if modus == "onboarding":
|
||||
conn.close()
|
||||
return redirect(url_for("oeffentlich.lektionen"))
|
||||
if modus == "weiter_wie_bisher":
|
||||
from services.projekte import aktives_projekt
|
||||
aktiv = aktives_projekt(conn, profil_id)
|
||||
conn.close()
|
||||
if aktiv:
|
||||
return redirect(url_for("profil.projekt_detail", projekt_id=aktiv["id"]))
|
||||
# Kein aktives Projekt mehr — nutzer wollte weitermachen, gibt aber nichts zum Weitermachen
|
||||
flash("Du hast gerade kein laufendes Projekt — leg ein neues an?", "success")
|
||||
return redirect(url_for("profil.cockpit"))
|
||||
conn.close()
|
||||
return redirect(url_for("profil.cockpit"))
|
||||
|
||||
|
||||
@bp.route("/cockpit/check-in/zuruecksetzen", methods=["POST"])
|
||||
@login_required
|
||||
def check_in_zuruecksetzen():
|
||||
"""Heutigen Check-In zuruecksetzen — Modal erscheint beim naechsten Cockpit wieder."""
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"DELETE FROM check_in WHERE profil_id = ? AND besuch_datum = ?",
|
||||
(session["profil_id"], date.today().isoformat())
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return redirect(url_for("profil.cockpit"))
|
||||
|
||||
@@ -333,15 +371,624 @@ def paarung_aufloesen():
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Stubs fuer spaetere Etappen
|
||||
# Slot-Reservierung (Iteration B1)
|
||||
# ===========================================================================
|
||||
|
||||
@bp.route("/cockpit/slots")
|
||||
@login_required
|
||||
def slots():
|
||||
abort(501) # Etappe 6/7
|
||||
"""Liste der eigenen Slots — bevorstehende und gerade aktive."""
|
||||
from services.slots import slots_eines_profils, profil_darf_reservieren
|
||||
conn = get_db()
|
||||
eigene = slots_eines_profils(conn, session["profil_id"], kommend_nur=True)
|
||||
darf = profil_darf_reservieren(conn, session["profil_id"])
|
||||
conn.close()
|
||||
return render_template("profil_slots.html", slots=eigene, darf_reservieren=darf)
|
||||
|
||||
|
||||
@bp.route("/cockpit/slot/neu", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def slot_neu():
|
||||
"""SuS legt einen neuen Reservierungs-Slot an."""
|
||||
from services.slots import (
|
||||
parse_datetime_local, validiere_zeitraum, konflikt_check, profil_darf_reservieren
|
||||
)
|
||||
profil_id = session["profil_id"]
|
||||
|
||||
conn = get_db()
|
||||
darf = profil_darf_reservieren(conn, profil_id)
|
||||
conn.close()
|
||||
if not darf:
|
||||
flash(
|
||||
"Reservierung ist fuer dich noch nicht freigeschaltet — bitte den Lehrer kurz darum.",
|
||||
"error"
|
||||
)
|
||||
return redirect(url_for("oeffentlich.drucker"))
|
||||
|
||||
conn = get_db()
|
||||
drucker_liste = conn.execute(
|
||||
"SELECT id, name, modell, freigegeben, gesperrt_grund "
|
||||
"FROM drucker WHERE aktiv = 1 AND freigegeben = 1 ORDER BY modell, name"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
# Default: heute, in 5 Minuten, 60 min Dauer
|
||||
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:
|
||||
drucker_id = int(request.form.get("drucker_id") or 0)
|
||||
except ValueError:
|
||||
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
|
||||
|
||||
# Drucker validieren
|
||||
conn = get_db()
|
||||
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 render_template(
|
||||
"profil_slot_neu.html",
|
||||
drucker_liste=drucker_liste,
|
||||
default_start=default_start, default_ende=default_ende,
|
||||
vorgewaehlt_drucker=drucker_id,
|
||||
eingabe_start=request.form.get("start_zeit"),
|
||||
eingabe_ende=request.form.get("ende_zeit"),
|
||||
eingabe_notiz=request.form.get("notiz"),
|
||||
)
|
||||
if not drucker["freigegeben"]:
|
||||
conn.close()
|
||||
flash(f"'{drucker['name']}' ist derzeit gesperrt. Bitte anderen Drucker waehlen.", "error")
|
||||
return redirect(url_for("profil.slot_neu"))
|
||||
|
||||
# Zeitraum validieren
|
||||
fehler = validiere_zeitraum(start, ende, jetzt)
|
||||
if fehler:
|
||||
conn.close()
|
||||
flash(fehler, "error")
|
||||
return render_template(
|
||||
"profil_slot_neu.html",
|
||||
drucker_liste=drucker_liste,
|
||||
default_start=default_start, default_ende=default_ende,
|
||||
vorgewaehlt_drucker=drucker_id,
|
||||
eingabe_start=request.form.get("start_zeit"),
|
||||
eingabe_ende=request.form.get("ende_zeit"),
|
||||
eingabe_notiz=request.form.get("notiz"),
|
||||
)
|
||||
|
||||
# Konflikt-Check
|
||||
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 render_template(
|
||||
"profil_slot_neu.html",
|
||||
drucker_liste=drucker_liste,
|
||||
default_start=default_start, default_ende=default_ende,
|
||||
vorgewaehlt_drucker=drucker_id,
|
||||
eingabe_start=request.form.get("start_zeit"),
|
||||
eingabe_ende=request.form.get("ende_zeit"),
|
||||
eingabe_notiz=request.form.get("notiz"),
|
||||
)
|
||||
|
||||
# Speichern
|
||||
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(f"Slot reserviert: {drucker['name']} von {start:%d.%m. %H:%M} bis {ende:%H:%M}.", "success")
|
||||
return redirect(url_for("profil.slots"))
|
||||
|
||||
# GET — Form anzeigen. Drucker-Vorauswahl via Query-Param (von /drucker)
|
||||
try:
|
||||
vorgewaehlt_drucker = int(request.args.get("drucker_id", 0))
|
||||
except ValueError:
|
||||
vorgewaehlt_drucker = 0
|
||||
return render_template(
|
||||
"profil_slot_neu.html",
|
||||
drucker_liste=drucker_liste,
|
||||
default_start=default_start, default_ende=default_ende,
|
||||
vorgewaehlt_drucker=vorgewaehlt_drucker,
|
||||
eingabe_start=None, eingabe_ende=None, eingabe_notiz=None,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/cockpit/slot/<int:slot_id>/stornieren", methods=["POST"])
|
||||
@login_required
|
||||
def slot_stornieren(slot_id):
|
||||
"""Eigenen Slot stornieren — nur eigene, nur wenn nicht schon beendet/storniert."""
|
||||
conn = get_db()
|
||||
slot = conn.execute(
|
||||
"SELECT id, profil_id, status FROM slot WHERE id = ?", (slot_id,)
|
||||
).fetchone()
|
||||
if not slot or slot["profil_id"] != session["profil_id"]:
|
||||
conn.close()
|
||||
flash("Slot nicht gefunden.", "error")
|
||||
return redirect(url_for("profil.slots"))
|
||||
if slot["status"] in ("storniert", "beendet"):
|
||||
conn.close()
|
||||
flash("Slot ist bereits abgeschlossen.", "error")
|
||||
return redirect(url_for("profil.slots"))
|
||||
conn.execute("UPDATE slot SET status = 'storniert' WHERE id = ?", (slot_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
flash("Slot storniert.", "success")
|
||||
return redirect(url_for("profil.slots"))
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Projekte / Selbstlern-Loop (Etappe 6 — Kern-Mechanik)
|
||||
# ===========================================================================
|
||||
#
|
||||
# Regeln: siehe services/projekte.py — kurz: max. 1 aktives Projekt, neues
|
||||
# nur nach Freigabe (auto bei Erfolg, durch Lehrer bei Misserfolg).
|
||||
|
||||
@bp.route("/cockpit/projekte")
|
||||
@login_required
|
||||
def projekte_liste():
|
||||
"""Eigene Projekt-Liste (aktiv + abgeschlossen)."""
|
||||
from services.projekte import liste_eigener, darf_neues_anlegen
|
||||
conn = get_db()
|
||||
eigene = liste_eigener(conn, session["profil_id"])
|
||||
darf, grund = darf_neues_anlegen(conn, session["profil_id"])
|
||||
conn.close()
|
||||
return render_template(
|
||||
"projekt_liste.html", projekte=eigene, darf_neues=darf, sperr_grund=grund
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/cockpit/projekt/neu", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def projekt_neu():
|
||||
"""Neues Projekt anlegen — mit Block-Check (siehe darf_neues_anlegen)."""
|
||||
from services.projekte import darf_neues_anlegen
|
||||
conn = get_db()
|
||||
darf, grund = darf_neues_anlegen(conn, session["profil_id"])
|
||||
if not darf:
|
||||
conn.close()
|
||||
flash(grund, "error")
|
||||
return redirect(url_for("profil.cockpit"))
|
||||
|
||||
if request.method == "POST":
|
||||
titel = (request.form.get("titel") or "").strip()
|
||||
beschreibung = (request.form.get("beschreibung_md") or "").strip() or None
|
||||
if not titel:
|
||||
conn.close()
|
||||
flash("Bitte einen Titel angeben.", "error")
|
||||
return render_template("projekt_neu.html", eingabe_titel="", eingabe_beschreibung=beschreibung)
|
||||
|
||||
cur = conn.execute(
|
||||
"INSERT INTO projekt (profil_id, titel, beschreibung_md, status_lebenszyklus, freigabe_status) "
|
||||
"VALUES (?, ?, ?, 'in_arbeit', 'in_arbeit')",
|
||||
(session["profil_id"], titel, beschreibung)
|
||||
)
|
||||
conn.commit()
|
||||
neue_id = cur.lastrowid
|
||||
conn.close()
|
||||
flash(f"Projekt '{titel}' angelegt — viel Erfolg!", "success")
|
||||
return redirect(url_for("profil.projekt_detail", projekt_id=neue_id))
|
||||
|
||||
conn.close()
|
||||
return render_template("projekt_neu.html", eingabe_titel=None, eingabe_beschreibung=None)
|
||||
|
||||
|
||||
def _eigenes_projekt_holen(projekt_id, conn=None):
|
||||
"""Liefert das Projekt, wenn es dem eingeloggten SuS gehoert. Sonst None.
|
||||
|
||||
Conn-Recycling optional, damit man in einer einzelnen DB-Connection bleiben kann.
|
||||
"""
|
||||
eigene_conn = conn is None
|
||||
conn = conn or get_db()
|
||||
try:
|
||||
from services.projekte import projekt_holen
|
||||
p = projekt_holen(conn, projekt_id)
|
||||
if not p or p["profil_id"] != session.get("profil_id"):
|
||||
return None
|
||||
return p
|
||||
finally:
|
||||
if eigene_conn:
|
||||
conn.close()
|
||||
|
||||
|
||||
@bp.route("/cockpit/projekt/<int:projekt_id>")
|
||||
@login_required
|
||||
def projekt_detail(projekt_id):
|
||||
from services.projekte import (
|
||||
dateien_eines_projekts, abschluss_voraussetzungen,
|
||||
vorlage_info, wiederholungen_von,
|
||||
)
|
||||
p = _eigenes_projekt_holen(projekt_id)
|
||||
if not p:
|
||||
flash("Projekt nicht gefunden.", "error")
|
||||
return redirect(url_for("profil.cockpit"))
|
||||
conn = get_db()
|
||||
software_liste = conn.execute(
|
||||
"SELECT slug, titel, kategorie FROM software_seite "
|
||||
"WHERE aktiv = 1 ORDER BY kategorie, sortierung, titel"
|
||||
).fetchall()
|
||||
drucker_liste = conn.execute(
|
||||
"SELECT id, name, modell FROM drucker WHERE aktiv = 1 ORDER BY modell, name"
|
||||
).fetchall()
|
||||
dateien = dateien_eines_projekts(conn, projekt_id)
|
||||
darf_abschliessen, fehlende = abschluss_voraussetzungen(conn, projekt_id)
|
||||
vorlage = vorlage_info(conn, p["vorlage_projekt_id"]) if p["vorlage_projekt_id"] else None
|
||||
wiederholungen = wiederholungen_von(conn, projekt_id)
|
||||
conn.close()
|
||||
return render_template(
|
||||
"projekt_detail.html",
|
||||
p=p,
|
||||
software_liste=software_liste,
|
||||
drucker_liste=drucker_liste,
|
||||
dateien=dateien,
|
||||
darf_abschliessen=darf_abschliessen,
|
||||
abschluss_fehlende=fehlende,
|
||||
vorlage=vorlage,
|
||||
wiederholungen=wiederholungen,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/cockpit/projekt/<int:projekt_id>/werkstatt", methods=["POST"])
|
||||
@login_required
|
||||
def projekt_werkstatt_speichern(projekt_id):
|
||||
"""Speichert Beschreibung + Software + Drucker + MakerWorld-URL in einem Schritt."""
|
||||
p = _eigenes_projekt_holen(projekt_id)
|
||||
if not p:
|
||||
flash("Projekt nicht gefunden.", "error")
|
||||
return redirect(url_for("profil.cockpit"))
|
||||
if p["status_lebenszyklus"] != "in_arbeit":
|
||||
flash("Abgeschlossene Projekte kannst du nicht mehr bearbeiten.", "error")
|
||||
return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id))
|
||||
|
||||
titel = (request.form.get("titel") or "").strip()
|
||||
beschreibung = (request.form.get("beschreibung_md") or "").strip() or None
|
||||
software = (request.form.get("modellier_software_slug") or "").strip() or None
|
||||
try:
|
||||
drucker = int(request.form.get("drucker_id_used") or 0) or None
|
||||
except ValueError:
|
||||
drucker = None
|
||||
makerworld = (request.form.get("makerworld_url") or "").strip() or None
|
||||
|
||||
if not titel:
|
||||
flash("Titel darf nicht leer sein.", "error")
|
||||
return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id))
|
||||
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"UPDATE projekt SET titel = ?, beschreibung_md = ?, "
|
||||
" modellier_software_slug = ?, drucker_id_used = ?, makerworld_url = ?, "
|
||||
" geaendert_am = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(titel, beschreibung, software, drucker, makerworld, projekt_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
flash("Aenderungen gespeichert.", "success")
|
||||
return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id))
|
||||
|
||||
|
||||
# Max-Upload-Groesse (30 MB) — Bambu-3MF-Files sind selten groesser
|
||||
MAX_UPLOAD_BYTES = 30 * 1024 * 1024
|
||||
|
||||
# Mapping Mime -> art (siehe Schema: art TEXT 'cover_foto'|'stl'|'3mf'|'foto'|'sonstiges')
|
||||
def _datei_art_aus_dateiname(dateiname: str, mime: str) -> str:
|
||||
name_lower = (dateiname or "").lower()
|
||||
if name_lower.endswith(".stl"):
|
||||
return "stl"
|
||||
if name_lower.endswith(".3mf"):
|
||||
return "3mf"
|
||||
if mime and mime.startswith("image/"):
|
||||
return "foto"
|
||||
return "sonstiges"
|
||||
|
||||
|
||||
@bp.route("/cockpit/projekt/<int:projekt_id>/datei", methods=["POST"])
|
||||
@login_required
|
||||
def projekt_datei_upload(projekt_id):
|
||||
"""Datei hochladen (JSON base64). Erwartet {dateiname, mime, base64}.
|
||||
|
||||
Eine Datei pro Request. Frontend ruft mehrfach auf bei mehreren Dateien.
|
||||
"""
|
||||
p = _eigenes_projekt_holen(projekt_id)
|
||||
if not p:
|
||||
return jsonify(error="Projekt nicht gefunden"), 404
|
||||
if p["status_lebenszyklus"] != "in_arbeit":
|
||||
return jsonify(error="Abgeschlossene Projekte koennen keine Dateien mehr aufnehmen"), 400
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
dateiname = (data.get("dateiname") or "").strip()
|
||||
mime = (data.get("mime") or "application/octet-stream").strip()
|
||||
b64 = data.get("base64") or ""
|
||||
if not dateiname or not b64:
|
||||
return jsonify(error="dateiname oder base64 fehlt"), 400
|
||||
|
||||
# base64 kann mit "data:...;base64," anfangen — abtrennen
|
||||
if "," in b64:
|
||||
b64 = b64.split(",", 1)[1]
|
||||
try:
|
||||
blob = base64.b64decode(b64)
|
||||
except Exception:
|
||||
return jsonify(error="base64 konnte nicht dekodiert werden"), 400
|
||||
|
||||
if len(blob) > MAX_UPLOAD_BYTES:
|
||||
return jsonify(error=f"Datei zu gross ({len(blob)//1024} KB, max {MAX_UPLOAD_BYTES//(1024*1024)} MB)"), 413
|
||||
|
||||
art = _datei_art_aus_dateiname(dateiname, mime)
|
||||
|
||||
conn = get_db()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO projekt_datei (projekt_id, dateiname, mime_type, groesse_bytes, inhalt, art, hochgeladen_von) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(projekt_id, dateiname, mime, len(blob), blob, art, session["profil_id"])
|
||||
)
|
||||
conn.commit()
|
||||
neue_id = cur.lastrowid
|
||||
conn.close()
|
||||
return jsonify(ok=True, id=neue_id, art=art, groesse=len(blob))
|
||||
|
||||
|
||||
@bp.route("/cockpit/projekt/<int:projekt_id>/datei/<int:datei_id>/loeschen", methods=["POST"])
|
||||
@login_required
|
||||
def projekt_datei_loeschen(projekt_id, datei_id):
|
||||
p = _eigenes_projekt_holen(projekt_id)
|
||||
if not p:
|
||||
flash("Projekt nicht gefunden.", "error")
|
||||
return redirect(url_for("profil.cockpit"))
|
||||
if p["status_lebenszyklus"] != "in_arbeit":
|
||||
flash("Dateien abgeschlossener Projekte koennen nicht geloescht werden.", "error")
|
||||
return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id))
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"DELETE FROM projekt_datei WHERE id = ? AND projekt_id = ?",
|
||||
(datei_id, projekt_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
flash("Datei geloescht.", "success")
|
||||
return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id))
|
||||
|
||||
|
||||
@bp.route("/cockpit/projekt/<int:projekt_id>/bearbeiten", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def projekt_bearbeiten(projekt_id):
|
||||
"""Titel + Beschreibung waehrend status='in_arbeit' anpassen."""
|
||||
p = _eigenes_projekt_holen(projekt_id)
|
||||
if not p:
|
||||
flash("Projekt nicht gefunden.", "error")
|
||||
return redirect(url_for("profil.cockpit"))
|
||||
if p["status_lebenszyklus"] != "in_arbeit":
|
||||
flash("Abgeschlossene Projekte kannst du nicht mehr bearbeiten.", "error")
|
||||
return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id))
|
||||
|
||||
if request.method == "POST":
|
||||
titel = (request.form.get("titel") or "").strip()
|
||||
beschreibung = (request.form.get("beschreibung_md") or "").strip() or None
|
||||
if not titel:
|
||||
flash("Titel darf nicht leer sein.", "error")
|
||||
return redirect(url_for("profil.projekt_bearbeiten", projekt_id=projekt_id))
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"UPDATE projekt SET titel = ?, beschreibung_md = ?, geaendert_am = CURRENT_TIMESTAMP "
|
||||
"WHERE id = ?",
|
||||
(titel, beschreibung, projekt_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
flash("Aenderungen gespeichert.", "success")
|
||||
return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id))
|
||||
|
||||
return render_template("projekt_bearbeiten.html", p=p)
|
||||
|
||||
|
||||
@bp.route("/cockpit/projekt/<int:projekt_id>/abschluss", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def projekt_abschluss(projekt_id):
|
||||
"""SuS schliesst das Projekt ab — mit differenzierter Selbstbewertung.
|
||||
|
||||
- Gesamt: zufrieden/mittel/gelernt (E-015)
|
||||
- Planung: gut/ok/schwierig
|
||||
- Ausfuehrung: gut/ok/schwierig
|
||||
- Was gelernt (Freitext)
|
||||
- Was klappte nicht (Freitext, hilft Lehrer bei Misserfolg)
|
||||
|
||||
Bei 'gelernt' (Misserfolg) → wartet_auf_freigabe (Lehrer muss drueber).
|
||||
Sonst → freigegeben (SuS kann gleich neu anfangen).
|
||||
"""
|
||||
from services.projekte import (
|
||||
EVAL_GESAMT, EVAL_QUALITAET, freigabe_nach_eval
|
||||
)
|
||||
p = _eigenes_projekt_holen(projekt_id)
|
||||
if not p:
|
||||
flash("Projekt nicht gefunden.", "error")
|
||||
return redirect(url_for("profil.cockpit"))
|
||||
if p["status_lebenszyklus"] != "in_arbeit":
|
||||
flash("Dieses Projekt ist bereits abgeschlossen.", "error")
|
||||
return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id))
|
||||
|
||||
# Abschluss-Voraussetzungen (Cover + Files) — pruefen bei GET und POST
|
||||
from services.projekte import abschluss_voraussetzungen
|
||||
conn = get_db()
|
||||
darf, fehlende = abschluss_voraussetzungen(conn, projekt_id)
|
||||
conn.close()
|
||||
if not darf:
|
||||
for f in fehlende:
|
||||
flash(f, "error")
|
||||
return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id))
|
||||
|
||||
if request.method == "POST":
|
||||
gesamt = (request.form.get("selbsteinschaetzung") or "").strip()
|
||||
planung = (request.form.get("selbsteinschaetzung_planung") or "").strip()
|
||||
ausfuehrung = (request.form.get("selbsteinschaetzung_ausfuehrung") or "").strip()
|
||||
gelernt = (request.form.get("was_gelernt") or "").strip() or None
|
||||
klappte_nicht = (request.form.get("was_klappte_nicht") or "").strip() or None
|
||||
|
||||
if gesamt not in EVAL_GESAMT:
|
||||
flash("Bitte Gesamt-Einschaetzung waehlen.", "error")
|
||||
return redirect(url_for("profil.projekt_abschluss", projekt_id=projekt_id))
|
||||
# Planung/Ausfuehrung optional, aber falls gesetzt: validieren
|
||||
if planung and planung not in EVAL_QUALITAET:
|
||||
planung = None
|
||||
if ausfuehrung and ausfuehrung not in EVAL_QUALITAET:
|
||||
ausfuehrung = None
|
||||
|
||||
neuer_freigabe_status = freigabe_nach_eval(gesamt)
|
||||
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"""UPDATE projekt SET
|
||||
status_lebenszyklus = 'abgeschlossen',
|
||||
abgeschlossen_am = CURRENT_TIMESTAMP,
|
||||
selbsteinschaetzung = ?,
|
||||
selbsteinschaetzung_planung = ?,
|
||||
selbsteinschaetzung_ausfuehrung = ?,
|
||||
was_gelernt = ?,
|
||||
was_klappte_nicht = ?,
|
||||
freigabe_status = ?,
|
||||
geaendert_am = CURRENT_TIMESTAMP
|
||||
WHERE id = ?""",
|
||||
(gesamt, planung or None, ausfuehrung or None,
|
||||
gelernt, klappte_nicht, neuer_freigabe_status, projekt_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
if neuer_freigabe_status == "wartet_auf_freigabe":
|
||||
flash(
|
||||
"Projekt abgeschlossen — und ehrlich bewertet, super! "
|
||||
"Der Lehrer schaut drueber, dann kannst du was Neues anfangen.",
|
||||
"success"
|
||||
)
|
||||
else:
|
||||
flash("Projekt abgeschlossen. Du kannst jetzt was Neues anfangen.", "success")
|
||||
return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id))
|
||||
|
||||
return render_template(
|
||||
"projekt_abschluss.html", p=p,
|
||||
EVAL_GESAMT=EVAL_GESAMT, EVAL_QUALITAET=EVAL_QUALITAET
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/cockpit/projekt/<int:projekt_id>/cover", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def projekt_cover(projekt_id):
|
||||
"""Cover-Foto via Crop-Tool (analog admin_software_bild)."""
|
||||
p = _eigenes_projekt_holen(projekt_id)
|
||||
if not p:
|
||||
if request.method == "POST":
|
||||
return jsonify(error="Projekt nicht gefunden"), 404
|
||||
flash("Projekt nicht gefunden.", "error")
|
||||
return redirect(url_for("profil.cockpit"))
|
||||
|
||||
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
|
||||
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 projekt SET cover_blob = ?, cover_mime = ? WHERE id = ?",
|
||||
(blob, mime, projekt_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify(ok=True)
|
||||
|
||||
return render_template("projekt_cover.html", p=p)
|
||||
|
||||
|
||||
@bp.route("/cockpit/projekt/<int:projekt_id>/cover/loeschen", methods=["POST"])
|
||||
@login_required
|
||||
def projekt_cover_loeschen(projekt_id):
|
||||
p = _eigenes_projekt_holen(projekt_id)
|
||||
if not p:
|
||||
flash("Projekt nicht gefunden.", "error")
|
||||
return redirect(url_for("profil.cockpit"))
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"UPDATE projekt SET cover_blob = NULL, cover_mime = NULL WHERE id = ?",
|
||||
(projekt_id,)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
flash("Cover-Bild geloescht.", "success")
|
||||
return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id))
|
||||
|
||||
|
||||
@bp.route("/cockpit/projekt/aus-vorlage/<int:vorlage_id>", methods=["POST"])
|
||||
@login_required
|
||||
def projekt_aus_vorlage(vorlage_id):
|
||||
"""Neues Projekt mit Verknuepfung zu einer Vorlage anlegen."""
|
||||
from services.projekte import aus_vorlage_anlegen
|
||||
conn = get_db()
|
||||
neue_id, fehler = aus_vorlage_anlegen(conn, session["profil_id"], vorlage_id)
|
||||
conn.close()
|
||||
if fehler:
|
||||
flash(fehler, "error")
|
||||
return redirect(url_for("oeffentlich.projekte"))
|
||||
flash(
|
||||
"Projekt angelegt — pass jetzt Titel und Beschreibung an, und mach deinen eigenen Twist.",
|
||||
"success"
|
||||
)
|
||||
return redirect(url_for("profil.projekt_detail", projekt_id=neue_id))
|
||||
|
||||
|
||||
@bp.route("/cockpit/projekt/<int:projekt_id>/rueckfrage/<int:rueckfrage_id>/erledigt", methods=["POST"])
|
||||
@login_required
|
||||
def rueckfrage_erledigt(projekt_id, rueckfrage_id):
|
||||
"""SuS markiert eine Rueckfrage als beantwortet → Projekt geht zurueck zu wartet_auf_freigabe."""
|
||||
p = _eigenes_projekt_holen(projekt_id)
|
||||
if not p:
|
||||
flash("Projekt nicht gefunden.", "error")
|
||||
return redirect(url_for("profil.cockpit"))
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"UPDATE projekt_rueckfrage SET status = 'erledigt', erledigt_am = CURRENT_TIMESTAMP "
|
||||
"WHERE id = ? AND projekt_id = ?",
|
||||
(rueckfrage_id, projekt_id)
|
||||
)
|
||||
# Wenn keine offenen Rueckfragen mehr da sind → zurueck zu wartet_auf_freigabe
|
||||
offen = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM projekt_rueckfrage WHERE projekt_id = ? AND status = 'offen'",
|
||||
(projekt_id,)
|
||||
).fetchone()["n"]
|
||||
if offen == 0:
|
||||
conn.execute(
|
||||
"UPDATE projekt SET freigabe_status = 'wartet_auf_freigabe' WHERE id = ?",
|
||||
(projekt_id,)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
flash("Rueckfrage als erledigt markiert — der Lehrer schaut nochmal drueber.", "success")
|
||||
return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id))
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Stubs fuer spaetere Etappen
|
||||
# ===========================================================================
|
||||
|
||||
@bp.route("/cockpit/druckauftraege")
|
||||
@login_required
|
||||
def druckauftraege():
|
||||
|
||||
Reference in New Issue
Block a user