From abf96ca8f2ca7f6e0d03556ff249883c562bd5fa Mon Sep 17 00:00:00 2001 From: Hans Puettmann Date: Sun, 24 May 2026 19:13:19 +0200 Subject: [PATCH] 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//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/ 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. --- CHANGELOG.md | 12 ++ CLAUDE.md | 2 +- COCKPIT.md | 4 +- docs/etappen.md | 40 +++-- routes/admin.py | 39 ++++- routes/oeffentlich.py | 23 ++- routes/profil.py | 265 +++++++++++++++++++++++++++++--- seed.py | 53 +++++++ services/cockpit_status.py | 201 +++++++++++++++++++++++++ services/dashboard_status.py | 117 ++++++++++++++ templates/admin_dashboard.html | 182 ++++++++++++++++++++++ templates/cockpit.html | 268 +++++++++++++++++++++++++++++++++ templates/lektion_detail.html | 44 ++++++ templates/lektionen.html | 23 +++ 14 files changed, 1228 insertions(+), 45 deletions(-) create mode 100644 services/cockpit_status.py create mode 100644 services/dashboard_status.py create mode 100644 templates/admin_dashboard.html create mode 100644 templates/cockpit.html create mode 100644 templates/lektion_detail.html create mode 100644 templates/lektionen.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ae3aa5..7d4db49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ tags: [changelog] Format: [Datum] — Kurz-Beschreibung. Details bei nicht-trivialen Änderungen. +## 2026-05-24 — Etappe 2 lauffähig (Cockpit progressiv + Check-In + Paarung + Lehrer-Dashboard) +- **Cockpit progressiv (V1):** zeigt nur Karten, die zum Status passen. Onboarding-Karte verschwindet wenn alle L1-L3 fertig; Paarungs-Karte erscheint immer wenn Paarung aktiv; Drucker-Buchung erst nach L1. +- **Check-In (V3 + E-021):** Modal beim ersten Cockpit-Aufruf eines Besuchstages, „Ich arbeite weiter" als Default-Ein-Klick-Option, Freitext optional. +- **Onboarding-Lektion-Status (E-019):** selbst-zertifiziert via `/cockpit/lektion//status` (offen → läuft → fertig). +- **Paarung (E-002):** Anfrage → Cockpit-Banner beim Adressaten → annehmen/ablehnen. Tabelle `profil_anfrage` on-demand angelegt. +- **Lehrer-Dashboard (V5):** Drill-Down `/admin/dashboard` mit 4 Sektionen (Wer ist heute hier, Wartet auf Review, Drucker-Status, Wer hängt fest) + Schnell-Zahlen + offene Rückfragen. +- **Quick-Action „Notiz hinterlassen":** Lehrer schreibt aus Dashboard direkt ins SuS-Cockpit (`lehrer_notiz`-Tabelle). +- **Lektion-Detail-Seite (Minimal):** `/lektionen` + `/lektion/` mit Markdown-Rendering + Status-Buttons für eingeloggte SuS. +- **3 Onboarding-Lektionen** als Seeds mit Platzhalter-Inhalt (Markus füllt später via Inline-Edit). +- **Datei-Helper:** `services/cockpit_status.py` + `services/dashboard_status.py` trennen Daten-Logik vom HTTP-Layer. +- Smoke-Test bestanden (Login als neuer SuS → Onboarding → Check-In → L1 fertig → Paarung mit zweitem SuS → Admin-Dashboard → Lehrer-Notiz → Banner im Cockpit). + ## 2026-05-24 — Git-Repo angelegt + Gitea-Push - Lokales Git-Repo initialisiert (branch `main`). - Initial-Commit `4658d49` mit kompletter Konzept v2 + Etappe 1 (alles aus den vorigen CHANGELOG-Eintraegen). diff --git a/CLAUDE.md b/CLAUDE.md index b25e97a..e3400c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ tags: [schule, 3d-druck, web, ag] > Vault-Startseite: [[COCKPIT]]. Hauptkonzept: [[kurskonzept]]. Architektur: [[architektur]]. > Offene Klärungen: [[offene-fragen]]. Entscheidungen: [[decisions]]. Etappen: [[etappen]]. -**Status:** **Etappe 1 läuft lokal** (Stand 2026-05-24). Login + Admin + Inline-Edit-Infra + Blueprint-Struktur funktional. Smoke-Test bestanden (Admin-Login, Profil-Anlage, SuS-Login mit PIN, Lockout-Logging). +**Status:** **Etappe 1+2 läuft lokal** (Stand 2026-05-24). Login, progressives Cockpit, Check-In, Onboarding-Status, Paarung, Drill-Down-Lehrer-Dashboard, Lehrer-Notiz-Banner — alles funktional. Smoke-Test bestanden. **Repo:** http://46.225.114.15:3000/admin/3d.lehrstun.de (SSH: `ssh://git@46.225.114.15:2222/admin/3d.lehrstun.de.git`) **Port intern:** 5057 (5055=quiz-collector, 5056=arduino-db). **Brand:** 3D-Druck-AG diff --git a/COCKPIT.md b/COCKPIT.md index f7863c4..2c0d99f 100644 --- a/COCKPIT.md +++ b/COCKPIT.md @@ -13,9 +13,9 @@ tags: [3d-druck, ag, schule, cockpit] ## Status auf einen Blick -- **Stand:** Konzept v2 + **Etappe 1 lokal lauffaehig** (Login + Admin + Inline-Edit + Blueprint-Struktur, Smoke-Test bestanden). Code: ~1800 Zeilen Python+HTML. +- **Stand:** Konzept v2 + **Etappe 1+2 lokal lauffaehig** (Login, Cockpit progressiv, Check-In, Paarung, Lehrer-Dashboard mit Drill-Down). Code: ~2500 Zeilen Python+HTML, Repo gepusht. - **Nichts deployed**, DNS noch nicht gesetzt. -- **Nächster Schritt:** Etappe 2 — progressives Cockpit, Check-In, Paarung, [[lehrer-dashboard|Lehrer-Dashboard]]. Oder vorher: Markus liest [[kurskonzept]] + [[selbstlern-loop]] + [[lehrer-dashboard]]. +- **Nächster Schritt:** Etappe 8 (ZSL-Material auswaehlen + umarbeiten) damit der Projekt-Katalog beim Start nicht leer ist. Oder Etappe 6 (Selbstlern-Loop voll). Oder Markus' Inhalte fuer L1-L3 einpflegen. - **Offene Klärungen:** [[offene-fragen]] (Bambu-Cloud-Account, A1-mini-Firmware, ZSL-Projekt-Auswahl) - **Lokal starten:** `venv/Scripts/python.exe app.py` → http://localhost:5057 (Admin-PW lokal: `lokal-test-123`) diff --git a/docs/etappen.md b/docs/etappen.md index 91d457e..9018259 100644 --- a/docs/etappen.md +++ b/docs/etappen.md @@ -53,29 +53,39 @@ tags: [konzept, plan, etappen] - [ ] base.html-JS für Inline-Edit-UI (kommt in Etappe 2, wenn das erste editierbare Feld in der UI sichtbar wird) - [ ] CSRF-Token (kommt in Etappe 2, sobald Forms wachsen) -## Etappe 2 — Cockpit (progressiv) + Check-In + Paarung + Lehrer-Dashboard (1 Sitzung) +## Etappe 2 — Cockpit (progressiv) + Check-In + Paarung + Lehrer-Dashboard (✓ am 2026-05-24) **Cockpit (V1 — progressives Layout):** -- [ ] `/cockpit` rendert je nach Status: - - Onboarding nicht erledigt → nur Onboarding-Karte + Check-In-Trigger - - L1 fertig → zusätzlich: Drucker-Buchung freigeschaltet - - Hat aktive Projekte → zusätzlich: eigene Projekte, Druckaufträge, Slots -- [ ] Cockpit-Banner für offene Rückfragen (V6) -- [ ] Cockpit-Banner für ungelesene Lehrer-Notizen +- [x] `/cockpit` rendert je nach Status (services/cockpit_status.py liefert das Bundle) +- [x] Cockpit-Banner für ungelesene Lehrer-Notizen (V5-Quick-Action) +- [x] Cockpit-Banner für offene Rückfragen (V6) — Render-seitig, Daten kommen mit Etappe 6 +- [x] Cockpit-Banner für eingegangene Paarungs-Anfragen **Check-In (V3 + E-021):** -- [ ] Modal beim ersten Cockpit-Aufruf eines Besuchstages -- [ ] Default-Option „Ich arbeite an X weiter" wenn aktives Projekt vorhanden -- [ ] `/cockpit/check-in`-POST speichert in `check_in`-Tabelle +- [x] Modal beim ersten Cockpit-Aufruf eines Besuchstages +- [x] Default-Option „Ich arbeite an X weiter" wenn aktives Projekt vorhanden +- [x] `/cockpit/check-in`-POST speichert in `check_in`-Tabelle (UNIQUE pro Tag) + +**Onboarding-Status:** +- [x] `/cockpit/lektion//status` (selbst-zertifiziert, E-019) +- [x] Onboarding-Karte mit Lektion-Status-Anzeige + Aktions-Buttons **Paarung:** -- [ ] `/cockpit/paarung/anfragen/` + Bestätigungsmechanik -- [ ] `/cockpit/paarung/aufloesen` +- [x] `profil_anfrage`-Tabelle on-demand angelegt +- [x] `/cockpit/paarung/anfragen/` mit Konflikt-Check +- [x] `/cockpit/paarung/anfrage//annehmen|ablehnen` +- [x] `/cockpit/paarung/aufloesen` **Lehrer-Dashboard (V5, [[lehrer-dashboard]]):** -- [ ] `/admin/dashboard` mit 4 Sektionen (Wer hier / Wartet Review / Drucker-Status / Hängt fest) -- [ ] Badge im Header für „wartet auf Review"-Anzahl -- [ ] „Notiz hinterlassen"-Quick-Action via `lehrer_notiz`-Tabelle +- [x] `/admin/dashboard` mit 4 Sektionen + Schnell-Zahlen + Rückfragen-Liste +- [x] „Notiz hinterlassen"-Quick-Action via `lehrer_notiz`-Tabelle +- [ ] Live-Drucker-Status (kommt mit Etappe 5) + +**Lektion-Detail-Seite (vorgezogen aus Etappe 3):** +- [x] `/lektionen` und `/lektion/` Minimal-Renderer mit Markdown + Status-Buttons (vollständige Version mit Inline-Edit kommt in Etappe 3) + +**Seeds:** +- [x] 3 Onboarding-Lektionen mit Platzhalter-Text (Markus fuellt mit Inline-Edit nach) ## Etappe 3 — Lektionen + Software-Sektion + Troubleshooting (1 Sitzung) diff --git a/routes/admin.py b/routes/admin.py index 8a86b2f..5792ac0 100644 --- a/routes/admin.py +++ b/routes/admin.py @@ -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//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") diff --git a/routes/oeffentlich.py b/routes/oeffentlich.py index 052d1bd..df14d7a 100644 --- a/routes/oeffentlich.py +++ b/routes/oeffentlich.py @@ -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/") 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") diff --git a/routes/profil.py b/routes/profil.py index 0c131c0..0443186 100644 --- a/routes/profil.py +++ b/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/", 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//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(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/", 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")) + + +# =========================================================================== +# 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 diff --git a/seed.py b/seed.py index 133f574..b2dd66e 100644 --- a/seed.py +++ b/seed.py @@ -50,6 +50,58 @@ def seed_drucker(): print(f"[seed] {len(drucker)} Drucker angelegt (oder bereits da).") +def seed_lektionen(): + """3 Onboarding-Lektionen mit Platzhalter-Inhalt. + + Endgueltige Inhalte werden gemeinsam mit Markus erarbeitet (siehe + docs/lektion-L1-drucker-kennenlernen.md etc.). Hier nur das Geruest, + damit der Onboarding-Pfad ueberhaupt was zum Anklicken hat. + """ + lektionen = [ + (1, "Drucker-Kennenlernen + Sicherheit", + "**Was du in dieser Lektion lernst:**\n\n" + "- Welche Drucker wir haben und welcher offen ist (A1 mini — Haende weg vom Hotend!).\n" + "- Wie du die Bambu Handy App einrichtest.\n" + "- Welches Material wann erlaubt ist (PLA / PLA+ immer, PETG/ABS nur mit Lehrer).\n" + "- Dein erster Druck mit einem vorbereiteten Modell.\n\n" + "**Aufgabe:** Lass dir vom Lehrer ein vorbereitetes Modell zeigen, " + "starte den Druck, beobachte die ersten Schichten.\n\n" + "*Inhalt wird mit Markus weiter ausgearbeitet.*", + "Frag den Lehrer, wenn du irgendwas nicht verstehst — Sicherheit geht vor.", + "https://bambulab.com/en/support/academy"), + (2, "Fremdes Modell waehlen + drucken", + "**Was du in dieser Lektion lernst:**\n\n" + "- Auf Printables (oder MakerWorld) ein passendes Modell finden.\n" + "- Das Modell in Bambu Studio importieren und slicen.\n" + "- Druckzeit + Material abschaetzen, Slot reservieren, drucken.\n\n" + "**Aufgabe:** Such dir auf Printables ein Modell, das unter 1 h " + "Druckzeit liegt. Such was, das du wirklich haben willst.\n\n" + "*Inhalt wird mit Markus weiter ausgearbeitet.*", + "Lizenz-Symbole sind nicht wichtig fuer uns — wir drucken nur fuer uns selbst, " + "geben nichts weiter.", + "https://www.printables.com/"), + (3, "Customizer / parametrisches Modell", + "**Was du in dieser Lektion lernst:**\n\n" + "- Was 'parametrisch' bedeutet (du veraenderst Werte, das Modell passt sich an).\n" + "- Mit dem MakerWorld-Customizer ein Modell mit deinem eigenen Namen drucken.\n\n" + "**Aufgabe:** Mach einen Schluesselanhaenger mit deinem Vornamen drauf — " + "veraendere Schriftart, Groesse, Loch-Position.\n\n" + "*Inhalt wird mit Markus weiter ausgearbeitet.*", + "Tipp: Das Modell 'Parametric Name Keychain Creator' auf MakerWorld ist ein guter Einstieg.", + "https://makerworld.com/en/models/1320280"), + ] + conn = get_db() + for nummer, titel, aufgabe_md, tipp_md, extern_link in lektionen: + conn.execute( + "INSERT OR IGNORE INTO lektion (nummer, titel, onboarding, aufgabe_md, tipp_md, extern_link, schwierigkeit, aktiv) " + "VALUES (?, ?, 1, ?, ?, ?, 'leicht', 1)", + (nummer, titel, aufgabe_md, tipp_md, extern_link) + ) + conn.commit() + conn.close() + print(f"[seed] {len(lektionen)} Onboarding-Lektionen angelegt.") + + def seed_einstellungen(): """Default-Einstellungen, die spaeter inline editiert werden koennen.""" werte = [ @@ -78,6 +130,7 @@ def main(): init_db() seed_ag() seed_drucker() + seed_lektionen() seed_einstellungen() print("[seed] Fertig.") diff --git a/services/cockpit_status.py b/services/cockpit_status.py new file mode 100644 index 0000000..58214c8 --- /dev/null +++ b/services/cockpit_status.py @@ -0,0 +1,201 @@ +"""Reine Status-Helper fuer das progressive Cockpit (V1). + +Trennt Daten-Logik vom HTTP-Layer: die Funktionen hier bekommen eine +profil_id und liefern dicts/listen, die das Template direkt verwendet. + +So bleibt routes/profil.py kurz und das Cockpit-Verhalten ist isoliert +testbar. +""" +from datetime import date, datetime, timedelta +from typing import Optional + +from database import get_db + +ONBOARDING_LEKTIONEN = [1, 2, 3] # Pflicht fuer Neue, siehe E-013 +ONBOARDING_L1 = 1 # Schaltet Slot-Buchung frei (V2) + + +def get_onboarding_fortschritt(profil_id: int) -> dict: + """Liefert pro Onboarding-Lektion den Status. + + Returns: + { + 'lektionen': [ + {'nummer': 1, 'titel': 'Drucker-Kennenlernen + Sicherheit', + 'status': 'fertig'|'laeuft'|'offen', 'aktuell': True/False}, + ... + ], + 'alle_fertig': bool, + 'l1_fertig': bool, # V2: schaltet Slot-Buchung frei + 'naechste': dict | None, # naechste offene/laufende Lektion (Sprung-Vorschlag) + } + """ + conn = get_db() + rows = conn.execute( + "SELECT l.nummer, l.titel, COALESCE(s.status, 'offen') AS status " + "FROM lektion l " + "LEFT JOIN profil_lektion_status s " + " ON s.lektion_nummer = l.nummer AND s.profil_id = ? " + "WHERE l.onboarding = 1 AND l.aktiv = 1 " + "ORDER BY l.nummer", + (profil_id,) + ).fetchall() + conn.close() + + lektionen = [dict(r) for r in rows] + alle_fertig = all(l["status"] == "fertig" for l in lektionen) and len(lektionen) > 0 + l1_fertig = any(l["nummer"] == ONBOARDING_L1 and l["status"] == "fertig" for l in lektionen) + + # naechste: erste, die nicht fertig ist + naechste = next((l for l in lektionen if l["status"] != "fertig"), None) + for l in lektionen: + l["aktuell"] = (naechste is not None and l["nummer"] == naechste["nummer"]) + + return { + "lektionen": lektionen, + "alle_fertig": alle_fertig, + "l1_fertig": l1_fertig, + "naechste": naechste, + } + + +def get_check_in_heute(profil_id: int) -> Optional[dict]: + """Liefert den Check-In von heute (oder None, wenn noch keiner).""" + conn = get_db() + row = conn.execute( + "SELECT id, modus, freitext, erstellt_am FROM check_in " + "WHERE profil_id = ? AND besuch_datum = ?", + (profil_id, date.today().isoformat()) + ).fetchone() + conn.close() + return dict(row) if row else None + + +def get_offene_rueckfragen(profil_id: int) -> list[dict]: + """Rueckfragen zu eigenen Projekten, die noch offen sind (V6-Banner).""" + conn = get_db() + rows = conn.execute( + "SELECT r.id, r.text, r.erstellt_am, p.titel AS projekt_titel, p.id AS projekt_id " + "FROM projekt_rueckfrage r " + "JOIN projekt p ON p.id = r.projekt_id " + "WHERE p.profil_id = ? AND r.status = 'offen' " + "ORDER BY r.erstellt_am DESC", + (profil_id,) + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def get_ungelesene_notizen(profil_id: int) -> list[dict]: + """Lehrer-Notizen, die noch nicht gelesen wurden.""" + conn = get_db() + rows = conn.execute( + "SELECT id, text, typ, erstellt_am FROM lehrer_notiz " + "WHERE profil_id = ? AND gelesen_am IS NULL " + "ORDER BY erstellt_am DESC", + (profil_id,) + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def get_aktive_projekte(profil_id: int) -> list[dict]: + """Eigene Projekte mit status_lebenszyklus='in_arbeit' (Etappe 6 fuellt das voll).""" + conn = get_db() + rows = conn.execute( + "SELECT id, titel, beschreibung_md, geaendert_am " + "FROM projekt " + "WHERE profil_id = ? AND status_lebenszyklus = 'in_arbeit' " + "ORDER BY geaendert_am DESC", + (profil_id,) + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def get_paarung(profil_id: int) -> Optional[dict]: + """Aktive Paarung des SuS (oder None).""" + conn = get_db() + row = conn.execute( + "SELECT pa.id AS paarung_id, " + " CASE WHEN pa.profil_a = ? THEN pa.profil_b ELSE pa.profil_a END AS partner_id, " + " pa.gebildet_am " + "FROM paarung pa " + "WHERE pa.aktiv = 1 AND (pa.profil_a = ? OR pa.profil_b = ?)", + (profil_id, profil_id, profil_id) + ).fetchone() + if not row: + conn.close() + return None + partner = conn.execute( + "SELECT id, vorname, tier FROM profil WHERE id = ?", + (row["partner_id"],) + ).fetchone() + conn.close() + return { + "paarung_id": row["paarung_id"], + "partner": dict(partner) if partner else None, + "gebildet_am": row["gebildet_am"], + } + + +def get_offene_anfragen_an_mich(profil_id: int) -> list[dict]: + """Paarungs-Anfragen, die jemand an mich gestellt hat (Status 'wartet').""" + conn = get_db() + # Tabelle existiert evtl. noch nicht, wenn diese Funktion vor _ensure_anfrage_tabelle aufgerufen wird + try: + rows = conn.execute( + "SELECT a.id, a.erstellt_am, p.id AS von_id, p.vorname AS von_vorname, p.tier AS von_tier " + "FROM profil_anfrage a " + "JOIN profil p ON p.id = a.von_profil_id " + "WHERE a.an_profil_id = ? AND a.status = 'wartet' " + "ORDER BY a.erstellt_am DESC", + (profil_id,) + ).fetchall() + except Exception: + rows = [] + conn.close() + return [dict(r) for r in rows] + + +def get_andere_profile(profil_id: int) -> list[dict]: + """Andere aktive Profile in derselben AG (fuer Paarungs-Anfrage).""" + conn = get_db() + rows = conn.execute( + "SELECT id, vorname, tier " + "FROM profil " + "WHERE aktiv = 1 AND id != ? AND ag_id = (" + " SELECT ag_id FROM profil WHERE id = ?" + ") " + "ORDER BY vorname COLLATE NOCASE", + (profil_id, profil_id) + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +# =========================================================================== +# Cockpit-Status-Bundle: ein dict mit allem, was das Cockpit-Template braucht +# =========================================================================== + +def get_cockpit_status(profil_id: int) -> dict: + """Bundle aller Daten fuer das progressive Cockpit (V1). + + Das Template entscheidet anhand dieses dicts, welche Karten gezeigt werden. + """ + onb = get_onboarding_fortschritt(profil_id) + aktive_projekte = get_aktive_projekte(profil_id) + paarung = get_paarung(profil_id) + return { + "onboarding": onb, + "check_in_heute": get_check_in_heute(profil_id), + "rueckfragen": get_offene_rueckfragen(profil_id), + "notizen": get_ungelesene_notizen(profil_id), + "aktive_projekte": aktive_projekte, + "paarung": paarung, + "offene_anfragen": get_offene_anfragen_an_mich(profil_id), + # Progressive-Schalter: + "ist_neu": not onb["alle_fertig"], + "darf_slots_buchen": onb["l1_fertig"], + "hat_projekte": len(aktive_projekte) > 0, + } diff --git a/services/dashboard_status.py b/services/dashboard_status.py new file mode 100644 index 0000000..766e8c4 --- /dev/null +++ b/services/dashboard_status.py @@ -0,0 +1,117 @@ +"""Daten-Helper fuer das Lehrer-Drill-Down-Dashboard (V5). + +Siehe docs/lehrer-dashboard.md fuer die 4 Sektionen: + 1. Wer ist heute hier + 2. Wartet auf Review + 3. Drucker-Status + 4. Wer haengt fest +""" +from datetime import date, timedelta + +from database import get_db + + +def wer_ist_heute_hier() -> list[dict]: + """SuS, die heute schon eingecheckt haben.""" + conn = get_db() + rows = conn.execute( + "SELECT c.id AS check_in_id, c.modus, c.freitext, c.erstellt_am, " + " p.id AS profil_id, p.vorname, p.tier " + "FROM check_in c " + "JOIN profil p ON p.id = c.profil_id " + "WHERE c.besuch_datum = ? " + "ORDER BY c.erstellt_am DESC", + (date.today().isoformat(),) + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def wartet_auf_review() -> list[dict]: + """Projekte mit Status 'wartet_auf_freigabe' — Lehrer muss reviewen.""" + conn = get_db() + rows = conn.execute( + "SELECT pr.id, pr.titel, pr.abgeschlossen_am, pr.selbsteinschaetzung, " + " p.vorname, p.tier " + "FROM projekt pr " + "LEFT JOIN profil p ON p.id = pr.profil_id " + "WHERE pr.freigabe_status = 'wartet_auf_freigabe' " + "ORDER BY pr.abgeschlossen_am ASC", + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def offene_rueckfragen() -> list[dict]: + """Rueckfragen, die der SuS noch nicht erledigt hat (alt = Warnung).""" + conn = get_db() + rows = conn.execute( + "SELECT r.id, r.text, r.erstellt_am, " + " pr.id AS projekt_id, pr.titel AS projekt_titel, " + " p.vorname, p.tier " + "FROM projekt_rueckfrage r " + "JOIN projekt pr ON pr.id = r.projekt_id " + "LEFT JOIN profil p ON p.id = pr.profil_id " + "WHERE r.status = 'offen' " + "ORDER BY r.erstellt_am ASC", + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def drucker_status_alle() -> list[dict]: + """Alle Drucker mit Status (oder None, wenn nie gepollt).""" + conn = get_db() + rows = conn.execute( + "SELECT d.id, d.name, d.modell, d.geschlossenes_gehaeuse, d.notiz, " + " ds.status, ds.job_name, ds.progress_pct, ds.restzeit_min, " + " ds.fehlermeldung, ds.gepollt_am, ds.manuell_gesetzt " + "FROM drucker d " + "LEFT JOIN drucker_status ds ON ds.drucker_id = d.id " + "WHERE d.aktiv = 1 " + "ORDER BY d.name", + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def wer_haengt_fest(tage: int = 14) -> list[dict]: + """SuS, die seit > N Tagen keinen Check-In + keinen Projekt-Abschluss hatten.""" + schwelle = (date.today() - timedelta(days=tage)).isoformat() + conn = get_db() + rows = conn.execute( + "SELECT p.id, p.vorname, p.tier, " + " (SELECT MAX(besuch_datum) FROM check_in WHERE profil_id = p.id) AS letzter_check_in, " + " (SELECT MAX(abgeschlossen_am) FROM projekt WHERE profil_id = p.id) AS letzter_abschluss " + "FROM profil p " + "WHERE p.aktiv = 1 " + " AND ( " + " (SELECT MAX(besuch_datum) FROM check_in WHERE profil_id = p.id) IS NULL " + " OR (SELECT MAX(besuch_datum) FROM check_in WHERE profil_id = p.id) < ? " + " ) " + "ORDER BY letzter_check_in DESC NULLS LAST", + (schwelle,) + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def dashboard_zahlen() -> dict: + """Badge-Zahlen fuer die Header-Anzeige.""" + conn = get_db() + review_n = conn.execute( + "SELECT COUNT(*) AS n FROM projekt WHERE freigabe_status = 'wartet_auf_freigabe'" + ).fetchone()["n"] + heute_n = conn.execute( + "SELECT COUNT(*) AS n FROM check_in WHERE besuch_datum = ?", + (date.today().isoformat(),) + ).fetchone()["n"] + drucker_problem_n = conn.execute( + "SELECT COUNT(*) AS n FROM drucker_status WHERE status IN ('failed', 'offline')" + ).fetchone()["n"] + conn.close() + return { + "review_n": review_n, + "heute_n": heute_n, + "drucker_problem_n": drucker_problem_n, + } diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html new file mode 100644 index 0000000..8970200 --- /dev/null +++ b/templates/admin_dashboard.html @@ -0,0 +1,182 @@ +{% extends "base.html" %} +{% block title %}Dashboard — Admin{% endblock %} +{% block content %} + +
+

