Etappe 2: Cockpit progressiv + Check-In + Paarung + Lehrer-Dashboard

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.
This commit is contained in:
2026-05-24 19:13:19 +02:00
parent fae86c64c0
commit abf96ca8f2
14 changed files with 1228 additions and 45 deletions

View File

@@ -238,7 +238,44 @@ def profile_karten():
@bp.route("/dashboard")
@admin_required
def dashboard():
abort(501) # Etappe 2
"""Drill-Down-Dashboard (V5). Siehe docs/lehrer-dashboard.md."""
from services.dashboard_status import (
wer_ist_heute_hier,
wartet_auf_review,
offene_rueckfragen,
drucker_status_alle,
wer_haengt_fest,
dashboard_zahlen,
)
return render_template(
"admin_dashboard.html",
heute=wer_ist_heute_hier(),
review=wartet_auf_review(),
rueckfragen=offene_rueckfragen(),
drucker=drucker_status_alle(),
haengen_fest=wer_haengt_fest(),
zahlen=dashboard_zahlen(),
)
@bp.route("/profil/<int:profil_id>/notiz", methods=["POST"])
@admin_required
def profil_notiz(profil_id):
"""V5-Quick-Action: Lehrer hinterlaesst Notiz im SuS-Cockpit."""
text = (request.form.get("text") or "").strip()
typ = (request.form.get("typ") or "info").strip()
if not text:
flash("Notiz darf nicht leer sein.", "error")
return redirect(url_for("admin.dashboard"))
conn = get_db()
conn.execute(
"INSERT INTO lehrer_notiz (profil_id, text, typ) VALUES (?, ?, ?)",
(profil_id, text, typ)
)
conn.commit()
conn.close()
flash("Notiz hinterlegt — wird im SuS-Cockpit angezeigt.", "success")
return redirect(url_for("admin.dashboard"))
@bp.route("/lektionen")

View File

@@ -18,12 +18,31 @@ def index():
@bp.route("/lektionen")
def lektionen():
abort(501)
"""Lektions-Uebersicht — Etappe 2 minimal, Etappe 3 voll mit Status-Badges."""
from database import get_db
conn = get_db()
lektionen = conn.execute(
"SELECT nummer, titel, onboarding, schwierigkeit FROM lektion "
"WHERE aktiv = 1 ORDER BY nummer"
).fetchall()
conn.close()
return render_template("lektionen.html", lektionen=lektionen)
@bp.route("/lektion/<int:nummer>")
def lektion_detail(nummer):
abort(501)
"""Lektion einzeln — Etappe 2 minimal, Etappe 3 mit Status-Buttons + Inline-Edit."""
from database import get_db
conn = get_db()
lektion = conn.execute(
"SELECT nummer, titel, onboarding, aufgabe_md, tipp_md, extern_link, schwierigkeit "
"FROM lektion WHERE nummer = ? AND aktiv = 1",
(nummer,)
).fetchone()
conn.close()
if not lektion:
abort(404)
return render_template("lektion_detail.html", lektion=lektion)
@bp.route("/software")

View File

@@ -1,11 +1,16 @@
"""SuS-Profil-Routen: Login, Cockpit (Skelett), Logout.
"""SuS-Profil-Routen: Login, Cockpit (progressiv), Check-In, Lektion-Status, Paarung.
Etappe 1: Login-Flow mit bcrypt + persistenter Lockout (E-020 V7).
Etappe 2: Cockpit (progressiv), Check-In, 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 flask import Blueprint, render_template, request, redirect, url_for, session, flash, abort
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 (
@@ -15,18 +20,22 @@ from services.auth import (
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)
# Login-Flow (Etappe 1, unveraendert)
# ===========================================================================
@bp.route("/login")
def login():
"""Kachel-Seite mit allen aktiven Profilen (Vorname + Tier-Icon)."""
conn = get_db()
profile = conn.execute(
"SELECT id, vorname, tier FROM profil WHERE aktiv = 1 "
@@ -38,7 +47,6 @@ def login():
@bp.route("/login/<int:profil_id>", methods=["GET", "POST"])
def login_pin(profil_id):
"""PIN-Eingabe fuer ein gewaehltes Profil."""
conn = get_db()
profil = conn.execute(
"SELECT id, vorname, tier, pin_hash, aktiv FROM profil WHERE id = ?",
@@ -50,7 +58,6 @@ def login_pin(profil_id):
flash("Profil nicht gefunden.", "error")
return redirect(url_for("profil.login"))
# Lockout-Check (persistent in DB, V7)
gesperrt, sek_rest = ist_gesperrt(profil_id)
if gesperrt:
min_rest = sek_rest // 60 + 1
@@ -64,7 +71,6 @@ def login_pin(profil_id):
if request.method == "POST":
eingabe_pin = (request.form.get("pin") or "").strip()
if pin_pruefen(eingabe_pin, profil["pin_hash"]):
# Erfolg: Lockout zuruecksetzen, Session befuellen
lockout_zuruecksetzen(profil_id)
session.clear()
session["profil_id"] = profil["id"]
@@ -86,46 +92,257 @@ def logout():
# ===========================================================================
# Cockpit (Etappe 1: Minimal-Stub, voll in Etappe 2 mit progressivem Layout)
# Cockpit (Etappe 2: progressives Layout V1)
# ===========================================================================
@bp.route("/cockpit")
@login_required
def cockpit():
"""Minimal-Stub fuer Etappe 1.
Etappe 2 macht das progressiv (V1):
- Onboarding nicht erledigt: nur Onboarding-Karte + Check-In-Trigger
- L1 fertig: zusaetzlich Drucker-Buchung
- Hat aktive Projekte: zusaetzlich Projekt-Karten, Druckauftraege, Slots
"""
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_stub.html",
"cockpit.html",
status=status,
andere_profile=andere_profile,
vorname=session.get("vorname"),
tier=session.get("tier"),
)
# Spaetere Etappen — Stubs damit Navigation nicht bricht
# ===========================================================================
# Check-In (V3 + E-021)
# ===========================================================================
@bp.route("/cockpit/check-in", methods=["POST"])
@login_required
def check_in():
abort(501)
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(nummer):
abort(501)
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)
abort(501) # Etappe 6/7
@bp.route("/cockpit/druckauftraege")
@login_required
def druckauftraege():
abort(501)
abort(501) # Etappe 7