"""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/", 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//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//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/", 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//", 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//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/") @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//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//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//datei//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//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//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//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//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/", 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//rueckfrage//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