Files
3d.lehrstun.de/routes/profil.py
Hans Puettmann 8cbd803328 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>
2026-05-25 19:04:18 +02:00

996 lines
37 KiB
Python

"""SuS-Profil-Routen: Login, Cockpit (progressiv), Check-In, Lektion-Status, Paarung.
Etappe 1: Login-Flow.
Etappe 2: Cockpit progressiv (V1), Check-In (V3 + E-021), Lektion-Status, Paarung.
Etappe 6: Projekt-Workflow.
Etappe 7: Druckauftraege + Slots.
"""
import base64
from datetime import date, datetime, timedelta
from flask import (
Blueprint, render_template, request, redirect, url_for,
session, flash, abort, jsonify
)
from database import get_db
from services.auth import (
pin_pruefen,
ist_gesperrt,
fehlversuch_loggen,
lockout_zuruecksetzen,
login_required,
)
from services.cockpit_status import (
get_cockpit_status,
get_andere_profile,
ONBOARDING_LEKTIONEN,
)
from tiere import tier_emoji, tier_name
bp = Blueprint("profil", __name__)
# ===========================================================================
# Login-Flow (Etappe 1, unveraendert)
# ===========================================================================
@bp.route("/login")
def login():
conn = get_db()
profile = conn.execute(
"SELECT id, vorname, tier FROM profil WHERE aktiv = 1 "
"ORDER BY vorname COLLATE NOCASE, tier"
).fetchall()
conn.close()
return render_template("login.html", profile=profile)
@bp.route("/login/<int:profil_id>", methods=["GET", "POST"])
def login_pin(profil_id):
conn = get_db()
profil = conn.execute(
"SELECT id, vorname, tier, pin_hash, aktiv FROM profil WHERE id = ?",
(profil_id,)
).fetchone()
conn.close()
if not profil or not profil["aktiv"]:
flash("Profil nicht gefunden.", "error")
return redirect(url_for("profil.login"))
gesperrt, sek_rest = ist_gesperrt(profil_id)
if gesperrt:
min_rest = sek_rest // 60 + 1
return render_template(
"login_pin.html",
profil=profil,
gesperrt=True,
min_rest=min_rest
)
if request.method == "POST":
eingabe_pin = (request.form.get("pin") or "").strip()
if pin_pruefen(eingabe_pin, profil["pin_hash"]):
lockout_zuruecksetzen(profil_id)
session.clear()
session["profil_id"] = profil["id"]
session["vorname"] = profil["vorname"]
session["tier"] = profil["tier"]
return redirect(url_for("profil.cockpit"))
else:
fehlversuch_loggen(profil_id)
flash("Falsche PIN. Versuch's nochmal.", "error")
return render_template("login_pin.html", profil=profil, gesperrt=False)
@bp.route("/logout")
def logout():
session.clear()
flash("Du bist ausgeloggt.", "success")
return redirect(url_for("oeffentlich.index"))
# ===========================================================================
# Cockpit (Etappe 2: progressives Layout V1)
# ===========================================================================
@bp.route("/cockpit")
@login_required
def cockpit():
status = get_cockpit_status(session["profil_id"])
andere_profile = []
if not status["paarung"]:
andere_profile = get_andere_profile(session["profil_id"])
return render_template(
"cockpit.html",
status=status,
andere_profile=andere_profile,
vorname=session.get("vorname"),
tier=session.get("tier"),
)
# ===========================================================================
# Check-In (V3 + E-021)
# ===========================================================================
@bp.route("/cockpit/check-in", methods=["POST"])
@login_required
def check_in():
modus = (request.form.get("modus") or "").strip()
freitext = (request.form.get("freitext") or "").strip() or None
# Modus validieren — einfach, kein striktes ENUM
erlaubte_modi = {
"weiter_wie_bisher", "neues_projekt", "onboarding",
"drucken", "helfen", "sonstiges"
}
if modus not in erlaubte_modi:
flash("Ungueltige Auswahl.", "error")
return redirect(url_for("profil.cockpit"))
conn = get_db()
conn.execute(
"INSERT OR REPLACE INTO check_in (profil_id, besuch_datum, modus, freitext) "
"VALUES (?, ?, ?, ?)",
(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"))
@bp.route("/cockpit/notiz/<int:notiz_id>/gelesen", methods=["POST"])
@login_required
def notiz_gelesen(notiz_id):
"""Lehrer-Notiz als gelesen markieren (V6 + V5)."""
conn = get_db()
conn.execute(
"UPDATE lehrer_notiz SET gelesen_am = CURRENT_TIMESTAMP "
"WHERE id = ? AND profil_id = ? AND gelesen_am IS NULL",
(notiz_id, session["profil_id"])
)
conn.commit()
conn.close()
return redirect(url_for("profil.cockpit"))
# ===========================================================================
# Onboarding-Lektion-Status (selbst-zertifiziert, E-019)
# ===========================================================================
@bp.route("/cockpit/lektion/<int:nummer>/status", methods=["POST"])
@login_required
def lektion_status_setzen(nummer):
"""Status-Wechsel: offen -> laeuft -> fertig (oder zurueck via 'reset')."""
neuer_status = (request.form.get("status") or "").strip()
erlaubte = {"offen", "laeuft", "fertig"}
if neuer_status not in erlaubte:
flash("Ungueltiger Status.", "error")
return redirect(url_for("profil.cockpit"))
conn = get_db()
# Pruefe ob Lektion existiert + onboarding ist (nicht jede Lektion soll selbst-zertifizierbar sein)
lektion = conn.execute(
"SELECT nummer FROM lektion WHERE nummer = ? AND aktiv = 1",
(nummer,)
).fetchone()
if not lektion:
conn.close()
flash("Lektion nicht gefunden.", "error")
return redirect(url_for("profil.cockpit"))
conn.execute(
"INSERT INTO profil_lektion_status (profil_id, lektion_nummer, status) "
"VALUES (?, ?, ?) "
"ON CONFLICT(profil_id, lektion_nummer) DO UPDATE SET "
" status = excluded.status, geaendert_am = CURRENT_TIMESTAMP",
(session["profil_id"], nummer, neuer_status)
)
conn.commit()
conn.close()
if neuer_status == "fertig":
flash(f"L{nummer} als fertig markiert. 👍", "success")
return redirect(url_for("profil.cockpit"))
# ===========================================================================
# Paarung (E-002): anfragen + bestaetigen + ablehnen + aufloesen
# ===========================================================================
#
# Mechanik:
# - Anfrage wird als profil_anfrage-Eintrag gespeichert (mit Status 'wartet').
# - Adressat sieht im Cockpit ein gelbes Banner "Mia moechte mit dir zusammenarbeiten — [Ja] [Nein]".
# - Bei [Ja]: paarung-Eintrag wird angelegt (UNIQUE-Constraint verhindert Doppel-Paarungen).
# - Bei [Nein]: profil_anfrage geht auf 'abgelehnt'.
# - Bei Aufloesung: paarung.aktiv = 0.
#
# Tabelle profil_anfrage wird hier on-demand angelegt, weil wir das nicht
# initial im Schema hatten. Das ist OK fuer Etappe 2 — falls die Paarung
# spaeter komplexer wird, migrieren wir.
def _ensure_anfrage_tabelle():
"""Idempotent — legt profil_anfrage an, falls noch nicht da."""
conn = get_db()
conn.execute("""
CREATE TABLE IF NOT EXISTS profil_anfrage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
von_profil_id INTEGER NOT NULL,
an_profil_id INTEGER NOT NULL,
status TEXT DEFAULT 'wartet',
erstellt_am DATETIME DEFAULT CURRENT_TIMESTAMP,
beantwortet_am DATETIME,
FOREIGN KEY (von_profil_id) REFERENCES profil(id) ON DELETE CASCADE,
FOREIGN KEY (an_profil_id) REFERENCES profil(id) ON DELETE CASCADE
)
""")
conn.commit()
conn.close()
_ensure_anfrage_tabelle()
@bp.route("/cockpit/paarung/anfragen/<int:andere_profil_id>", methods=["POST"])
@login_required
def paarung_anfragen(andere_profil_id):
von = session["profil_id"]
if von == andere_profil_id:
flash("Du kannst dich nicht mit dir selbst paaren.", "error")
return redirect(url_for("profil.cockpit"))
conn = get_db()
# Pruefen: bin ich oder die andere Person schon in einer aktiven Paarung?
schon_gepaart = conn.execute(
"SELECT id FROM paarung WHERE aktiv = 1 AND ? IN (profil_a, profil_b)",
(von,)
).fetchone()
if schon_gepaart:
conn.close()
flash("Du bist schon in einer aktiven Paarung — loese sie erst auf.", "error")
return redirect(url_for("profil.cockpit"))
andere_gepaart = conn.execute(
"SELECT id FROM paarung WHERE aktiv = 1 AND ? IN (profil_a, profil_b)",
(andere_profil_id,)
).fetchone()
if andere_gepaart:
conn.close()
flash("Die andere Person ist schon in einer Paarung.", "error")
return redirect(url_for("profil.cockpit"))
# Bereits offene Anfrage in die Richtung?
schon_angefragt = conn.execute(
"SELECT id FROM profil_anfrage WHERE status = 'wartet' "
"AND ((von_profil_id = ? AND an_profil_id = ?) OR (von_profil_id = ? AND an_profil_id = ?))",
(von, andere_profil_id, andere_profil_id, von)
).fetchone()
if schon_angefragt:
conn.close()
flash("Es laeuft schon eine Anfrage zwischen euch.", "error")
return redirect(url_for("profil.cockpit"))
conn.execute(
"INSERT INTO profil_anfrage (von_profil_id, an_profil_id) VALUES (?, ?)",
(von, andere_profil_id)
)
conn.commit()
conn.close()
flash("Anfrage geschickt. Die andere Person muss sie bestaetigen.", "success")
return redirect(url_for("profil.cockpit"))
@bp.route("/cockpit/paarung/anfrage/<int:anfrage_id>/<aktion>", methods=["POST"])
@login_required
def paarung_anfrage_beantworten(anfrage_id, aktion):
if aktion not in ("annehmen", "ablehnen"):
abort(400)
conn = get_db()
anfrage = conn.execute(
"SELECT id, von_profil_id, an_profil_id, status FROM profil_anfrage WHERE id = ?",
(anfrage_id,)
).fetchone()
if not anfrage or anfrage["an_profil_id"] != session["profil_id"] or anfrage["status"] != "wartet":
conn.close()
flash("Anfrage nicht gefunden oder schon beantwortet.", "error")
return redirect(url_for("profil.cockpit"))
if aktion == "annehmen":
a, b = sorted([anfrage["von_profil_id"], anfrage["an_profil_id"]])
conn.execute(
"INSERT INTO paarung (profil_a, profil_b, aktiv) VALUES (?, ?, 1)",
(a, b)
)
flash("Paarung gebildet — viel Erfolg zusammen!", "success")
else:
flash("Anfrage abgelehnt.", "success")
conn.execute(
"UPDATE profil_anfrage SET status = ?, beantwortet_am = CURRENT_TIMESTAMP WHERE id = ?",
("angenommen" if aktion == "annehmen" else "abgelehnt", anfrage_id)
)
conn.commit()
conn.close()
return redirect(url_for("profil.cockpit"))
@bp.route("/cockpit/paarung/aufloesen", methods=["POST"])
@login_required
def paarung_aufloesen():
conn = get_db()
conn.execute(
"UPDATE paarung SET aktiv = 0 WHERE aktiv = 1 AND ? IN (profil_a, profil_b)",
(session["profil_id"],)
)
conn.commit()
conn.close()
flash("Paarung aufgeloest.", "success")
return redirect(url_for("profil.cockpit"))
# ===========================================================================
# Slot-Reservierung (Iteration B1)
# ===========================================================================
@bp.route("/cockpit/slots")
@login_required
def slots():
"""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():
abort(501) # Etappe 7