Dashboard

+ +
+ +{# ====== Schnell-Zahlen ====== #} +
+
+
{{ zahlen.heute_n }}
+
heute eingecheckt
+
+
+
{{ zahlen.review_n }}
+
wartet auf Review
+
+
+
{{ zahlen.drucker_problem_n }}
+
Drucker-Problem
+
+
+ +{# ====== Sektion 1: Wer ist heute hier ====== #} +
+

👋 Wer ist heute hier ({{ heute|length }})

+ {% if heute %} + + + + + + {% for h in heute %} + + + + + + + + + {% endfor %} + +
VornameModusNotizseit
{{ tier_emoji(h.tier) }}{{ h.vorname }} + {% if h.modus == 'weiter_wie_bisher' %}Arbeitet weiter + {% elif h.modus == 'neues_projekt' %}🆕 Neues Projekt + {% elif h.modus == 'onboarding' %}📚 Onboarding + {% elif h.modus == 'drucken' %}🖨️ Drucken + {% elif h.modus == 'helfen' %}🤝 Hilft anderen + {% else %}{{ h.modus }}{% endif %} + {{ h.freitext or "" }}{{ h.erstellt_am[11:16] if h.erstellt_am else '' }} +
+ Notiz schreiben +
+ + +
+
+
+ {% else %} +

Noch keiner heute eingecheckt.

+ {% endif %} +
+ +{# ====== Sektion 2: Wartet auf Review ====== #} +
+

⏳ Wartet auf Review ({{ review|length }})

+ {% if review %} + + + + {% for r in review %} + + + + + + + + + {% endfor %} + +
ProjektSuSSelbsteinschaetzungabgeschlossen
{{ tier_emoji(r.tier) }}{{ r.titel }}{{ r.vorname }} + {% if r.selbsteinschaetzung == 'zufrieden' %}🟢 zufrieden + {% elif r.selbsteinschaetzung == 'mittel' %}🟡 mittel + {% elif r.selbsteinschaetzung == 'gelernt' %}🔴 gelernt + {% endif %} + {{ r.abgeschlossen_am[:10] if r.abgeschlossen_am else '' }} + Reviewen +
+ {% else %} +

Keine offenen Reviews. (Selbstlern-Loop kommt mit Etappe 6.)

+ {% endif %} +
+ +{# ====== Sektion 3: Drucker-Status ====== #} +
+

🖨️ Drucker-Status

+
+ {% for d in drucker %} +
+

{{ d.name }}

+

+ {{ d.modell }}{% if not d.geschlossenes_gehaeuse %} · offen{% endif %} +

+

+ {% if d.status == 'printing' %}🟢 drucken + {% elif d.status == 'idle' %}⚪ idle + {% elif d.status == 'paused' %}🟡 pausiert + {% elif d.status == 'finished' %}✅ fertig + {% elif d.status == 'failed' %}🔴 Fehler + {% elif d.status == 'offline' %}⚫ offline + {% else %}⚪ kein Status (API noch nicht in Etappe 5){% endif %} +

+ {% if d.job_name %}

{{ d.job_name }}

{% endif %} + {% if d.progress_pct %}

{{ d.progress_pct|round|int }} %{% if d.restzeit_min %} · noch {{ d.restzeit_min }} min{% endif %}

{% endif %} + {% if d.fehlermeldung %}

{{ d.fehlermeldung }}

{% endif %} + {% if d.notiz %}

ℹ️ {{ d.notiz }}

{% endif %} +
+ {% endfor %} +
+
+ +{# ====== Sektion 4: Wer haengt fest ====== #} +
+

💤 Wer hängt fest (>2 Wochen kein Check-In)

+ {% if haengen_fest %} + + + + {% for h in haengen_fest %} + + + + + + + {% endfor %} + +
Vornameletzter Check-In
{{ tier_emoji(h.tier) }}{{ h.vorname }} + {% if h.letzter_check_in %}{{ h.letzter_check_in }}{% else %}noch nie{% endif %} + +
+ Erinnerung +
+ + + +
+
+
+ {% else %} +

Alle dabei. 👍

+ {% endif %} +
+ +{# ====== Sektion: Offene Rueckfragen ====== #} +{% if rueckfragen %} +
+

❓ Offene Rückfragen ({{ rueckfragen|length }})

+

SuS hat noch nicht nachgebessert.

+
    + {% for r in rueckfragen %} +
  • + {{ tier_emoji(r.tier) }} {{ r.vorname }} · + „{{ r.projekt_titel }}" · + seit {{ r.erstellt_am[:10] }} · + „{{ r.text[:80] }}{% if r.text|length > 80 %}…{% endif %}" +
  • + {% endfor %} +
+
+{% endif %} + +{% endblock %} diff --git a/templates/cockpit.html b/templates/cockpit.html new file mode 100644 index 0000000..ab47ff9 --- /dev/null +++ b/templates/cockpit.html @@ -0,0 +1,268 @@ +{% extends "base.html" %} +{% block title %}Cockpit — {{ vorname }}{% endblock %} +{% block content %} + +{# ====== Header ====== #} +
+

+ {{ tier_emoji(tier) }} + Hallo, {{ vorname }}! +

+ Abmelden +
+ +{# ====== BANNER: Lehrer-Notizen (V5-Quick-Action) ====== #} +{% for n in status.notizen %} +
+

+ 📌 Lehrer-Notiz: {{ n.text }} +

+
+ +
+
+{% endfor %} + +{# ====== BANNER: Offene Rueckfragen zu eigenen Projekten (V6) ====== #} +{% for r in status.rueckfragen %} +
+

+ ❓ Rueckfrage zu „{{ r.projekt_titel }}": {{ r.text }} +

+

+ Bessere das Projekt nach, dann melde es erneut zur Freigabe. +

+
+{% endfor %} + +{# ====== BANNER: Eingegangene Paarungs-Anfragen ====== #} +{% for a in status.offene_anfragen %} +
+

+ {{ tier_emoji(a.von_tier) }} {{ a.von_vorname }} moechte mit dir + zusammenarbeiten. Magst du? +

+
+
+ +
+
+ +
+
+
+{% endfor %} + +{# ====== Check-In-Modal — wenn heute noch nichts gewaehlt wurde ====== #} +{% if not status.check_in_heute %} +
+

Was machst du heute? 🎯

+

+ Damit Herb und Markus auf einen Blick sehen, woran du arbeitest. + Ein Klick reicht — Freitext nur, wenn du willst. +

+
+
+ {% set hat_projekte = status.aktive_projekte | length > 0 %} + {% if hat_projekte %} + + {% endif %} + + {% if status.ist_neu %} + + {% endif %} + + + +
+ + +
+
+{% else %} +
+

+ Heute eingecheckt: + {% if status.check_in_heute.modus == 'weiter_wie_bisher' %}Arbeite weiter + {% elif status.check_in_heute.modus == 'neues_projekt' %}Neues Projekt + {% elif status.check_in_heute.modus == 'onboarding' %}Onboarding + {% elif status.check_in_heute.modus == 'drucken' %}Drucken + {% elif status.check_in_heute.modus == 'helfen' %}Hilft anderen + {% else %}{{ status.check_in_heute.modus }}{% endif %} + {% if status.check_in_heute.freitext %} — „{{ status.check_in_heute.freitext }}"{% endif %} +

+
+{% endif %} + +
+ + {# ====== ONBOARDING-KARTE (V1: nur wenn nicht fertig) ====== #} + {% if status.ist_neu %} +
+

Dein Onboarding-Pfad 🚀

+

+ Drei Lektionen, damit du allein klarkommst. Du klickst selbst „fertig", wenn du soweit bist. +

+
+ {% for l in status.onboarding.lektionen %} +
+ + {% if l.status == 'fertig' %}✅ + {% elif l.status == 'laeuft' %}🟡 + {% else %}⚪{% endif %} + + + L{{ l.nummer }} — {{ l.titel }} + + + {% if l.status != 'fertig' %} + + ansehen + +
+ {% if l.status == 'offen' %} + + {% else %} + + {% endif %} +
+ {% else %} +
+ +
+ {% endif %} +
+
+ {% endfor %} +
+ {% if not status.darf_slots_buchen %} +

+ ℹ️ Drucker reservieren kannst du, sobald L1 (Sicherheit) erledigt ist. +

+ {% endif %} +
+ {% endif %} + + {# ====== PROJEKT-KARTE (V1: nur wenn Projekte oder Onboarding fertig) ====== #} + {% if status.hat_projekte or not status.ist_neu %} +
+

{% if status.paarung %}Euer Projekt{% else %}Dein Projekt{% endif %}

+ {% if status.aktive_projekte %} + {% for p in status.aktive_projekte %} +
+ {{ p.titel }} + {% if p.beschreibung_md %} +
{{ p.beschreibung_md|markdown }}
+ {% endif %} +
+ {% endfor %} + {% else %} +

+ Du hast noch kein laufendes Projekt. +

+

+ Projekt-Anlage kommt mit Etappe 6. +

+ {% endif %} +
+ {% endif %} + + {# ====== PAARUNG / TEAM-KARTE — immer wenn Paarung aktiv, sonst nur fuer Erfahrene ====== #} + {% if status.paarung or not status.ist_neu %} +
+

{% if status.paarung %}Dein Team{% else %}Allein arbeiten?{% endif %}

+ {% if status.paarung %} +

+ Du arbeitest mit + {{ tier_emoji(status.paarung.partner.tier) }} {{ status.paarung.partner.vorname }} + zusammen seit {{ status.paarung.gebildet_am[:10] }}. +

+
+ +
+ {% else %} +

+ Du kannst dich mit jemandem zusammenschliessen — frag im Cockpit-Untermenue. +

+ {% if andere_profile %} +
+ Anfrage stellen +
+ {% for andere in andere_profile %} +
+ +
+ {% endfor %} +
+
+ {% else %} +

+ Noch niemand sonst in der AG. +

+ {% endif %} + {% endif %} +
+ {% endif %} + + {# ====== DRUCKER-/SLOT-KARTE (V1: nur wenn L1 fertig) ====== #} + {% if status.darf_slots_buchen %} +
+

🖨️ Drucker reservieren

+

+ Slot-System kommt mit Etappe 7. Bis dahin: sprich dich vor Ort ab. +

+
+ {% endif %} + +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/lektion_detail.html b/templates/lektion_detail.html new file mode 100644 index 0000000..0e170eb --- /dev/null +++ b/templates/lektion_detail.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %}L{{ lektion.nummer }} — {{ lektion.titel }}{% endblock %} +{% block content %} +
+

L{{ lektion.nummer }} {{ lektion.titel }}

+ ← Alle Lektionen +
+ +
+ {% if lektion.onboarding %}Onboarding{% endif %} + {{ lektion.schwierigkeit }} + +
{{ lektion.aufgabe_md|markdown }}
+ + {% if lektion.tipp_md %} +
+ 💡 Tipp +
{{ lektion.tipp_md|markdown }}
+
+ {% endif %} + + {% if lektion.extern_link %} +

+ + Externer Link ↗ + +

+ {% endif %} +
+ +{# Wenn eingeloggt: Status-Buttons zum Selbst-Zertifizieren #} +{% if session.profil_id %} +
+

Status setzen (du entscheidest selbst, wann du fertig bist):

+
+ {% for opt, label in [('offen', '⚪ noch nicht angefangen'), ('laeuft', '🟡 läuft'), ('fertig', '✅ fertig')] %} +
+ +
+ {% endfor %} +
+
+{% endif %} +{% endblock %} diff --git a/templates/lektionen.html b/templates/lektionen.html new file mode 100644 index 0000000..634ec8a --- /dev/null +++ b/templates/lektionen.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% block title %}Lektionen{% endblock %} +{% block content %} +

Lektionen

+

+ Onboarding (L1-L3) sind für Neue gedacht. Wer schon dabei + ist, kann sie überspringen oder als Auffrischung nochmal durchgehen. +

+ + +{% endblock %}