Cockpit (V1, progressives Layout): - /cockpit zeigt nur Karten, die zum Status passen. Onboarding-Karte nur fuer Neue; Paarungs-Karte immer wenn aktiv; Drucker-Buchung erst nach L1 fertig. - Banner-System: ungelesene Lehrer-Notizen, offene Rueckfragen, eingegangene Paarungs-Anfragen. Check-In (V3 + E-021): - Modal beim ersten Cockpit-Aufruf eines Besuchstages. - "Ich arbeite weiter" als Default-Ein-Klick-Option. - Freitext optional. - POST /cockpit/check-in mit UNIQUE-Constraint pro Tag. Onboarding-Status (E-019): - /cockpit/lektion/<nr>/status (selbst-zertifiziert). - profil_lektion_status-Tabelle, ON CONFLICT-Upsert. Paarung (E-002): - profil_anfrage-Tabelle on-demand. - Anfrage stellen / annehmen / ablehnen / aufloesen. - Konflikt-Check (keine Doppel-Paarungen). - Banner im Cockpit des Adressaten. Lehrer-Dashboard (V5, docs/lehrer-dashboard.md): - /admin/dashboard mit 4 Sektionen (Wer ist heute hier / Wartet auf Review / Drucker-Status / Wer haengt fest) + Schnell-Zahlen + offene Rueckfragen. - "Notiz hinterlassen"-Quick-Action via lehrer_notiz-Tabelle. - Live-Drucker-Status kommt mit Etappe 5 (aktuell nur DB-Stand). Daten-Helper getrennt: - services/cockpit_status.py: Bundle fuer progressives Cockpit - services/dashboard_status.py: 4-Sektionen-Queries Vorgezogen aus Etappe 3: - /lektionen + /lektion/<nr> Minimal-Renderer mit Markdown - 3 Onboarding-Lektionen als Seeds (Platzhalter-Text fuer Markus) Smoke-Test bestanden: Login als neuer SuS -> Onboarding -> Check-In -> L1 fertig -> Paarung mit zweitem SuS -> Admin-Dashboard -> Lehrer-Notiz -> Banner im Cockpit.
349 lines
12 KiB
Python
349 lines
12 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.
|
|
"""
|
|
from datetime import date
|
|
|
|
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()
|
|
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"))
|
|
|
|
|
|
# ===========================================================================
|
|
# Stubs fuer spaetere Etappen
|
|
# ===========================================================================
|
|
|
|
@bp.route("/cockpit/slots")
|
|
@login_required
|
|
def slots():
|
|
abort(501) # Etappe 6/7
|
|
|
|
|
|
@bp.route("/cockpit/druckauftraege")
|
|
@login_required
|
|
def druckauftraege():
|
|
abort(501) # Etappe 7
|