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:
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
265
routes/profil.py
265
routes/profil.py
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user