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:
2026-05-25 19:04:18 +02:00
parent 66f6dd96dc
commit 8cbd803328
40 changed files with 5721 additions and 55 deletions

View File

@@ -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"))

View File

@@ -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."""

View File

@@ -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,
)

View File

@@ -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():