Block A — Externe Links:
- projekt_datei.extern_url: eine "Datei" kann Upload (BLOB) ODER Link sein
- Link-Route /cockpit/projekt/<id>/link, Werkstatt-UI mit Link-Feld
- Katalog + Detail rendern Links als "Oeffnen ↗", Uploads als Download
- Upload-Limit 30 -> 60 MB (grosse Bambu-3MF); darueber: externer Link
Block B — Power-User-Rolle:
- profil.rolle ('schueler'|'power'); Login setzt Session-Rolle
- Decorator power_oder_admin; ist_power-Helper
- Login-Kacheln: Power mit 🔧 statt Tier; Admin-Profilanlage mit Rollen-Dropdown
(Tier nur bei schueler Pflicht, Power kriegt Platzhalter-Slug 'power')
- Cockpit: kein Check-In fuer Power, stattdessen "Power-Werkzeuge"-Karte
- Power-Projekt-Regeln: mehrere parallel erlaubt, Abschluss direkt 'freigegeben'
- Rechte power_oder_admin: Drucker-Verwaltung, Software-Katalog,
Slot-fuer-SuS, inline-edit (beschraenkt auf drucker+software_seite)
- Bleibt admin-only: SuS-Profile, Projekt-Freigaben, Admin-Dashboard
- Power-relevante Admin-Templates: Zurueck-Link rollenabhaengig (Cockpit statt Dashboard)
Block C — PAROL6-Demo:
- scripts/seed_demo_parol6.py: Power-Profil 'Herb' + PAROL6-Roboterarm-Projekt
- GitHub-Repo als Link, Bauanleitung-PDF als BLOB, Cover aus 3MF-Render
- abgeschlossen+freigegeben -> im Katalog, als Vorlage waehlbar
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1037 lines
39 KiB
Python
1037 lines
39 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, rolle FROM profil WHERE aktiv = 1 "
|
|
"ORDER BY rolle DESC, 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, rolle 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"]
|
|
session["rolle"] = profil["rolle"] or "schueler"
|
|
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 (60 MB) — Bambu-3MF mit mehreren Platten + Thumbnails
|
|
# werden schnell gross. Wer noch groesser braucht, nutzt einen externen Link.
|
|
MAX_UPLOAD_BYTES = 60 * 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>/link", methods=["POST"])
|
|
@login_required
|
|
def projekt_link_hinzufuegen(projekt_id):
|
|
"""Externen Link als 'Datei' hinzufuegen (GitHub, MakerWorld, gehostete 3MF, ...)."""
|
|
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 koennen keine Links mehr aufnehmen.", "error")
|
|
return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id))
|
|
|
|
name = (request.form.get("dateiname") or "").strip()
|
|
url = (request.form.get("extern_url") or "").strip()
|
|
if not url:
|
|
flash("Bitte eine URL angeben.", "error")
|
|
return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id))
|
|
if not (url.startswith("http://") or url.startswith("https://")):
|
|
flash("Der Link muss mit http:// oder https:// beginnen.", "error")
|
|
return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id))
|
|
if not name:
|
|
name = url # Fallback: URL als Anzeigename
|
|
|
|
conn = get_db()
|
|
conn.execute(
|
|
"INSERT INTO projekt_datei (projekt_id, dateiname, extern_url, art, hochgeladen_von) "
|
|
"VALUES (?, ?, ?, 'link', ?)",
|
|
(projekt_id, name, url, session["profil_id"])
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
flash("Link hinzugefuegt.", "success")
|
|
return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id))
|
|
|
|
|
|
@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
|
|
|
|
# Power-User-Projekte (Demos) gehen direkt in den Katalog, kein Lehrer-Review.
|
|
if session.get("rolle") == "power":
|
|
neuer_freigabe_status = "freigegeben"
|
|
else:
|
|
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
|