diff --git a/.gitignore b/.gitignore index 7cb51f5..0851be3 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ backups/ # Editor .vscode/ .idea/ +.claude/ *.swp .DS_Store diff --git a/app.py b/app.py index 0a708e9..2e6340c 100644 --- a/app.py +++ b/app.py @@ -104,6 +104,29 @@ import markdown as _markdown from markupsafe import Markup +@app.template_filter("iso_format") +def iso_format_filter(iso_str, format_str="%H:%M"): + """Parsed einen ISO-Datetime-String und formatiert ihn (strftime).""" + from datetime import datetime + try: + dt = datetime.fromisoformat(iso_str) + except (TypeError, ValueError): + return iso_str or "" + return dt.strftime(format_str) + + +@app.template_filter("dauer_min") +def dauer_min_filter(start_iso, ende_iso): + """Berechnet Dauer in ganzen Minuten zwischen zwei ISO-Datetime-Strings.""" + from datetime import datetime + try: + s = datetime.fromisoformat(start_iso) + e = datetime.fromisoformat(ende_iso) + except (TypeError, ValueError): + return "" + return int((e - s).total_seconds() // 60) + + @app.template_filter("markdown") def markdown_filter(text): """Rendert Markdown zu HTML und markiert das Ergebnis als 'safe', diff --git a/database.py b/database.py index ddbe0f2..fd322f7 100644 --- a/database.py +++ b/database.py @@ -45,6 +45,10 @@ CREATE TABLE IF NOT EXISTS profil ( pin_hash TEXT NOT NULL, -- bcrypt ag_id TEXT NOT NULL, -- FK ag_mitgliedschaft aktiv INTEGER DEFAULT 1, + -- Lehrer schaltet SuS manuell zur Drucker-Reservierung frei (ersetzt + -- den geplanten L1-Gate-Automatismus, weil manche SuS zuhause schon + -- drucken oder schneller durch L1 sind als die Doku festhalten kann). + reservieren_freigeschaltet INTEGER DEFAULT 0, erstellt_am DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(ag_id, vorname, tier), FOREIGN KEY (ag_id) REFERENCES ag_mitgliedschaft(id) @@ -99,8 +103,17 @@ CREATE TABLE IF NOT EXISTS projekt ( -- Lebenszyklus status_lebenszyklus TEXT DEFAULT 'in_arbeit', -- 'in_arbeit'|'abgeschlossen' abgeschlossen_am DATETIME, - selbsteinschaetzung TEXT, -- 'zufrieden'|'mittel'|'gelernt' (E-015) - was_gelernt TEXT, -- optionaler Freitext + selbsteinschaetzung TEXT, -- 'zufrieden'|'mittel'|'gelernt' (E-015) — Gesamt + selbsteinschaetzung_planung TEXT, -- 'gut'|'ok'|'schwierig' — wie war die Planung? + selbsteinschaetzung_ausfuehrung TEXT, -- 'gut'|'ok'|'schwierig' — wie war die Umsetzung? + was_gelernt TEXT, + was_klappte_nicht TEXT, -- optionaler Freitext, hilft Lehrer beim Review + cover_blob BLOB, -- Cover-Foto (Crop-Tool, 400x400 JPEG) + cover_mime TEXT, + -- Werkstatt-Erfassung (Etappe 6 Phase D, mit Herb 2026-05-25) + modellier_software_slug TEXT, -- FK auf software_seite.slug (welche Hauptsoftware) + drucker_id_used INTEGER, -- FK auf drucker.id (welcher Drucker wurde benutzt) + makerworld_url TEXT, -- optional: SuS hat Projekt zusaetzlich auf MakerWorld veroeffentlicht -- optionaler Freitext -- Freigabe-Workflow (E-017) freigabe_status TEXT DEFAULT 'in_arbeit', -- 'in_arbeit'|'wartet_auf_freigabe'|'freigegeben'|'rueckfrage' freigegeben_am DATETIME, @@ -195,7 +208,14 @@ CREATE TABLE IF NOT EXISTS drucker ( serial_nr TEXT, -- fuer Cloud-API geschlossenes_gehaeuse INTEGER DEFAULT 1, -- A1 mini = 0 aktiv INTEGER DEFAULT 1, - notiz TEXT + notiz TEXT, + -- Freigabe-Steuerung (Etappe 5-vorgezogen): Lehrer kann Drucker + -- voruebergehend von Reservierung ausschliessen (Reparatur, Wartung). + freigegeben INTEGER DEFAULT 1, -- 1 = SuS koennen reservieren, 0 = gesperrt + gesperrt_grund TEXT, -- nur relevant wenn freigegeben=0 + -- Optional: Drucker-spezifische URL-Overrides ueber dem Modell-Default + manual_url TEXT, + troubleshooting_url TEXT ); CREATE TABLE IF NOT EXISTS drucker_status ( @@ -264,6 +284,31 @@ CREATE TABLE IF NOT EXISTS lektion ( aktiv INTEGER DEFAULT 1 ); +-- =========================================================================== +-- Software-Sektion (Etappe 3 — Karten mit Bild + Kurzbeschreibung + Links) +-- =========================================================================== +-- Wir halten uns absichtlich raus: SuS haben die Software ohnehin auf den +-- Schul-PCs / im Browser und machen eigene Erfahrungen. Wir geben nur die +-- Auswahl vor (Karten-Liste mit Hauptlink + Tutorial-Link, ggf. Store-Links +-- mit QR-Code). Keine Detail-Seite. +CREATE TABLE IF NOT EXISTS software_seite ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT UNIQUE NOT NULL, -- 'bambu-studio', 'gridfinity', ... + titel TEXT NOT NULL, + kurzbeschreibung TEXT NOT NULL, -- 1-2 Saetze, sichtbar auf der Karte + url TEXT NOT NULL, -- Hauptlink (Startseite / Download) + tutorial_url TEXT, -- optionaler deutscher Einstieg + plattform TEXT, -- Klartext: 'Browser', 'Windows/macOS/Linux', ... + app_store_url TEXT, -- nur bei Apps + play_store_url TEXT, -- nur bei Apps + bild_pfad TEXT, -- alt: relativ zu static/img/software/, optional Fallback + bild_blob BLOB, -- per Crop-Tool hochgeladen (Default-Quelle) + bild_mime TEXT, -- z.B. 'image/jpeg' + kategorie TEXT NOT NULL DEFAULT 'software', -- 'software'|'app'|'generator' + sortierung INTEGER DEFAULT 100, + aktiv INTEGER DEFAULT 1 +); + -- =========================================================================== -- Einstellungen (Inline-Edit-Konfig) + Audit-Log -- =========================================================================== diff --git a/migrations/2026-05-25_drucker_freigabe_links.py b/migrations/2026-05-25_drucker_freigabe_links.py new file mode 100644 index 0000000..2376ddd --- /dev/null +++ b/migrations/2026-05-25_drucker_freigabe_links.py @@ -0,0 +1,44 @@ +"""Migration 2026-05-25: Drucker-Tabelle um Freigabe-Status + URL-Overrides. + +Etappe 5-vorgezogen: Lehrer muss einen Drucker voruebergehend sperren koennen +(Reparatur, Aufraeumarbeiten, Filament-Wechsel), und SuS sehen das mit Grund. +Plus optionale URL-Felder fuer Drucker-spezifische Manual-/Troubleshooting-Links. + +Idempotent: prueft PRAGMA table_info bevor ADD COLUMN. +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from database import get_db + + +NEUE_SPALTEN = [ + ("freigegeben", "INTEGER DEFAULT 1"), + ("gesperrt_grund", "TEXT"), + ("manual_url", "TEXT"), + ("troubleshooting_url", "TEXT"), +] + + +def main(): + conn = get_db() + spalten = {row["name"] for row in conn.execute("PRAGMA table_info(drucker)")} + angelegt = [] + for name, typ in NEUE_SPALTEN: + if name not in spalten: + conn.execute(f"ALTER TABLE drucker ADD COLUMN {name} {typ}") + angelegt.append(name) + # Bestehende Drucker auf freigegeben=1 setzen (ALTER mit DEFAULT setzt + # neue Zeilen, aber bestehende Zeilen bekommen NULL — wir korrigieren das). + conn.execute("UPDATE drucker SET freigegeben = 1 WHERE freigegeben IS NULL") + conn.commit() + conn.close() + if angelegt: + print(f"[migration] drucker: Spalten ergaenzt: {', '.join(angelegt)}.") + else: + print("[migration] drucker: alle Spalten existieren bereits — nichts zu tun.") + + +if __name__ == "__main__": + main() diff --git a/migrations/2026-05-25_profil_reservieren_flag.py b/migrations/2026-05-25_profil_reservieren_flag.py new file mode 100644 index 0000000..9e1a7c6 --- /dev/null +++ b/migrations/2026-05-25_profil_reservieren_flag.py @@ -0,0 +1,31 @@ +"""Migration 2026-05-25: profil.reservieren_freigeschaltet einfuehren. + +Default fuer bestehende und neue Profile: 0 (nicht freigeschaltet). +Lehrer muss pro SuS aktiv freischalten. Ersetzt den geplanten L1-Gate- +Automatismus, weil das Setting heterogen ist (manche SuS drucken zuhause). + +Idempotent. +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from database import get_db + + +def main(): + conn = get_db() + spalten = {row["name"] for row in conn.execute("PRAGMA table_info(profil)")} + if "reservieren_freigeschaltet" in spalten: + conn.close() + print("[migration] profil.reservieren_freigeschaltet existiert bereits.") + return + conn.execute("ALTER TABLE profil ADD COLUMN reservieren_freigeschaltet INTEGER DEFAULT 0") + conn.execute("UPDATE profil SET reservieren_freigeschaltet = 0 WHERE reservieren_freigeschaltet IS NULL") + conn.commit() + conn.close() + print("[migration] profil.reservieren_freigeschaltet ergaenzt (alle Profile default=0).") + + +if __name__ == "__main__": + main() diff --git a/migrations/2026-05-25_projekt_eval_cover.py b/migrations/2026-05-25_projekt_eval_cover.py new file mode 100644 index 0000000..d9570cd --- /dev/null +++ b/migrations/2026-05-25_projekt_eval_cover.py @@ -0,0 +1,121 @@ +"""Migration 2026-05-25: Projekt-Tabelle um Eval-Achsen + Cover-Foto. + +Differenzierte Selbstbewertung (Etappe 6, nach Reality-Check mit Herb): +- Gesamt-Bewertung war schon da (selbsteinschaetzung). +- NEU: Planung-Qualitaet + Ausfuehrungs-Qualitaet als zweite Achsen + (gut/ok/schwierig), damit SuS reflektiert was wirklich besser ginge. +- NEU: was_klappte_nicht — Freitext, damit der Lehrer beim Review-Misserfolg + Kontext hat (statt Selbst-Geschoenrede). +- NEU: cover_blob + cover_mime — Crop-Tool aus admin_software_bild.html + portiert, gleiches Pattern wie software_seite.bild_blob. + +Idempotent. + +Legt zusaetzlich 2 Mock-Projekte fuer Profil 100 (Test-Biene) an, falls +keine eigenen Projekte existieren. Damit ist die Cockpit-/Katalog-Logik +testbar ohne von Hand Daten anzulegen. +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from database import get_db + + +NEUE_SPALTEN = [ + ("selbsteinschaetzung_planung", "TEXT"), + ("selbsteinschaetzung_ausfuehrung", "TEXT"), + ("was_klappte_nicht", "TEXT"), + ("cover_blob", "BLOB"), + ("cover_mime", "TEXT"), +] + + +MOCK_PROJEKTE = [ + { + "titel": "Schluesselanhaenger mit meinem Namen", + "beschreibung_md": ( + "Mein erstes 3D-Modell ueberhaupt! Mit MakerWorld-Customizer einen " + "Schluesselanhaenger mit *Test* eingegeben. PLA gelb auf P1S #1, etwa " + "25 Minuten Druckzeit." + ), + "status_lebenszyklus": "abgeschlossen", + "abgeschlossen_am": "2026-05-15 14:30:00", + "selbsteinschaetzung": "zufrieden", + "selbsteinschaetzung_planung": "ok", + "selbsteinschaetzung_ausfuehrung": "gut", + "was_gelernt": "Slicen geht erstaunlich schnell — und wenn Bett warm genug ist, klebt es richtig gut.", + "was_klappte_nicht": None, + "freigabe_status": "freigegeben", + }, + { + "titel": "Stifteigel fuer den Schreibtisch", + "beschreibung_md": ( + "Ein kleiner Igel, in dessen Ruecken man Stifte stecken kann. Vorlage " + "von Printables, etwas angepasst (groessere Loecher fuer dicke Filzstifte). " + "Druck laeuft gerade noch." + ), + "status_lebenszyklus": "in_arbeit", + "abgeschlossen_am": None, + "selbsteinschaetzung": None, + "selbsteinschaetzung_planung": None, + "selbsteinschaetzung_ausfuehrung": None, + "was_gelernt": None, + "was_klappte_nicht": None, + "freigabe_status": "in_arbeit", + }, +] + + +def main(): + conn = get_db() + + # ---- Schema-Erweiterung ---- + spalten = {row["name"] for row in conn.execute("PRAGMA table_info(projekt)")} + angelegt = [] + for name, typ in NEUE_SPALTEN: + if name not in spalten: + conn.execute(f"ALTER TABLE projekt ADD COLUMN {name} {typ}") + angelegt.append(name) + if angelegt: + print(f"[migration] projekt: Spalten ergaenzt: {', '.join(angelegt)}.") + else: + print("[migration] projekt: alle neuen Spalten existieren bereits.") + + # ---- Mock-Projekte fuer Test-Biene (Profil 100) ---- + profil = conn.execute( + "SELECT id, vorname FROM profil WHERE id = 100 AND aktiv = 1" + ).fetchone() + if not profil: + print("[migration] Profil 100 nicht gefunden — Mock-Daten ueberspringen.") + conn.commit() + conn.close() + return + + schon_da = conn.execute( + "SELECT COUNT(*) AS n FROM projekt WHERE profil_id = 100" + ).fetchone()["n"] + if schon_da > 0: + print(f"[migration] Profil 100 hat schon {schon_da} Projekte — Mock-Daten ueberspringen.") + conn.commit() + conn.close() + return + + for p in MOCK_PROJEKTE: + conn.execute( + """INSERT INTO projekt + (profil_id, titel, beschreibung_md, status_lebenszyklus, abgeschlossen_am, + selbsteinschaetzung, selbsteinschaetzung_planung, selbsteinschaetzung_ausfuehrung, + was_gelernt, was_klappte_nicht, freigabe_status, sichtbar_in_ag) + VALUES (100, :titel, :beschreibung_md, :status_lebenszyklus, :abgeschlossen_am, + :selbsteinschaetzung, :selbsteinschaetzung_planung, :selbsteinschaetzung_ausfuehrung, + :was_gelernt, :was_klappte_nicht, :freigabe_status, 1)""", + p + ) + conn.commit() + print(f"[migration] {len(MOCK_PROJEKTE)} Mock-Projekte fuer Profil 100 ({profil['vorname']}) angelegt.") + conn.close() + + +if __name__ == "__main__": + main() diff --git a/migrations/2026-05-25_projekt_werkstatt_felder.py b/migrations/2026-05-25_projekt_werkstatt_felder.py new file mode 100644 index 0000000..d1e5c96 --- /dev/null +++ b/migrations/2026-05-25_projekt_werkstatt_felder.py @@ -0,0 +1,44 @@ +"""Migration 2026-05-25: projekt-Tabelle um Werkstatt-Felder. + +Mit Herb am 2026-05-25 festgelegt: +- modellier_software_slug: welche Hauptsoftware wurde benutzt (Single-Select). + FK auf software_seite.slug (kein DB-Constraint, weil software_seite-Karten + per Admin geloescht werden koennen — alter Slug bleibt dann als Klartext). +- drucker_id_used: welcher Drucker wurde benutzt (Single-Select). Nicht 'drucker_id', + weil das Schema-historisch fuer Slots gedacht war; 'used' macht's eindeutig. +- makerworld_url: optionale Veroeffentlichung auf MakerWorld (externer Link). + +Idempotent. +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from database import get_db + + +NEUE_SPALTEN = [ + ("modellier_software_slug", "TEXT"), + ("drucker_id_used", "INTEGER"), + ("makerworld_url", "TEXT"), +] + + +def main(): + conn = get_db() + spalten = {row["name"] for row in conn.execute("PRAGMA table_info(projekt)")} + angelegt = [] + for name, typ in NEUE_SPALTEN: + if name not in spalten: + conn.execute(f"ALTER TABLE projekt ADD COLUMN {name} {typ}") + angelegt.append(name) + conn.commit() + conn.close() + if angelegt: + print(f"[migration] projekt: Spalten ergaenzt: {', '.join(angelegt)}.") + else: + print("[migration] projekt: alle Werkstatt-Spalten existieren bereits.") + + +if __name__ == "__main__": + main() diff --git a/migrations/2026-05-25_software_bild_blob.py b/migrations/2026-05-25_software_bild_blob.py new file mode 100644 index 0000000..76c3288 --- /dev/null +++ b/migrations/2026-05-25_software_bild_blob.py @@ -0,0 +1,35 @@ +"""Migration 2026-05-25: Software-Karten kriegen Bild-BLOB-Spalten. + +Bilder werden direkt in der DB gespeichert (analog zu arduino.lehrstun.de's +bauteile.bild). Vorteil gegenueber static/img/: kein Filesystem-Drift, +ein Backup deckt alles ab, kein Sync zwischen lokaler DB und Hetzner noetig. + +Idempotent: prueft PRAGMA table_info bevor ADD COLUMN. +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from database import get_db + + +def main(): + conn = get_db() + spalten = {row["name"] for row in conn.execute("PRAGMA table_info(software_seite)")} + aenderungen = [] + if "bild_blob" not in spalten: + conn.execute("ALTER TABLE software_seite ADD COLUMN bild_blob BLOB") + aenderungen.append("bild_blob") + if "bild_mime" not in spalten: + conn.execute("ALTER TABLE software_seite ADD COLUMN bild_mime TEXT") + aenderungen.append("bild_mime") + conn.commit() + conn.close() + if aenderungen: + print(f"[migration] Spalten ergaenzt: {', '.join(aenderungen)}.") + else: + print("[migration] Spalten bild_blob + bild_mime existieren bereits — nichts zu tun.") + + +if __name__ == "__main__": + main() diff --git a/migrations/2026-05-25_software_sektion.py b/migrations/2026-05-25_software_sektion.py new file mode 100644 index 0000000..ca3b17e --- /dev/null +++ b/migrations/2026-05-25_software_sektion.py @@ -0,0 +1,164 @@ +"""Migration 2026-05-25: Software-Karten initial befuellen. + +Etappe 3 — schlankes Karten-Konzept (siehe docs/decisions.md, neue Entscheidung +zur Software-Sektion). Wir halten uns raus: SuS haben die Software auf den +Schul-PCs / im Browser und machen eigene Erfahrungen. Wir liefern nur die +Auswahl und ein paar Tutorials. + +Inhalte pro Karte: 1-2 Satz Beschreibung, Hauptlink, optional Tutorial-Link. +Apps zusaetzlich App-Store + Play-Store-URLs (QR-Codes folgen in Folge-Schritt). + +Idempotent: INSERT OR REPLACE auf slug. Achtung: ueberschreibt Inline-Edits +des Lehrers. Nur fuer Initial-Seed gedacht. +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from database import get_db, init_db + + +SOFTWARE = [ + # ---------- Slicer (Pflicht) ---------- + { + "slug": "bambu-studio", + "titel": "Bambu Studio", + "kurzbeschreibung": "Slicer fuer unsere Drucker. Modell oeffnen, Drucker waehlen, drucken.", + "url": "https://bambulab.com/de/download/studio", + "tutorial_url": "https://wiki.bambulab.com/en/software/bambu-studio/studio-quick-start", + "plattform": "Windows / macOS / Linux", + "kategorie": "software", + "sortierung": 10, + }, + # ---------- App ---------- + { + "slug": "bambu-handy", + "titel": "Bambu Handy", + "kurzbeschreibung": "Drucker live am Handy beobachten, pausieren, Kamera ansehen.", + "url": "https://bambulab.com/de/download", + "tutorial_url": None, + "plattform": "iOS + Android", + "app_store_url": "https://apps.apple.com/de/app/bambu-handy/id1620054970", + "play_store_url": "https://play.google.com/store/apps/details?id=com.bambulab.bambu_link", + "kategorie": "app", + "sortierung": 20, + }, + # ---------- Modell-Plattformen ---------- + { + "slug": "printables", + "titel": "Printables", + "kurzbeschreibung": "Modell-Plattform von Prusa. Werbefrei, deutsche UI — erste Adresse fuer fertige Modelle.", + "url": "https://www.printables.com", + "tutorial_url": "https://www.printables.com/education", + "plattform": "Browser", + "kategorie": "software", + "sortierung": 30, + }, + { + "slug": "makerworld", + "titel": "MakerWorld", + "kurzbeschreibung": "Bambu-eigene Modell-Plattform mit Customizer — Modelle mit eigenem Namen oder Wunschmassen.", + "url": "https://makerworld.com", + "tutorial_url": "https://makerworld.com/en/models/1320280", # Parametric Name Keychain als Erst-Customizer + "plattform": "Browser (Konto noetig)", + "kategorie": "software", + "sortierung": 40, + }, + # ---------- CAD ---------- + { + "slug": "tinkercad", + "titel": "TinkerCAD", + "kurzbeschreibung": "Visuelles 3D-CAD im Browser. Grundkoerper ziehen, kombinieren, exportieren. Einstieg ins eigene Modellieren.", + "url": "https://www.tinkercad.com", + "tutorial_url": "https://www.tinkercad.com/learn", + "plattform": "Browser", + "kategorie": "software", + "sortierung": 50, + }, + # OpenSCAD bewusst nicht aufgenommen — solange wir keine Lektion dafuer + # haben, wuerden wir's nur als Karte "in den Raum stellen". Wenn spaeter + # eine OpenSCAD-Lektion entsteht (Bruecke zur Informatik), kann die Karte + # via Admin-GUI oder Folge-Migration nachgezogen werden. + { + "slug": "onshape", + "titel": "Onshape", + "kurzbeschreibung": "Professionelles CAD im Browser. Was Profis nutzen — fuer Schulen kostenlos, ab 13 Jahren.", + "url": "https://www.onshape.com/de/education/", + "tutorial_url": "https://www.youtube.com/watch?v=VyVqG8ZPvaM", # stolz3D Anfaenger Grundkurs + "plattform": "Browser + iOS + Android", + "kategorie": "software", + "sortierung": 70, + }, + # ---------- Online-Generatoren ---------- + { + "slug": "gridfinity", + "titel": "Gridfinity Builder", + "kurzbeschreibung": "Online-Generator fuer Gridfinity-Boxen — modulare Werkstatt-Ordnungssysteme nach Mass.", + "url": "https://gridfinity.perplexinglabs.com/pr/gridfinity-rebuilt/0/1", + "tutorial_url": None, + "plattform": "Browser", + "kategorie": "generator", + "sortierung": 80, + }, +] + + +# Felder, die als Spalten gepflegt werden (alle anderen Keys werden ignoriert) +SPALTEN = ( + "slug", "titel", "kurzbeschreibung", "url", "tutorial_url", + "plattform", "app_store_url", "play_store_url", "bild_pfad", + "kategorie", "sortierung", +) + + +def main(): + init_db() + conn = get_db() + angelegt = 0 + aktualisiert = 0 + for s in SOFTWARE: + # Default-Werte fuer optionale Felder, damit das UPDATE/INSERT einheitlich ist + werte = {sp: s.get(sp) for sp in SPALTEN} + + existiert = conn.execute( + "SELECT 1 FROM software_seite WHERE slug = ?", (werte["slug"],) + ).fetchone() + if existiert: + conn.execute( + """UPDATE software_seite SET + titel = :titel, + kurzbeschreibung = :kurzbeschreibung, + url = :url, + tutorial_url = :tutorial_url, + plattform = :plattform, + app_store_url = :app_store_url, + play_store_url = :play_store_url, + bild_pfad = :bild_pfad, + kategorie = :kategorie, + sortierung = :sortierung, + aktiv = 1 + WHERE slug = :slug""", + werte + ) + aktualisiert += 1 + else: + conn.execute( + """INSERT INTO software_seite + (slug, titel, kurzbeschreibung, url, tutorial_url, + plattform, app_store_url, play_store_url, bild_pfad, + kategorie, sortierung, aktiv) + VALUES + (:slug, :titel, :kurzbeschreibung, :url, :tutorial_url, + :plattform, :app_store_url, :play_store_url, :bild_pfad, + :kategorie, :sortierung, 1)""", + werte + ) + angelegt += 1 + conn.commit() + conn.close() + print(f"[migration] software_seite: {angelegt} angelegt, {aktualisiert} aktualisiert " + f"({len(SOFTWARE)} Karten total).") + + +if __name__ == "__main__": + main() diff --git a/routes/admin.py b/routes/admin.py index 5792ac0..a4a1b10 100644 --- a/routes/admin.py +++ b/routes/admin.py @@ -5,6 +5,7 @@ Etappe 2: Drill-Down-Dashboard (V5). Etappe 6: Projekt-Review-Queue. Etappe 7: Druckauftrag-Freigabe + Slot-Priorisierung. """ +import base64 import secrets from flask import Blueprint, render_template, request, redirect, url_for, session, flash, jsonify, abort @@ -24,10 +25,13 @@ ERLAUBTE_INLINE_TABELLEN = { # Tabelle -> Set erlaubter Felder "lektion": {"titel", "aufgabe_md", "tipp_md", "extern_link"}, "einstellung": {"wert"}, - "drucker": {"name", "notiz"}, + "drucker": {"name", "notiz", "manual_url", "troubleshooting_url", "gesperrt_grund"}, + "software_seite": { + "titel", "kurzbeschreibung", "url", "tutorial_url", + "plattform", "app_store_url", "play_store_url", "bild_pfad", + }, # spaeter: # "projekt": {"titel", "beschreibung_md"} -- mit Status-Check, Etappe 6 - # "software_seite": {"titel", "inhalt_md", "url", "quickstart_md"} -- Etappe 3 } @@ -43,7 +47,7 @@ def admin_login(): if admin_passwort_pruefen(eingabe): session["admin"] = True flash("Admin-Modus aktiv.", "success") - return redirect(url_for("admin.profile_liste")) + return redirect(url_for("admin.dashboard")) flash("Falsches Passwort.", "error") return render_template("admin_login.html") @@ -65,7 +69,7 @@ def profile_liste(): conn = get_db() profile = conn.execute( "SELECT p.id, p.vorname, p.tier, p.aktiv, p.erstellt_am, " - " p.ag_id, ag.name AS ag_name " + " p.ag_id, p.reservieren_freigeschaltet, ag.name AS ag_name " "FROM profil p " "LEFT JOIN ag_mitgliedschaft ag ON p.ag_id = ag.id " "ORDER BY p.aktiv DESC, p.vorname COLLATE NOCASE" @@ -134,6 +138,29 @@ def profil_aktiv_toggle(profil_id): return redirect(url_for("admin.profile_liste")) +@bp.route("/profil//reservieren-toggle", methods=["POST"]) +@admin_required +def profil_reservieren_toggle(profil_id): + """Lehrer schaltet die Reservierungs-Freigabe pro SuS um.""" + conn = get_db() + conn.execute( + "UPDATE profil SET reservieren_freigeschaltet = " + "CASE reservieren_freigeschaltet WHEN 1 THEN 0 ELSE 1 END WHERE id = ?", + (profil_id,) + ) + conn.commit() + row = conn.execute( + "SELECT vorname, reservieren_freigeschaltet FROM profil WHERE id = ?", (profil_id,) + ).fetchone() + conn.close() + if row: + if row["reservieren_freigeschaltet"]: + flash(f"{row['vorname']} darf jetzt Drucker reservieren.", "success") + else: + flash(f"Reservierungs-Freigabe fuer {row['vorname']} aufgehoben.", "success") + return redirect(url_for("admin.profile_liste")) + + @bp.route("/profil//pin-reset", methods=["POST"]) @admin_required def profil_pin_reset(profil_id): @@ -231,6 +258,556 @@ def profile_karten(): return render_template("admin_profile_karten.html", profile=profile) +# =========================================================================== +# Slot-Verwaltung (Iteration B2) — Lehrer reserviert, sieht, loescht +# =========================================================================== + +@bp.route("/slots") +@admin_required +def slot_uebersicht(): + """Uebersicht aller bevorstehenden + heute aktiven Slots.""" + from datetime import datetime, timedelta + conn = get_db() + # Heute beendete bleiben sichtbar (manche sind erst gerade fertig) + grenze = (datetime.now() - timedelta(hours=2)).isoformat() + slots = conn.execute( + "SELECT s.id, s.drucker_id, s.profil_id, s.start_zeit, s.ende_zeit, " + " s.status, s.notiz, " + " d.name AS drucker_name, d.modell, d.freigegeben, " + " p.vorname, p.tier " + "FROM slot s " + "LEFT JOIN drucker d ON d.id = s.drucker_id " + "LEFT JOIN profil p ON p.id = s.profil_id " + "WHERE s.ende_zeit >= ? " + "ORDER BY s.start_zeit ASC", + (grenze,) + ).fetchall() + conn.close() + return render_template("admin_slots.html", slots=slots) + + +@bp.route("/slot/neu", methods=["GET", "POST"]) +@admin_required +def slot_neu_lehrer(): + """Lehrer reserviert im Namen eines SuS — ignoriert das Freischalt-Flag.""" + from datetime import datetime, timedelta + from services.slots import ( + parse_datetime_local, validiere_zeitraum, konflikt_check + ) + + conn = get_db() + profile = conn.execute( + "SELECT id, vorname, tier FROM profil WHERE aktiv = 1 " + "ORDER BY vorname COLLATE NOCASE, tier" + ).fetchall() + drucker_liste = conn.execute( + "SELECT id, name, modell, freigegeben FROM drucker " + "WHERE aktiv = 1 ORDER BY modell, name" + ).fetchall() + + jetzt = datetime.now() + default_start = (jetzt + timedelta(minutes=5)).replace(second=0, microsecond=0) + default_ende = default_start + timedelta(hours=1) + + if request.method == "POST": + try: + profil_id = int(request.form.get("profil_id") or 0) + drucker_id = int(request.form.get("drucker_id") or 0) + except ValueError: + profil_id = drucker_id = 0 + start = parse_datetime_local(request.form.get("start_zeit") or "") + ende = parse_datetime_local(request.form.get("ende_zeit") or "") + notiz = (request.form.get("notiz") or "").strip() or None + + # Basis-Validierung + if not profil_id: + conn.close() + flash("Bitte SuS-Profil waehlen.", "error") + return redirect(url_for("admin.slot_neu_lehrer")) + if not drucker_id: + conn.close() + flash("Bitte Drucker waehlen.", "error") + return redirect(url_for("admin.slot_neu_lehrer")) + + drucker = conn.execute( + "SELECT id, name, freigegeben FROM drucker WHERE id = ? AND aktiv = 1", + (drucker_id,) + ).fetchone() + if not drucker: + conn.close() + flash("Drucker nicht gefunden.", "error") + return redirect(url_for("admin.slot_neu_lehrer")) + # Lehrer darf auch fuer gesperrte Drucker reservieren (z.B. nach Reparatur), + # aber wir warnen kurz via Flash. + if not drucker["freigegeben"]: + flash(f"Achtung: '{drucker['name']}' ist gesperrt — der Slot wird trotzdem angelegt.", "success") + + fehler = validiere_zeitraum(start, ende, jetzt) + if fehler: + conn.close() + flash(fehler, "error") + return redirect(url_for("admin.slot_neu_lehrer")) + + konflikte = konflikt_check(conn, drucker_id, start, ende) + if konflikte: + k = konflikte[0] + conn.close() + flash( + f"Zeitraum-Konflikt: ein anderer Slot blockiert den Drucker von " + f"{k['start_zeit'][11:16]} bis {k['ende_zeit'][11:16]}.", + "error" + ) + return redirect(url_for("admin.slot_neu_lehrer")) + + conn.execute( + "INSERT INTO slot (drucker_id, profil_id, start_zeit, ende_zeit, status, notiz) " + "VALUES (?, ?, ?, ?, 'reserviert', ?)", + (drucker_id, profil_id, start.isoformat(), ende.isoformat(), notiz) + ) + conn.commit() + conn.close() + flash("Slot angelegt.", "success") + return redirect(url_for("admin.slot_uebersicht")) + + # GET — Form + conn.close() + # Vorauswahl via Query-Param (drucker_id und/oder profil_id) + try: + vor_drucker = int(request.args.get("drucker_id", 0)) + except ValueError: + vor_drucker = 0 + try: + vor_profil = int(request.args.get("profil_id", 0)) + except ValueError: + vor_profil = 0 + return render_template( + "admin_slot_neu.html", + profile=profile, drucker_liste=drucker_liste, + default_start=default_start, default_ende=default_ende, + vor_drucker=vor_drucker, vor_profil=vor_profil, + ) + + +@bp.route("/slot//loeschen", methods=["POST"]) +@admin_required +def slot_loeschen(slot_id): + """Slot hart loeschen (Lehrer-Override). Ein 'stornieren'-Status haetten + wir auch, aber Lehrer-Aufraeumarbeiten sind oft endgueltig. + """ + conn = get_db() + row = conn.execute( + "SELECT s.id, p.vorname FROM slot s LEFT JOIN profil p ON p.id = s.profil_id WHERE s.id = ?", + (slot_id,) + ).fetchone() + if not row: + conn.close() + flash("Slot nicht gefunden.", "error") + return redirect(url_for("admin.slot_uebersicht")) + conn.execute("DELETE FROM slot WHERE id = ?", (slot_id,)) + conn.commit() + conn.close() + flash(f"Slot von {row['vorname'] or '—'} geloescht.", "success") + return redirect(url_for("admin.slot_uebersicht")) + + +# =========================================================================== +# Software-Sektion verwalten (Etappe 3) +# =========================================================================== + +KATEGORIEN = ("software", "app", "generator") + + +def _software_clean_form(form): + """Liest ein Software-Formular und gibt ein dict zurueck. + + Leere Strings werden zu None konvertiert (sauberer in der DB). + Wirft ValueError mit deutscher Meldung, wenn Pflichtfelder fehlen + oder die Kategorie unbekannt ist. + """ + def s(name): + v = (form.get(name) or "").strip() + return v or None + + slug = s("slug") + titel = s("titel") + kurz = s("kurzbeschreibung") + url = s("url") + kategorie = s("kategorie") or "software" + + if not slug: + raise ValueError("Slug fehlt.") + if not titel: + raise ValueError("Titel fehlt.") + if not kurz: + raise ValueError("Kurzbeschreibung fehlt.") + if not url: + raise ValueError("Hauptlink (URL) fehlt.") + if kategorie not in KATEGORIEN: + raise ValueError(f"Kategorie '{kategorie}' unbekannt.") + + # Sortierung: leer = ans Ende + try: + sortierung = int(form.get("sortierung") or 100) + except ValueError: + sortierung = 100 + + return { + "slug": slug, + "titel": titel, + "kurzbeschreibung": kurz, + "url": url, + "tutorial_url": s("tutorial_url"), + "plattform": s("plattform"), + "app_store_url": s("app_store_url"), + "play_store_url": s("play_store_url"), + "bild_pfad": s("bild_pfad"), + "kategorie": kategorie, + "sortierung": sortierung, + } + + +@bp.route("/software") +@admin_required +def software_liste(): + """Verwaltung der Software-Karten — Liste + Anlegen-Formular auf einer Seite.""" + conn = get_db() + karten = conn.execute( + "SELECT id, slug, titel, kurzbeschreibung, url, tutorial_url, plattform, " + " app_store_url, play_store_url, bild_pfad, kategorie, sortierung, aktiv " + "FROM software_seite ORDER BY kategorie, sortierung, titel" + ).fetchall() + conn.close() + return render_template("admin_software.html", karten=karten, kategorien=KATEGORIEN) + + +@bp.route("/software/neu", methods=["POST"]) +@admin_required +def software_neu(): + try: + daten = _software_clean_form(request.form) + except ValueError as e: + flash(str(e), "error") + return redirect(url_for("admin.software_liste")) + + conn = get_db() + schon_da = conn.execute( + "SELECT 1 FROM software_seite WHERE slug = ?", (daten["slug"],) + ).fetchone() + if schon_da: + conn.close() + flash(f"Slug '{daten['slug']}' ist bereits vergeben.", "error") + return redirect(url_for("admin.software_liste")) + + conn.execute( + """INSERT INTO software_seite + (slug, titel, kurzbeschreibung, url, tutorial_url, + plattform, app_store_url, play_store_url, bild_pfad, + kategorie, sortierung, aktiv) + VALUES + (:slug, :titel, :kurzbeschreibung, :url, :tutorial_url, + :plattform, :app_store_url, :play_store_url, :bild_pfad, + :kategorie, :sortierung, 1)""", + daten + ) + conn.commit() + conn.close() + flash(f"'{daten['titel']}' angelegt.", "success") + return redirect(url_for("admin.software_liste")) + + +@bp.route("/software//bearbeiten", methods=["GET", "POST"]) +@admin_required +def software_bearbeiten(karte_id): + """Bearbeiten-Seite fuer eine Software-Karte. Gleiches Form-Layout wie /neu, + aber alle Felder vorbefuellt. Slug kann hier NICHT geaendert werden (waere + Inkonsistenz, wenn jemand einen Link teilt) — fuer Slug-Umbenennung muesste + man loeschen + neu anlegen. + """ + conn = get_db() + karte = conn.execute( + "SELECT id, slug, titel, kurzbeschreibung, url, tutorial_url, plattform, " + " app_store_url, play_store_url, bild_pfad, kategorie, sortierung, aktiv, " + " (bild_blob IS NOT NULL) AS hat_bild_blob " + "FROM software_seite WHERE id = ?", (karte_id,) + ).fetchone() + conn.close() + + if not karte: + flash("Karte nicht gefunden.", "error") + return redirect(url_for("admin.software_liste")) + + if request.method == "POST": + try: + # Slug aus der bestehenden Karte uebernehmen (kein Edit), Rest aus Form + form_daten = dict(request.form) + form_daten["slug"] = karte["slug"] + daten = _software_clean_form(form_daten) + except ValueError as e: + flash(str(e), "error") + return redirect(url_for("admin.software_bearbeiten", karte_id=karte_id)) + + conn = get_db() + conn.execute( + """UPDATE software_seite SET + titel = :titel, + kurzbeschreibung = :kurzbeschreibung, + url = :url, + tutorial_url = :tutorial_url, + plattform = :plattform, + app_store_url = :app_store_url, + play_store_url = :play_store_url, + bild_pfad = :bild_pfad, + kategorie = :kategorie, + sortierung = :sortierung + WHERE id = :id""", + {**daten, "id": karte_id} + ) + conn.commit() + conn.close() + flash(f"'{daten['titel']}' gespeichert.", "success") + return redirect(url_for("admin.software_liste")) + + return render_template("admin_software_edit.html", karte=karte, kategorien=KATEGORIEN) + + +@bp.route("/software//aktiv", methods=["POST"]) +@admin_required +def software_aktiv_toggle(karte_id): + conn = get_db() + conn.execute( + "UPDATE software_seite SET aktiv = CASE aktiv WHEN 1 THEN 0 ELSE 1 END WHERE id = ?", + (karte_id,) + ) + conn.commit() + conn.close() + return redirect(url_for("admin.software_liste")) + + +@bp.route("/software//loeschen", methods=["POST"]) +@admin_required +def software_loeschen(karte_id): + """Hart loeschen — wir haben keine FK-Beziehungen zu Software-Karten, das ist sicher.""" + conn = get_db() + row = conn.execute( + "SELECT titel FROM software_seite WHERE id = ?", (karte_id,) + ).fetchone() + if not row: + conn.close() + flash("Karte nicht gefunden.", "error") + return redirect(url_for("admin.software_liste")) + conn.execute("DELETE FROM software_seite WHERE id = ?", (karte_id,)) + conn.commit() + conn.close() + flash(f"'{row['titel']}' geloescht.", "success") + return redirect(url_for("admin.software_liste")) + + +# =========================================================================== +# Projekt-Review (Selbstlern-Loop, Etappe 6) +# =========================================================================== + +@bp.route("/projekte") +@admin_required +def projekte_uebersicht(): + """Alle Projekte aller SuS — mit Status-Filter.""" + from services.projekte import alle_projekte, status_zaehler + aktueller_filter = (request.args.get("filter") or "alle").strip() + if aktueller_filter not in ("alle", "wartet", "in_arbeit", "abgeschlossen", "rueckfrage"): + aktueller_filter = "alle" + conn = get_db() + projekte = alle_projekte(conn, None if aktueller_filter == "alle" else aktueller_filter) + zaehler = status_zaehler(conn) + conn.close() + return render_template( + "admin_projekte.html", + projekte=projekte, zaehler=zaehler, aktueller_filter=aktueller_filter + ) + + +@bp.route("/projekt-freigabe") +@admin_required +def projekt_freigabe(): + """Alias auf die Uebersicht mit Filter=wartet (Kompatibilitaet zu alten Links).""" + return redirect(url_for("admin.projekte_uebersicht", filter="wartet")) + + +@bp.route("/projekt/") +@admin_required +def projekt_review(projekt_id): + """Detail-Sicht eines Projekts mit Lehrer-Aktionen.""" + from services.projekte import projekt_holen + conn = get_db() + p = projekt_holen(conn, projekt_id) + if not p: + conn.close() + flash("Projekt nicht gefunden.", "error") + return redirect(url_for("admin.projekt_freigabe")) + profil = conn.execute( + "SELECT id, vorname, tier FROM profil WHERE id = ?", (p["profil_id"],) + ).fetchone() + rueckfragen = conn.execute( + "SELECT id, text, status, erstellt_am, erledigt_am " + "FROM projekt_rueckfrage WHERE projekt_id = ? ORDER BY erstellt_am DESC", + (projekt_id,) + ).fetchall() + conn.close() + return render_template( + "admin_projekt_review.html", + p=p, profil=dict(profil) if profil else None, + rueckfragen=rueckfragen, + ) + + +@bp.route("/projekt//freigeben", methods=["POST"]) +@admin_required +def projekt_freigeben(projekt_id): + """Lehrer setzt freigabe_status = 'freigegeben'.""" + conn = get_db() + row = conn.execute( + "SELECT titel FROM projekt WHERE id = ?", (projekt_id,) + ).fetchone() + if not row: + conn.close() + flash("Projekt nicht gefunden.", "error") + return redirect(url_for("admin.projekt_freigabe")) + conn.execute( + "UPDATE projekt SET freigabe_status = 'freigegeben', " + "freigegeben_am = CURRENT_TIMESTAMP WHERE id = ?", + (projekt_id,) + ) + conn.commit() + conn.close() + flash(f"Projekt '{row['titel']}' freigegeben.", "success") + return redirect(url_for("admin.projekt_freigabe")) + + +@bp.route("/projekt//rueckfrage", methods=["POST"]) +@admin_required +def projekt_rueckfrage(projekt_id): + """Lehrer stellt Rueckfrage — SuS sieht sie im Cockpit und kann sie beantworten.""" + text = (request.form.get("text") or "").strip() + if not text: + flash("Rueckfrage darf nicht leer sein.", "error") + return redirect(url_for("admin.projekt_review", projekt_id=projekt_id)) + conn = get_db() + row = conn.execute( + "SELECT profil_id, titel FROM projekt WHERE id = ?", (projekt_id,) + ).fetchone() + if not row: + conn.close() + flash("Projekt nicht gefunden.", "error") + return redirect(url_for("admin.projekt_freigabe")) + conn.execute( + "INSERT INTO projekt_rueckfrage (projekt_id, von_profil_id, text, status) " + "VALUES (?, ?, ?, 'offen')", + (projekt_id, None, text) # von_profil_id NULL = Lehrer (kein eigenes Profil) + ) + conn.execute( + "UPDATE projekt SET freigabe_status = 'rueckfrage' WHERE id = ?", + (projekt_id,) + ) + conn.commit() + conn.close() + flash(f"Rueckfrage zu '{row['titel']}' an SuS geschickt.", "success") + return redirect(url_for("admin.projekt_freigabe")) + + +@bp.route("/projekt//loeschen", methods=["POST"]) +@admin_required +def projekt_admin_loeschen(projekt_id): + """Lehrer loescht ein Projekt hart (z.B. Penismodelle, Copyright). + + Audit-Log: in aenderung_log dokumentiert, weil das eine destruktive Aktion + ist, die kein SuS aufrufen kann. + """ + conn = get_db() + row = conn.execute( + "SELECT titel, profil_id FROM projekt WHERE id = ?", (projekt_id,) + ).fetchone() + if not row: + conn.close() + flash("Projekt nicht gefunden.", "error") + return redirect(url_for("admin.projekt_freigabe")) + conn.execute( + "INSERT INTO aenderung_log (tabelle, datensatz_id, feld, alter_wert, neuer_wert) " + "VALUES (?, ?, ?, ?, ?)", + ("projekt", projekt_id, "DELETE", row["titel"], "") + ) + conn.execute("DELETE FROM projekt WHERE id = ?", (projekt_id,)) + conn.commit() + conn.close() + flash(f"Projekt '{row['titel']}' geloescht.", "success") + return redirect(url_for("admin.projekt_freigabe")) + + +# =========================================================================== +# Software-Bild: Crop-Upload + Loeschen +# =========================================================================== + +@bp.route("/software//bild", methods=["GET", "POST"]) +@admin_required +def software_bild(karte_id): + """Bild fuer eine Software-Karte hochladen (Paste + Crop, base64 ueber JSON). + + GET: zeigt das Crop-Tool (analog admin_bauteil_bild.html aus arduino.lehrstun.de). + POST: erwartet JSON {bild_base64: 'data:image/jpeg;base64,...'}, speichert BLOB. + """ + conn = get_db() + karte = conn.execute( + "SELECT id, slug, titel, kategorie FROM software_seite WHERE id = ?", (karte_id,) + ).fetchone() + conn.close() + + if not karte: + if request.method == "POST": + return jsonify(error="Karte nicht gefunden"), 404 + flash("Karte nicht gefunden.", "error") + return redirect(url_for("admin.software_liste")) + + if request.method == "POST": + data = request.get_json(silent=True) or {} + bild_str = data.get("bild_base64") + if not bild_str: + return jsonify(error="Kein Bild empfangen"), 400 + + # Format: 'data:image/jpeg;base64,/9j/4AAQ...' -> Mime + Roh-Base64 trennen + mime = "image/jpeg" + if "," in bild_str: + header, bild_str = bild_str.split(",", 1) + if ":" in header and ";" in header: + mime = header.split(":")[1].split(";")[0] + + try: + blob = base64.b64decode(bild_str) + except Exception: + return jsonify(error="Bild konnte nicht dekodiert werden"), 400 + + conn = get_db() + conn.execute( + "UPDATE software_seite SET bild_blob = ?, bild_mime = ? WHERE id = ?", + (blob, mime, karte_id) + ) + conn.commit() + conn.close() + return jsonify(ok=True) + + return render_template("admin_software_bild.html", karte=karte) + + +@bp.route("/software//bild/loeschen", methods=["POST"]) +@admin_required +def software_bild_loeschen(karte_id): + conn = get_db() + conn.execute( + "UPDATE software_seite SET bild_blob = NULL, bild_mime = NULL WHERE id = ?", + (karte_id,) + ) + conn.commit() + conn.close() + flash("Bild geloescht.", "success") + return redirect(url_for("admin.software_bearbeiten", karte_id=karte_id)) + + # =========================================================================== # Stubs fuer spaetere Etappen # =========================================================================== @@ -247,6 +824,10 @@ def dashboard(): wer_haengt_fest, dashboard_zahlen, ) + from services.projekte import warten_auf_freigabe_anzahl + conn = get_db() + projekte_warten = warten_auf_freigabe_anzahl(conn) + conn.close() return render_template( "admin_dashboard.html", heute=wer_ist_heute_hier(), @@ -255,6 +836,7 @@ def dashboard(): drucker=drucker_status_alle(), haengen_fest=wer_haengt_fest(), zahlen=dashboard_zahlen(), + projekte_warten=projekte_warten, ) @@ -287,10 +869,96 @@ def admin_lektionen(): @bp.route("/drucker") @admin_required def admin_drucker(): - abort(501) # Etappe 5 + """Drucker-Verwaltung: Status setzen, Freigabe-Toggle, Links pflegen.""" + from services.drucker_links import drucker_links + conn = get_db() + rows = conn.execute( + "SELECT d.id, d.name, d.modell, d.geschlossenes_gehaeuse, d.aktiv, " + " d.notiz, d.freigegeben, d.gesperrt_grund, d.manual_url, d.troubleshooting_url, " + " s.status, s.job_name, s.progress_pct, s.restzeit_min, " + " s.fehlermeldung, s.gepollt_am, s.manuell_gesetzt " + "FROM drucker d " + "LEFT JOIN drucker_status s ON s.drucker_id = d.id " + "ORDER BY d.modell, d.name" + ).fetchall() + conn.close() + drucker_view = [] + for d in rows: + links = drucker_links(d) + drucker_view.append({**dict(d), "manual": links["manual"], "troubleshooting": links["troubleshooting"]}) + return render_template("admin_drucker.html", drucker_liste=drucker_view, status_optionen=DRUCKER_STATUS_OPTIONEN) -@bp.route("/projekt-freigabe") +# Status-Optionen (Auswahl im Lehrer-UI). Manuelle Pflege bis die Bambu-API live ist. +DRUCKER_STATUS_OPTIONEN = ( + ("idle", "⚪ frei"), + ("printing", "🟢 druckt"), + ("paused", "🟡 pausiert"), + ("finished", "✅ fertig — Druck abholen"), + ("failed", "🔴 Fehler"), + ("offline", "⚫ offline"), +) + + +@bp.route("/drucker//status", methods=["POST"]) @admin_required -def projekt_freigabe(): - abort(501) # Etappe 6 +def drucker_status_setzen(drucker_id): + """Lehrer setzt den Status eines Druckers manuell.""" + from datetime import datetime + neuer_status = (request.form.get("status") or "").strip() + erlaubt = {s for s, _ in DRUCKER_STATUS_OPTIONEN} + if neuer_status and neuer_status not in erlaubt: + flash(f"Status '{neuer_status}' ungueltig.", "error") + return redirect(url_for("admin.admin_drucker")) + + job_name = (request.form.get("job_name") or "").strip() or None + fehlermeldung = (request.form.get("fehlermeldung") or "").strip() or None + + conn = get_db() + # Upsert in drucker_status (PRIMARY KEY drucker_id) + existiert = conn.execute( + "SELECT 1 FROM drucker_status WHERE drucker_id = ?", (drucker_id,) + ).fetchone() + if existiert: + conn.execute( + "UPDATE drucker_status SET status = ?, job_name = ?, fehlermeldung = ?, " + " manuell_gesetzt = 1, gepollt_am = ? " + "WHERE drucker_id = ?", + (neuer_status or None, job_name, fehlermeldung, datetime.now().isoformat(), drucker_id) + ) + else: + conn.execute( + "INSERT INTO drucker_status (drucker_id, status, job_name, fehlermeldung, " + " manuell_gesetzt, gepollt_am) " + "VALUES (?, ?, ?, ?, 1, ?)", + (drucker_id, neuer_status or None, job_name, fehlermeldung, datetime.now().isoformat()) + ) + conn.commit() + conn.close() + flash("Status aktualisiert.", "success") + return redirect(url_for("admin.admin_drucker")) + + +@bp.route("/drucker//freigabe", methods=["POST"]) +@admin_required +def drucker_freigabe_toggle(drucker_id): + """Drucker fuer Reservierung freigeben oder sperren (mit optionalem Grund).""" + neuer_wert = 1 if request.form.get("freigeben") == "1" else 0 + grund = (request.form.get("gesperrt_grund") or "").strip() or None + # Wenn freigegeben, Grund leeren — wirkt sauberer + if neuer_wert == 1: + grund = None + conn = get_db() + conn.execute( + "UPDATE drucker SET freigegeben = ?, gesperrt_grund = ? WHERE id = ?", + (neuer_wert, grund, drucker_id) + ) + conn.commit() + conn.close() + if neuer_wert == 1: + flash("Drucker freigegeben.", "success") + else: + flash("Drucker gesperrt." + (f" Grund: {grund}" if grund else ""), "success") + return redirect(url_for("admin.admin_drucker")) + + diff --git a/routes/api.py b/routes/api.py index cc91905..d87730f 100644 --- a/routes/api.py +++ b/routes/api.py @@ -4,11 +4,79 @@ Etappe 5: /api/drucker/status (Live-Status fuer /drucker) Etappe 6: /api/projekte/suche (Filter-Live-Suche im Katalog) Etappe 5: /api/slots/heute (Slot-Polling) """ -from flask import Blueprint, jsonify, abort +import base64 +from flask import Blueprint, jsonify, abort, Response + +from database import get_db bp = Blueprint("api", __name__, url_prefix="/api") +# 1x1 transparentes PNG als Default-Bild fuer Karten ohne BLOB +_LEER_PNG = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQI12NgAAIABQABnil7QQAAAAlFTkSuQmCC" +) + + +@bp.route("/software//bild") +def software_bild(karte_id): + """Liefert das Bild einer Software-Karte aus (BLOB aus DB). + + Wird vom oeffentlichen Karten-Template + vom Admin-Edit-Preview genutzt. + Wenn kein BLOB hinterlegt ist, liefert 1x1-PNG zurueck (kein 404, damit + img-Tags nicht broken sind). + """ + conn = get_db() + row = conn.execute( + "SELECT bild_blob, bild_mime FROM software_seite WHERE id = ?", (karte_id,) + ).fetchone() + conn.close() + + if not row or not row["bild_blob"]: + return Response(_LEER_PNG, mimetype="image/png") + return Response(row["bild_blob"], mimetype=row["bild_mime"] or "image/jpeg") + + +@bp.route("/projekt//datei/") +def projekt_datei_download(projekt_id, datei_id): + """Datei-Download (BLOB-Stream). + + Liefert mit Content-Disposition: attachment, damit der Browser einen + Download-Dialog zeigt (statt das STL inline anzuzeigen). + """ + conn = get_db() + row = conn.execute( + "SELECT dateiname, mime_type, inhalt FROM projekt_datei " + "WHERE id = ? AND projekt_id = ?", + (datei_id, projekt_id) + ).fetchone() + conn.close() + if not row or not row["inhalt"]: + abort(404) + resp = Response(row["inhalt"], mimetype=row["mime_type"] or "application/octet-stream") + # Dateiname url-sicher kodieren — sonst Stolperfalle bei Umlauten + from urllib.parse import quote + resp.headers["Content-Disposition"] = f'attachment; filename="{quote(row["dateiname"])}"' + return resp + + +@bp.route("/projekt//cover") +def projekt_cover(projekt_id): + """Cover-Foto eines Projekts (BLOB) ausliefern. + + Wird vom Cockpit + Katalog + Lehrer-Review genutzt. Wenn kein BLOB + hinterlegt ist, liefert 1x1-PNG, damit img-Tags nicht broken sind. + """ + conn = get_db() + row = conn.execute( + "SELECT cover_blob, cover_mime FROM projekt WHERE id = ?", (projekt_id,) + ).fetchone() + conn.close() + if not row or not row["cover_blob"]: + return Response(_LEER_PNG, mimetype="image/png") + return Response(row["cover_blob"], mimetype=row["cover_mime"] or "image/jpeg") + + @bp.route("/drucker/status") def drucker_status(): """Etappe 5: JSON aller 4 Drucker mit Status/Restzeit.""" diff --git a/routes/oeffentlich.py b/routes/oeffentlich.py index df14d7a..f2111a8 100644 --- a/routes/oeffentlich.py +++ b/routes/oeffentlich.py @@ -4,7 +4,10 @@ Etappe 1: nur Startseite + Error-Handler portiert. Etappe 3: Lektionen, Software, Projekt-Katalog, Troubleshooting. Etappe 5: Drucker-Live-View. """ -from flask import Blueprint, render_template, abort +from flask import Blueprint, render_template, abort, session + +from services.auth import login_required +from services.drucker_links import drucker_links bp = Blueprint("oeffentlich", __name__) @@ -47,22 +50,53 @@ def lektion_detail(nummer): @bp.route("/software") def software(): - abort(501) + """Software-Sektion: Karten-Uebersicht, gruppiert nach Kategorie. + Etappe 3 — Karten-Konzept (Inhalt: 1-2 Saetze + Links). Die SuS lernen + den Umgang mit den Tools in den Lektionen (siehe docs/lektionen-plan.md), + hier nur der Katalog. + """ + from database import get_db + conn = get_db() + karten = conn.execute( + "SELECT id, slug, titel, kurzbeschreibung, url, tutorial_url, " + " plattform, app_store_url, play_store_url, bild_pfad, kategorie, " + " (bild_blob IS NOT NULL) AS hat_bild_blob " + "FROM software_seite WHERE aktiv = 1 " + "ORDER BY kategorie, sortierung, titel" + ).fetchall() + conn.close() -@bp.route("/software/") -def software_detail(slug): - abort(501) + # Nach Kategorie gruppieren, damit das Template einfach iterieren kann + gruppen = {"software": [], "app": [], "generator": []} + for k in karten: + gruppen.setdefault(k["kategorie"], []).append(k) + + return render_template("software.html", gruppen=gruppen) @bp.route("/projekte") +@login_required def projekte(): - abort(501) - - -@bp.route("/projekte/") -def projekt_detail(projekt_id): - abort(501) + """Oeffentlicher Katalog (fuer eingeloggte SuS).""" + from database import get_db + from services.projekte import katalog_projekte, projekt_dateien_fuer_katalog, darf_neues_anlegen + eigene_id = session["profil_id"] + conn = get_db() + projekte_roh = katalog_projekte(conn) + darf_wiederholen, sperr_grund = darf_neues_anlegen(conn, eigene_id) + projekte_view = [] + for p in projekte_roh: + dateien = projekt_dateien_fuer_katalog(conn, p["id"]) + projekte_view.append({**dict(p), "dateien": [dict(d) for d in dateien]}) + conn.close() + return render_template( + "projekte_katalog.html", + projekte=projekte_view, + eigene_profil_id=eigene_id, + darf_wiederholen=darf_wiederholen, + wiederhol_sperr_grund=sperr_grund, + ) @bp.route("/troubleshooting") @@ -71,5 +105,47 @@ def troubleshooting(): @bp.route("/drucker") +@login_required def drucker(): - abort(501) + """Drucker-Uebersicht fuer eingeloggte SuS. + + Zeigt alle aktiven Drucker mit Status, Manual-/Troubleshooting-Links + und einem Banner bei Sperrung. Belegung-Anzeige (heute) folgt mit + Iteration B (Slot-Reservierung). + """ + from database import get_db + from services.slots import aktuelle_belegung, naechste_slots, profil_darf_reservieren + + conn = get_db() + drucker_liste = conn.execute( + "SELECT d.id, d.name, d.modell, d.geschlossenes_gehaeuse, d.notiz, " + " d.freigegeben, d.gesperrt_grund, d.manual_url, d.troubleshooting_url, " + " s.status, s.job_name, s.progress_pct, s.restzeit_min, " + " s.fehlermeldung, s.gepollt_am, s.manuell_gesetzt " + "FROM drucker d " + "LEFT JOIN drucker_status s ON s.drucker_id = d.id " + "WHERE d.aktiv = 1 " + "ORDER BY d.modell, d.name" + ).fetchall() + darf_reservieren = profil_darf_reservieren(conn, session.get("profil_id")) + + # Pro Drucker: Links + aktuelle Belegung + naechste 3 Slots + drucker_view = [] + for d in drucker_liste: + links = drucker_links(d) + belegung = aktuelle_belegung(conn, d["id"]) + kommend = naechste_slots(conn, d["id"], n=3) + drucker_view.append({ + **dict(d), + "manual": links["manual"], + "troubleshooting": links["troubleshooting"], + "belegung": dict(belegung) if belegung else None, + "kommend": [dict(s) for s in kommend], + }) + conn.close() + + return render_template( + "drucker.html", + drucker_liste=drucker_view, + darf_reservieren=darf_reservieren, + ) diff --git a/routes/profil.py b/routes/profil.py index 0443186..6254a61 100644 --- a/routes/profil.py +++ b/routes/profil.py @@ -5,7 +5,8 @@ Etappe 2: Cockpit progressiv (V1), Check-In (V3 + E-021), Lektion-Status, Paarun Etappe 6: Projekt-Workflow. Etappe 7: Druckauftraege + Slots. """ -from datetime import date +import base64 +from datetime import date, datetime, timedelta from flask import ( Blueprint, render_template, request, redirect, url_for, @@ -137,6 +138,43 @@ def check_in(): (session["profil_id"], date.today().isoformat(), modus, freitext) ) conn.commit() + + # Quick-Action: je nach Modus direkt zum passenden Ort weiterleiten. + # Falls die Aktion blockiert ist (z.B. neues_projekt waehrend Lehrer-Review-Wartung), + # leitet die jeweilige Route auf /cockpit mit Flash-Meldung weiter. + profil_id = session["profil_id"] + if modus == "neues_projekt": + conn.close() + return redirect(url_for("profil.projekt_neu")) + if modus == "drucken": + conn.close() + return redirect(url_for("oeffentlich.drucker")) + if modus == "onboarding": + conn.close() + return redirect(url_for("oeffentlich.lektionen")) + if modus == "weiter_wie_bisher": + from services.projekte import aktives_projekt + aktiv = aktives_projekt(conn, profil_id) + conn.close() + if aktiv: + return redirect(url_for("profil.projekt_detail", projekt_id=aktiv["id"])) + # Kein aktives Projekt mehr — nutzer wollte weitermachen, gibt aber nichts zum Weitermachen + flash("Du hast gerade kein laufendes Projekt — leg ein neues an?", "success") + return redirect(url_for("profil.cockpit")) + conn.close() + return redirect(url_for("profil.cockpit")) + + +@bp.route("/cockpit/check-in/zuruecksetzen", methods=["POST"]) +@login_required +def check_in_zuruecksetzen(): + """Heutigen Check-In zuruecksetzen — Modal erscheint beim naechsten Cockpit wieder.""" + conn = get_db() + conn.execute( + "DELETE FROM check_in WHERE profil_id = ? AND besuch_datum = ?", + (session["profil_id"], date.today().isoformat()) + ) + conn.commit() conn.close() return redirect(url_for("profil.cockpit")) @@ -333,15 +371,624 @@ def paarung_aufloesen(): # =========================================================================== -# Stubs fuer spaetere Etappen +# Slot-Reservierung (Iteration B1) # =========================================================================== @bp.route("/cockpit/slots") @login_required def slots(): - abort(501) # Etappe 6/7 + """Liste der eigenen Slots — bevorstehende und gerade aktive.""" + from services.slots import slots_eines_profils, profil_darf_reservieren + conn = get_db() + eigene = slots_eines_profils(conn, session["profil_id"], kommend_nur=True) + darf = profil_darf_reservieren(conn, session["profil_id"]) + conn.close() + return render_template("profil_slots.html", slots=eigene, darf_reservieren=darf) +@bp.route("/cockpit/slot/neu", methods=["GET", "POST"]) +@login_required +def slot_neu(): + """SuS legt einen neuen Reservierungs-Slot an.""" + from services.slots import ( + parse_datetime_local, validiere_zeitraum, konflikt_check, profil_darf_reservieren + ) + profil_id = session["profil_id"] + + conn = get_db() + darf = profil_darf_reservieren(conn, profil_id) + conn.close() + if not darf: + flash( + "Reservierung ist fuer dich noch nicht freigeschaltet — bitte den Lehrer kurz darum.", + "error" + ) + return redirect(url_for("oeffentlich.drucker")) + + conn = get_db() + drucker_liste = conn.execute( + "SELECT id, name, modell, freigegeben, gesperrt_grund " + "FROM drucker WHERE aktiv = 1 AND freigegeben = 1 ORDER BY modell, name" + ).fetchall() + conn.close() + + # Default: heute, in 5 Minuten, 60 min Dauer + jetzt = datetime.now() + default_start = (jetzt + timedelta(minutes=5)).replace(second=0, microsecond=0) + default_ende = default_start + timedelta(hours=1) + + if request.method == "POST": + try: + drucker_id = int(request.form.get("drucker_id") or 0) + except ValueError: + drucker_id = 0 + start = parse_datetime_local(request.form.get("start_zeit") or "") + ende = parse_datetime_local(request.form.get("ende_zeit") or "") + notiz = (request.form.get("notiz") or "").strip() or None + + # Drucker validieren + conn = get_db() + drucker = conn.execute( + "SELECT id, name, freigegeben FROM drucker WHERE id = ? AND aktiv = 1", + (drucker_id,) + ).fetchone() + if not drucker: + conn.close() + flash("Drucker nicht gefunden.", "error") + return render_template( + "profil_slot_neu.html", + drucker_liste=drucker_liste, + default_start=default_start, default_ende=default_ende, + vorgewaehlt_drucker=drucker_id, + eingabe_start=request.form.get("start_zeit"), + eingabe_ende=request.form.get("ende_zeit"), + eingabe_notiz=request.form.get("notiz"), + ) + if not drucker["freigegeben"]: + conn.close() + flash(f"'{drucker['name']}' ist derzeit gesperrt. Bitte anderen Drucker waehlen.", "error") + return redirect(url_for("profil.slot_neu")) + + # Zeitraum validieren + fehler = validiere_zeitraum(start, ende, jetzt) + if fehler: + conn.close() + flash(fehler, "error") + return render_template( + "profil_slot_neu.html", + drucker_liste=drucker_liste, + default_start=default_start, default_ende=default_ende, + vorgewaehlt_drucker=drucker_id, + eingabe_start=request.form.get("start_zeit"), + eingabe_ende=request.form.get("ende_zeit"), + eingabe_notiz=request.form.get("notiz"), + ) + + # Konflikt-Check + konflikte = konflikt_check(conn, drucker_id, start, ende) + if konflikte: + k = konflikte[0] + conn.close() + flash( + f"Zeitraum-Konflikt: ein anderer Slot blockiert den Drucker von " + f"{k['start_zeit'][11:16]} bis {k['ende_zeit'][11:16]}.", + "error" + ) + return render_template( + "profil_slot_neu.html", + drucker_liste=drucker_liste, + default_start=default_start, default_ende=default_ende, + vorgewaehlt_drucker=drucker_id, + eingabe_start=request.form.get("start_zeit"), + eingabe_ende=request.form.get("ende_zeit"), + eingabe_notiz=request.form.get("notiz"), + ) + + # Speichern + conn.execute( + "INSERT INTO slot (drucker_id, profil_id, start_zeit, ende_zeit, status, notiz) " + "VALUES (?, ?, ?, ?, 'reserviert', ?)", + (drucker_id, profil_id, start.isoformat(), ende.isoformat(), notiz) + ) + conn.commit() + conn.close() + flash(f"Slot reserviert: {drucker['name']} von {start:%d.%m. %H:%M} bis {ende:%H:%M}.", "success") + return redirect(url_for("profil.slots")) + + # GET — Form anzeigen. Drucker-Vorauswahl via Query-Param (von /drucker) + try: + vorgewaehlt_drucker = int(request.args.get("drucker_id", 0)) + except ValueError: + vorgewaehlt_drucker = 0 + return render_template( + "profil_slot_neu.html", + drucker_liste=drucker_liste, + default_start=default_start, default_ende=default_ende, + vorgewaehlt_drucker=vorgewaehlt_drucker, + eingabe_start=None, eingabe_ende=None, eingabe_notiz=None, + ) + + +@bp.route("/cockpit/slot//stornieren", methods=["POST"]) +@login_required +def slot_stornieren(slot_id): + """Eigenen Slot stornieren — nur eigene, nur wenn nicht schon beendet/storniert.""" + conn = get_db() + slot = conn.execute( + "SELECT id, profil_id, status FROM slot WHERE id = ?", (slot_id,) + ).fetchone() + if not slot or slot["profil_id"] != session["profil_id"]: + conn.close() + flash("Slot nicht gefunden.", "error") + return redirect(url_for("profil.slots")) + if slot["status"] in ("storniert", "beendet"): + conn.close() + flash("Slot ist bereits abgeschlossen.", "error") + return redirect(url_for("profil.slots")) + conn.execute("UPDATE slot SET status = 'storniert' WHERE id = ?", (slot_id,)) + conn.commit() + conn.close() + flash("Slot storniert.", "success") + return redirect(url_for("profil.slots")) + + +# =========================================================================== +# Projekte / Selbstlern-Loop (Etappe 6 — Kern-Mechanik) +# =========================================================================== +# +# Regeln: siehe services/projekte.py — kurz: max. 1 aktives Projekt, neues +# nur nach Freigabe (auto bei Erfolg, durch Lehrer bei Misserfolg). + +@bp.route("/cockpit/projekte") +@login_required +def projekte_liste(): + """Eigene Projekt-Liste (aktiv + abgeschlossen).""" + from services.projekte import liste_eigener, darf_neues_anlegen + conn = get_db() + eigene = liste_eigener(conn, session["profil_id"]) + darf, grund = darf_neues_anlegen(conn, session["profil_id"]) + conn.close() + return render_template( + "projekt_liste.html", projekte=eigene, darf_neues=darf, sperr_grund=grund + ) + + +@bp.route("/cockpit/projekt/neu", methods=["GET", "POST"]) +@login_required +def projekt_neu(): + """Neues Projekt anlegen — mit Block-Check (siehe darf_neues_anlegen).""" + from services.projekte import darf_neues_anlegen + conn = get_db() + darf, grund = darf_neues_anlegen(conn, session["profil_id"]) + if not darf: + conn.close() + flash(grund, "error") + return redirect(url_for("profil.cockpit")) + + if request.method == "POST": + titel = (request.form.get("titel") or "").strip() + beschreibung = (request.form.get("beschreibung_md") or "").strip() or None + if not titel: + conn.close() + flash("Bitte einen Titel angeben.", "error") + return render_template("projekt_neu.html", eingabe_titel="", eingabe_beschreibung=beschreibung) + + cur = conn.execute( + "INSERT INTO projekt (profil_id, titel, beschreibung_md, status_lebenszyklus, freigabe_status) " + "VALUES (?, ?, ?, 'in_arbeit', 'in_arbeit')", + (session["profil_id"], titel, beschreibung) + ) + conn.commit() + neue_id = cur.lastrowid + conn.close() + flash(f"Projekt '{titel}' angelegt — viel Erfolg!", "success") + return redirect(url_for("profil.projekt_detail", projekt_id=neue_id)) + + conn.close() + return render_template("projekt_neu.html", eingabe_titel=None, eingabe_beschreibung=None) + + +def _eigenes_projekt_holen(projekt_id, conn=None): + """Liefert das Projekt, wenn es dem eingeloggten SuS gehoert. Sonst None. + + Conn-Recycling optional, damit man in einer einzelnen DB-Connection bleiben kann. + """ + eigene_conn = conn is None + conn = conn or get_db() + try: + from services.projekte import projekt_holen + p = projekt_holen(conn, projekt_id) + if not p or p["profil_id"] != session.get("profil_id"): + return None + return p + finally: + if eigene_conn: + conn.close() + + +@bp.route("/cockpit/projekt/") +@login_required +def projekt_detail(projekt_id): + from services.projekte import ( + dateien_eines_projekts, abschluss_voraussetzungen, + vorlage_info, wiederholungen_von, + ) + p = _eigenes_projekt_holen(projekt_id) + if not p: + flash("Projekt nicht gefunden.", "error") + return redirect(url_for("profil.cockpit")) + conn = get_db() + software_liste = conn.execute( + "SELECT slug, titel, kategorie FROM software_seite " + "WHERE aktiv = 1 ORDER BY kategorie, sortierung, titel" + ).fetchall() + drucker_liste = conn.execute( + "SELECT id, name, modell FROM drucker WHERE aktiv = 1 ORDER BY modell, name" + ).fetchall() + dateien = dateien_eines_projekts(conn, projekt_id) + darf_abschliessen, fehlende = abschluss_voraussetzungen(conn, projekt_id) + vorlage = vorlage_info(conn, p["vorlage_projekt_id"]) if p["vorlage_projekt_id"] else None + wiederholungen = wiederholungen_von(conn, projekt_id) + conn.close() + return render_template( + "projekt_detail.html", + p=p, + software_liste=software_liste, + drucker_liste=drucker_liste, + dateien=dateien, + darf_abschliessen=darf_abschliessen, + abschluss_fehlende=fehlende, + vorlage=vorlage, + wiederholungen=wiederholungen, + ) + + +@bp.route("/cockpit/projekt//werkstatt", methods=["POST"]) +@login_required +def projekt_werkstatt_speichern(projekt_id): + """Speichert Beschreibung + Software + Drucker + MakerWorld-URL in einem Schritt.""" + p = _eigenes_projekt_holen(projekt_id) + if not p: + flash("Projekt nicht gefunden.", "error") + return redirect(url_for("profil.cockpit")) + if p["status_lebenszyklus"] != "in_arbeit": + flash("Abgeschlossene Projekte kannst du nicht mehr bearbeiten.", "error") + return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id)) + + titel = (request.form.get("titel") or "").strip() + beschreibung = (request.form.get("beschreibung_md") or "").strip() or None + software = (request.form.get("modellier_software_slug") or "").strip() or None + try: + drucker = int(request.form.get("drucker_id_used") or 0) or None + except ValueError: + drucker = None + makerworld = (request.form.get("makerworld_url") or "").strip() or None + + if not titel: + flash("Titel darf nicht leer sein.", "error") + return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id)) + + conn = get_db() + conn.execute( + "UPDATE projekt SET titel = ?, beschreibung_md = ?, " + " modellier_software_slug = ?, drucker_id_used = ?, makerworld_url = ?, " + " geaendert_am = CURRENT_TIMESTAMP WHERE id = ?", + (titel, beschreibung, software, drucker, makerworld, projekt_id) + ) + conn.commit() + conn.close() + flash("Aenderungen gespeichert.", "success") + return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id)) + + +# Max-Upload-Groesse (30 MB) — Bambu-3MF-Files sind selten groesser +MAX_UPLOAD_BYTES = 30 * 1024 * 1024 + +# Mapping Mime -> art (siehe Schema: art TEXT 'cover_foto'|'stl'|'3mf'|'foto'|'sonstiges') +def _datei_art_aus_dateiname(dateiname: str, mime: str) -> str: + name_lower = (dateiname or "").lower() + if name_lower.endswith(".stl"): + return "stl" + if name_lower.endswith(".3mf"): + return "3mf" + if mime and mime.startswith("image/"): + return "foto" + return "sonstiges" + + +@bp.route("/cockpit/projekt//datei", methods=["POST"]) +@login_required +def projekt_datei_upload(projekt_id): + """Datei hochladen (JSON base64). Erwartet {dateiname, mime, base64}. + + Eine Datei pro Request. Frontend ruft mehrfach auf bei mehreren Dateien. + """ + p = _eigenes_projekt_holen(projekt_id) + if not p: + return jsonify(error="Projekt nicht gefunden"), 404 + if p["status_lebenszyklus"] != "in_arbeit": + return jsonify(error="Abgeschlossene Projekte koennen keine Dateien mehr aufnehmen"), 400 + + data = request.get_json(silent=True) or {} + dateiname = (data.get("dateiname") or "").strip() + mime = (data.get("mime") or "application/octet-stream").strip() + b64 = data.get("base64") or "" + if not dateiname or not b64: + return jsonify(error="dateiname oder base64 fehlt"), 400 + + # base64 kann mit "data:...;base64," anfangen — abtrennen + if "," in b64: + b64 = b64.split(",", 1)[1] + try: + blob = base64.b64decode(b64) + except Exception: + return jsonify(error="base64 konnte nicht dekodiert werden"), 400 + + if len(blob) > MAX_UPLOAD_BYTES: + return jsonify(error=f"Datei zu gross ({len(blob)//1024} KB, max {MAX_UPLOAD_BYTES//(1024*1024)} MB)"), 413 + + art = _datei_art_aus_dateiname(dateiname, mime) + + conn = get_db() + cur = conn.execute( + "INSERT INTO projekt_datei (projekt_id, dateiname, mime_type, groesse_bytes, inhalt, art, hochgeladen_von) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (projekt_id, dateiname, mime, len(blob), blob, art, session["profil_id"]) + ) + conn.commit() + neue_id = cur.lastrowid + conn.close() + return jsonify(ok=True, id=neue_id, art=art, groesse=len(blob)) + + +@bp.route("/cockpit/projekt//datei//loeschen", methods=["POST"]) +@login_required +def projekt_datei_loeschen(projekt_id, datei_id): + p = _eigenes_projekt_holen(projekt_id) + if not p: + flash("Projekt nicht gefunden.", "error") + return redirect(url_for("profil.cockpit")) + if p["status_lebenszyklus"] != "in_arbeit": + flash("Dateien abgeschlossener Projekte koennen nicht geloescht werden.", "error") + return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id)) + conn = get_db() + conn.execute( + "DELETE FROM projekt_datei WHERE id = ? AND projekt_id = ?", + (datei_id, projekt_id) + ) + conn.commit() + conn.close() + flash("Datei geloescht.", "success") + return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id)) + + +@bp.route("/cockpit/projekt//bearbeiten", methods=["GET", "POST"]) +@login_required +def projekt_bearbeiten(projekt_id): + """Titel + Beschreibung waehrend status='in_arbeit' anpassen.""" + p = _eigenes_projekt_holen(projekt_id) + if not p: + flash("Projekt nicht gefunden.", "error") + return redirect(url_for("profil.cockpit")) + if p["status_lebenszyklus"] != "in_arbeit": + flash("Abgeschlossene Projekte kannst du nicht mehr bearbeiten.", "error") + return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id)) + + if request.method == "POST": + titel = (request.form.get("titel") or "").strip() + beschreibung = (request.form.get("beschreibung_md") or "").strip() or None + if not titel: + flash("Titel darf nicht leer sein.", "error") + return redirect(url_for("profil.projekt_bearbeiten", projekt_id=projekt_id)) + conn = get_db() + conn.execute( + "UPDATE projekt SET titel = ?, beschreibung_md = ?, geaendert_am = CURRENT_TIMESTAMP " + "WHERE id = ?", + (titel, beschreibung, projekt_id) + ) + conn.commit() + conn.close() + flash("Aenderungen gespeichert.", "success") + return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id)) + + return render_template("projekt_bearbeiten.html", p=p) + + +@bp.route("/cockpit/projekt//abschluss", methods=["GET", "POST"]) +@login_required +def projekt_abschluss(projekt_id): + """SuS schliesst das Projekt ab — mit differenzierter Selbstbewertung. + + - Gesamt: zufrieden/mittel/gelernt (E-015) + - Planung: gut/ok/schwierig + - Ausfuehrung: gut/ok/schwierig + - Was gelernt (Freitext) + - Was klappte nicht (Freitext, hilft Lehrer bei Misserfolg) + + Bei 'gelernt' (Misserfolg) → wartet_auf_freigabe (Lehrer muss drueber). + Sonst → freigegeben (SuS kann gleich neu anfangen). + """ + from services.projekte import ( + EVAL_GESAMT, EVAL_QUALITAET, freigabe_nach_eval + ) + p = _eigenes_projekt_holen(projekt_id) + if not p: + flash("Projekt nicht gefunden.", "error") + return redirect(url_for("profil.cockpit")) + if p["status_lebenszyklus"] != "in_arbeit": + flash("Dieses Projekt ist bereits abgeschlossen.", "error") + return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id)) + + # Abschluss-Voraussetzungen (Cover + Files) — pruefen bei GET und POST + from services.projekte import abschluss_voraussetzungen + conn = get_db() + darf, fehlende = abschluss_voraussetzungen(conn, projekt_id) + conn.close() + if not darf: + for f in fehlende: + flash(f, "error") + return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id)) + + if request.method == "POST": + gesamt = (request.form.get("selbsteinschaetzung") or "").strip() + planung = (request.form.get("selbsteinschaetzung_planung") or "").strip() + ausfuehrung = (request.form.get("selbsteinschaetzung_ausfuehrung") or "").strip() + gelernt = (request.form.get("was_gelernt") or "").strip() or None + klappte_nicht = (request.form.get("was_klappte_nicht") or "").strip() or None + + if gesamt not in EVAL_GESAMT: + flash("Bitte Gesamt-Einschaetzung waehlen.", "error") + return redirect(url_for("profil.projekt_abschluss", projekt_id=projekt_id)) + # Planung/Ausfuehrung optional, aber falls gesetzt: validieren + if planung and planung not in EVAL_QUALITAET: + planung = None + if ausfuehrung and ausfuehrung not in EVAL_QUALITAET: + ausfuehrung = None + + neuer_freigabe_status = freigabe_nach_eval(gesamt) + + conn = get_db() + conn.execute( + """UPDATE projekt SET + status_lebenszyklus = 'abgeschlossen', + abgeschlossen_am = CURRENT_TIMESTAMP, + selbsteinschaetzung = ?, + selbsteinschaetzung_planung = ?, + selbsteinschaetzung_ausfuehrung = ?, + was_gelernt = ?, + was_klappte_nicht = ?, + freigabe_status = ?, + geaendert_am = CURRENT_TIMESTAMP + WHERE id = ?""", + (gesamt, planung or None, ausfuehrung or None, + gelernt, klappte_nicht, neuer_freigabe_status, projekt_id) + ) + conn.commit() + conn.close() + + if neuer_freigabe_status == "wartet_auf_freigabe": + flash( + "Projekt abgeschlossen — und ehrlich bewertet, super! " + "Der Lehrer schaut drueber, dann kannst du was Neues anfangen.", + "success" + ) + else: + flash("Projekt abgeschlossen. Du kannst jetzt was Neues anfangen.", "success") + return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id)) + + return render_template( + "projekt_abschluss.html", p=p, + EVAL_GESAMT=EVAL_GESAMT, EVAL_QUALITAET=EVAL_QUALITAET + ) + + +@bp.route("/cockpit/projekt//cover", methods=["GET", "POST"]) +@login_required +def projekt_cover(projekt_id): + """Cover-Foto via Crop-Tool (analog admin_software_bild).""" + p = _eigenes_projekt_holen(projekt_id) + if not p: + if request.method == "POST": + return jsonify(error="Projekt nicht gefunden"), 404 + flash("Projekt nicht gefunden.", "error") + return redirect(url_for("profil.cockpit")) + + if request.method == "POST": + data = request.get_json(silent=True) or {} + bild_str = data.get("bild_base64") + if not bild_str: + return jsonify(error="Kein Bild empfangen"), 400 + mime = "image/jpeg" + if "," in bild_str: + header, bild_str = bild_str.split(",", 1) + if ":" in header and ";" in header: + mime = header.split(":")[1].split(";")[0] + try: + blob = base64.b64decode(bild_str) + except Exception: + return jsonify(error="Bild konnte nicht dekodiert werden"), 400 + conn = get_db() + conn.execute( + "UPDATE projekt SET cover_blob = ?, cover_mime = ? WHERE id = ?", + (blob, mime, projekt_id) + ) + conn.commit() + conn.close() + return jsonify(ok=True) + + return render_template("projekt_cover.html", p=p) + + +@bp.route("/cockpit/projekt//cover/loeschen", methods=["POST"]) +@login_required +def projekt_cover_loeschen(projekt_id): + p = _eigenes_projekt_holen(projekt_id) + if not p: + flash("Projekt nicht gefunden.", "error") + return redirect(url_for("profil.cockpit")) + conn = get_db() + conn.execute( + "UPDATE projekt SET cover_blob = NULL, cover_mime = NULL WHERE id = ?", + (projekt_id,) + ) + conn.commit() + conn.close() + flash("Cover-Bild geloescht.", "success") + return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id)) + + +@bp.route("/cockpit/projekt/aus-vorlage/", methods=["POST"]) +@login_required +def projekt_aus_vorlage(vorlage_id): + """Neues Projekt mit Verknuepfung zu einer Vorlage anlegen.""" + from services.projekte import aus_vorlage_anlegen + conn = get_db() + neue_id, fehler = aus_vorlage_anlegen(conn, session["profil_id"], vorlage_id) + conn.close() + if fehler: + flash(fehler, "error") + return redirect(url_for("oeffentlich.projekte")) + flash( + "Projekt angelegt — pass jetzt Titel und Beschreibung an, und mach deinen eigenen Twist.", + "success" + ) + return redirect(url_for("profil.projekt_detail", projekt_id=neue_id)) + + +@bp.route("/cockpit/projekt//rueckfrage//erledigt", methods=["POST"]) +@login_required +def rueckfrage_erledigt(projekt_id, rueckfrage_id): + """SuS markiert eine Rueckfrage als beantwortet → Projekt geht zurueck zu wartet_auf_freigabe.""" + p = _eigenes_projekt_holen(projekt_id) + if not p: + flash("Projekt nicht gefunden.", "error") + return redirect(url_for("profil.cockpit")) + conn = get_db() + conn.execute( + "UPDATE projekt_rueckfrage SET status = 'erledigt', erledigt_am = CURRENT_TIMESTAMP " + "WHERE id = ? AND projekt_id = ?", + (rueckfrage_id, projekt_id) + ) + # Wenn keine offenen Rueckfragen mehr da sind → zurueck zu wartet_auf_freigabe + offen = conn.execute( + "SELECT COUNT(*) AS n FROM projekt_rueckfrage WHERE projekt_id = ? AND status = 'offen'", + (projekt_id,) + ).fetchone()["n"] + if offen == 0: + conn.execute( + "UPDATE projekt SET freigabe_status = 'wartet_auf_freigabe' WHERE id = ?", + (projekt_id,) + ) + conn.commit() + conn.close() + flash("Rueckfrage als erledigt markiert — der Lehrer schaut nochmal drueber.", "success") + return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id)) + + +# =========================================================================== +# Stubs fuer spaetere Etappen +# =========================================================================== + @bp.route("/cockpit/druckauftraege") @login_required def druckauftraege(): diff --git a/services/cockpit_status.py b/services/cockpit_status.py index 58214c8..a5f2ff0 100644 --- a/services/cockpit_status.py +++ b/services/cockpit_status.py @@ -100,10 +100,15 @@ def get_ungelesene_notizen(profil_id: int) -> list[dict]: def get_aktive_projekte(profil_id: int) -> list[dict]: - """Eigene Projekte mit status_lebenszyklus='in_arbeit' (Etappe 6 fuellt das voll).""" + """Eigene Projekte mit status_lebenszyklus='in_arbeit'. + + Nach Etappe 6: max. 1 (Regel "ein aktives Projekt pro SuS"). + Liefert trotzdem Liste, damit das Template unveraendert bleibt. + """ conn = get_db() rows = conn.execute( - "SELECT id, titel, beschreibung_md, geaendert_am " + "SELECT id, titel, beschreibung_md, geaendert_am, " + " (cover_blob IS NOT NULL) AS hat_cover " "FROM projekt " "WHERE profil_id = ? AND status_lebenszyklus = 'in_arbeit' " "ORDER BY geaendert_am DESC", @@ -113,6 +118,26 @@ def get_aktive_projekte(profil_id: int) -> list[dict]: return [dict(r) for r in rows] +def get_projekt_kontext(profil_id: int) -> dict: + """Liefert den Projekt-Kontext fuers Cockpit: + - aktives (in_arbeit) + - darf_neues + sperr_grund + - letztes_abgeschlossenes (wenn Wartet auf Freigabe oder Rueckfrage: anzeigen) + """ + from services.projekte import darf_neues_anlegen, aktives_projekt, letztes_projekt + conn = get_db() + aktiv = aktives_projekt(conn, profil_id) + darf, grund = darf_neues_anlegen(conn, profil_id) + letztes = letztes_projekt(conn, profil_id) if not aktiv else None + conn.close() + return { + "aktiv": dict(aktiv) if aktiv else None, + "darf_neues": darf, + "sperr_grund": grund, + "letztes": dict(letztes) if letztes else None, + } + + def get_paarung(profil_id: int) -> Optional[dict]: """Aktive Paarung des SuS (oder None).""" conn = get_db() @@ -185,6 +210,7 @@ def get_cockpit_status(profil_id: int) -> dict: """ onb = get_onboarding_fortschritt(profil_id) aktive_projekte = get_aktive_projekte(profil_id) + projekt_kontext = get_projekt_kontext(profil_id) paarung = get_paarung(profil_id) return { "onboarding": onb, @@ -192,6 +218,7 @@ def get_cockpit_status(profil_id: int) -> dict: "rueckfragen": get_offene_rueckfragen(profil_id), "notizen": get_ungelesene_notizen(profil_id), "aktive_projekte": aktive_projekte, + "projekt": projekt_kontext, # neu: Selbstlern-Loop-Kontext "paarung": paarung, "offene_anfragen": get_offene_anfragen_an_mich(profil_id), # Progressive-Schalter: diff --git a/services/drucker_links.py b/services/drucker_links.py new file mode 100644 index 0000000..644c327 --- /dev/null +++ b/services/drucker_links.py @@ -0,0 +1,53 @@ +"""Default-Manual + Troubleshooting-Links pro Drucker-Modell. + +Bambu-Wiki-URLs als Startpunkt — koennen pro konkretem Drucker in der +DB via drucker.manual_url / drucker.troubleshooting_url ueberschrieben +werden (Inline-Edit). Wenn Override gesetzt, gewinnt der. +""" + +MODELL_LINKS = { + "P1S": { + "manual": "https://wiki.bambulab.com/en/p1", + "troubleshooting": "https://wiki.bambulab.com/en/p1/troubleshooting", + }, + "X1C": { + "manual": "https://wiki.bambulab.com/en/x1", + "troubleshooting": "https://wiki.bambulab.com/en/x1/troubleshooting", + }, + "P2S": { + "manual": "https://wiki.bambulab.com/de/p2s/manual/p2s-intro", + "troubleshooting": "https://wiki.bambulab.com/en/p2/troubleshooting", + }, + "A1 mini": { + "manual": "https://wiki.bambulab.com/en/a1-mini", + "troubleshooting": "https://wiki.bambulab.com/en/a1-mini/troubleshooting", + }, +} + + +def drucker_links(drucker): + """Liefert (manual_url, troubleshooting_url) fuer einen Drucker. + + Order of resolution: DB-Override > Modell-Default > None. + + `drucker` darf ein sqlite3.Row oder dict sein — Hauptsache es hat + die Schluessel modell, manual_url, troubleshooting_url. + """ + modell = drucker["modell"] if drucker else None + default = MODELL_LINKS.get(modell, {}) + return { + "manual": _trim(drucker, "manual_url") or default.get("manual"), + "troubleshooting": _trim(drucker, "troubleshooting_url") or default.get("troubleshooting"), + } + + +def _trim(drucker, schluessel): + """Liest ein optionales Feld aus dem Row/Dict, gibt None wenn leer.""" + try: + wert = drucker[schluessel] + except (KeyError, IndexError): + return None + if not wert: + return None + wert = wert.strip() + return wert or None diff --git a/services/projekte.py b/services/projekte.py new file mode 100644 index 0000000..89ea791 --- /dev/null +++ b/services/projekte.py @@ -0,0 +1,341 @@ +"""Projekt-Helper fuer den Selbstlern-Loop (Etappe 6). + +Kern-Regeln (mit Herb am 2026-05-25 festgehalten): +1. Pro SuS gibt es maximal EIN aktives Projekt (status_lebenszyklus = 'in_arbeit'). +2. Ein neues Projekt kann NUR angelegt werden, wenn: + - kein 'in_arbeit'-Projekt existiert UND + - das letzte abgeschlossene Projekt ist 'freigegeben' (nicht 'wartet_auf_freigabe' + und nicht 'rueckfrage'). +3. Bei Selbstbewertung 'gelernt' (= Misserfolg) → automatisch 'wartet_auf_freigabe' + → Lehrer muss freigeben, bevor neues Projekt erlaubt ist. +4. Bei Selbstbewertung 'zufrieden' / 'mittel' → automatisch 'freigegeben'. + Lehrer kann nachtraeglich 'rueckfrage' setzen, dann blockiert es ebenfalls. +""" +from datetime import datetime + + +# Status-Werte (zentral, damit nicht ueberall String-Literale) +LEBENSZYKLUS_IN_ARBEIT = "in_arbeit" +LEBENSZYKLUS_ABGESCHLOSSEN = "abgeschlossen" + +FREIGABE_IN_ARBEIT = "in_arbeit" +FREIGABE_WARTET = "wartet_auf_freigabe" +FREIGABE_FREIGEGEBEN = "freigegeben" +FREIGABE_RUECKFRAGE = "rueckfrage" + +EVAL_GESAMT = ("zufrieden", "mittel", "gelernt") +EVAL_QUALITAET = ("gut", "ok", "schwierig") + + +def aktives_projekt(conn, profil_id: int): + """Liefert das aktuelle in_arbeit-Projekt eines SuS (oder None).""" + return conn.execute( + "SELECT id, titel, beschreibung_md, status_lebenszyklus, freigabe_status, " + " geaendert_am, (cover_blob IS NOT NULL) AS hat_cover " + "FROM projekt WHERE profil_id = ? AND status_lebenszyklus = ? " + "ORDER BY id DESC LIMIT 1", + (profil_id, LEBENSZYKLUS_IN_ARBEIT) + ).fetchone() + + +def letztes_projekt(conn, profil_id: int): + """Liefert das chronologisch letzte Projekt — egal welcher Status.""" + return conn.execute( + "SELECT id, titel, status_lebenszyklus, freigabe_status, " + " selbsteinschaetzung, abgeschlossen_am " + "FROM projekt WHERE profil_id = ? " + "ORDER BY COALESCE(abgeschlossen_am, geaendert_am) DESC, id DESC LIMIT 1", + (profil_id,) + ).fetchone() + + +def darf_neues_anlegen(conn, profil_id: int): + """Prueft, ob der SuS aktuell ein neues Projekt anlegen darf. + + Returns: + (darf: bool, grund: str | None) + grund ist der Sperr-Text fuer die UI, wenn darf=False. + """ + if aktives_projekt(conn, profil_id): + return (False, "Du hast schon ein laufendes Projekt — erst abschliessen, dann neu anfangen.") + letztes = letztes_projekt(conn, profil_id) + if letztes and letztes["freigabe_status"] == FREIGABE_WARTET: + return ( + False, + f"Dein letztes Projekt ('{letztes['titel']}') wartet auf Freigabe vom Lehrer. " + "Sobald er drueber geschaut hat, kannst du ein neues anfangen." + ) + if letztes and letztes["freigabe_status"] == FREIGABE_RUECKFRAGE: + return ( + False, + f"Der Lehrer hat eine Rueckfrage zu deinem letzten Projekt ('{letztes['titel']}'). " + "Schau ins Cockpit, beantworte sie — dann kannst du neu anfangen." + ) + return (True, None) + + +def liste_eigener(conn, profil_id: int, kommend_nur: bool = False): + """Liefert alle Projekte eines SuS. Default: ALLE inkl. abgeschlossene.""" + sql = ( + "SELECT id, titel, beschreibung_md, status_lebenszyklus, freigabe_status, " + " selbsteinschaetzung, selbsteinschaetzung_planung, selbsteinschaetzung_ausfuehrung, " + " abgeschlossen_am, geaendert_am, (cover_blob IS NOT NULL) AS hat_cover " + "FROM projekt WHERE profil_id = ? " + ) + if kommend_nur: + sql += f"AND status_lebenszyklus = '{LEBENSZYKLUS_IN_ARBEIT}' " + sql += "ORDER BY COALESCE(abgeschlossen_am, geaendert_am) DESC, id DESC" + return conn.execute(sql, (profil_id,)).fetchall() + + +def projekt_holen(conn, projekt_id: int): + """Volle Projekt-Row inkl. hat_cover-Flag + Werkstatt-Felder.""" + return conn.execute( + "SELECT id, profil_id, paarung_id, titel, beschreibung_md, " + " status_lebenszyklus, abgeschlossen_am, freigabe_status, " + " selbsteinschaetzung, selbsteinschaetzung_planung, selbsteinschaetzung_ausfuehrung, " + " was_gelernt, was_klappte_nicht, " + " freigegeben_am, freigegeben_von, sichtbar_in_ag, vorlage_projekt_id, " + " modellier_software_slug, drucker_id_used, makerworld_url, " + " geaendert_am, (cover_blob IS NOT NULL) AS hat_cover " + "FROM projekt WHERE id = ?", + (projekt_id,) + ).fetchone() + + +def dateien_eines_projekts(conn, projekt_id: int): + """Alle hochgeladenen Dateien eines Projekts (ohne BLOB-Inhalt — nur Metadaten).""" + return conn.execute( + "SELECT id, dateiname, mime_type, groesse_bytes, art, hochgeladen_am " + "FROM projekt_datei WHERE projekt_id = ? " + "ORDER BY hochgeladen_am ASC", + (projekt_id,) + ).fetchall() + + +def abschluss_voraussetzungen(conn, projekt_id: int): + """Prueft, ob ein Projekt abgeschlossen werden darf. + + Bedingung: Cover-Bild gesetzt UND mindestens eine Datei hochgeladen. + + Returns: + (darf: bool, fehler: list[str]) — Liste der noch fehlenden Punkte. + """ + row = conn.execute( + "SELECT (cover_blob IS NOT NULL) AS hat_cover, " + " (SELECT COUNT(*) FROM projekt_datei WHERE projekt_id = ?) AS files " + "FROM projekt WHERE id = ?", + (projekt_id, projekt_id) + ).fetchone() + if not row: + return (False, ["Projekt nicht gefunden."]) + fehler = [] + if not row["hat_cover"]: + fehler.append("Cover-Bild fehlt — lad ein Foto vom Druck oder Modell hoch.") + if row["files"] == 0: + fehler.append("Keine Dateien hochgeladen — mindestens eine STL/3MF oder ein Foto wird gebraucht.") + return (len(fehler) == 0, fehler) + + +def freigabe_nach_eval(selbsteinschaetzung_gesamt: str) -> str: + """Bestimmt den Freigabe-Status anhand der Selbstbewertung. + + - 'zufrieden' / 'mittel' → 'freigegeben' (SuS kann gleich weiter) + - 'gelernt' (Misserfolg) → 'wartet_auf_freigabe' (Lehrer muss drueber) + """ + if selbsteinschaetzung_gesamt == "gelernt": + return FREIGABE_WARTET + return FREIGABE_FREIGEGEBEN + + +def warten_auf_freigabe_anzahl(conn) -> int: + """Zahl fuer das Admin-Dashboard-Badge.""" + row = conn.execute( + "SELECT COUNT(*) AS n FROM projekt WHERE freigabe_status = ?", + (FREIGABE_WARTET,) + ).fetchone() + return row["n"] if row else 0 + + +def alle_projekte(conn, filter_status: str | None = None): + """Alle Projekte aller SuS — fuer Admin-Uebersicht. + + filter_status: + None / 'alle' — alles + 'wartet' — nur freigabe_status = wartet_auf_freigabe + 'in_arbeit' — nur status_lebenszyklus = in_arbeit + 'abgeschlossen' — nur status_lebenszyklus = abgeschlossen + 'rueckfrage' — nur freigabe_status = rueckfrage + """ + sql = ( + "SELECT p.id, p.titel, p.status_lebenszyklus, p.freigabe_status, " + " p.selbsteinschaetzung, p.selbsteinschaetzung_planung, p.selbsteinschaetzung_ausfuehrung, " + " p.abgeschlossen_am, p.geaendert_am, p.profil_id, " + " pr.vorname, pr.tier, (p.cover_blob IS NOT NULL) AS hat_cover " + "FROM projekt p LEFT JOIN profil pr ON pr.id = p.profil_id " + ) + where = [] + params: list = [] + if filter_status == "wartet": + where.append("p.freigabe_status = ?") + params.append(FREIGABE_WARTET) + elif filter_status == "in_arbeit": + where.append("p.status_lebenszyklus = ?") + params.append(LEBENSZYKLUS_IN_ARBEIT) + elif filter_status == "abgeschlossen": + where.append("p.status_lebenszyklus = ?") + params.append(LEBENSZYKLUS_ABGESCHLOSSEN) + elif filter_status == "rueckfrage": + where.append("p.freigabe_status = ?") + params.append(FREIGABE_RUECKFRAGE) + if where: + sql += "WHERE " + " AND ".join(where) + " " + # Sortierung: wartend zuerst, dann in_arbeit, dann abgeschlossen + sql += ( + "ORDER BY " + "CASE p.freigabe_status WHEN 'wartet_auf_freigabe' THEN 0 WHEN 'rueckfrage' THEN 1 ELSE 2 END, " + "CASE p.status_lebenszyklus WHEN 'in_arbeit' THEN 0 ELSE 1 END, " + "COALESCE(p.abgeschlossen_am, p.geaendert_am) DESC" + ) + return conn.execute(sql, params).fetchall() + + +def status_zaehler(conn) -> dict: + """Liefert dict mit Anzahl pro Filter-Status — fuer Filter-Buttons.""" + row = conn.execute( + "SELECT " + " COUNT(*) AS alle, " + " SUM(CASE WHEN freigabe_status = ? THEN 1 ELSE 0 END) AS wartet, " + " SUM(CASE WHEN status_lebenszyklus = ? THEN 1 ELSE 0 END) AS in_arbeit, " + " SUM(CASE WHEN status_lebenszyklus = ? THEN 1 ELSE 0 END) AS abgeschlossen, " + " SUM(CASE WHEN freigabe_status = ? THEN 1 ELSE 0 END) AS rueckfrage " + "FROM projekt", + (FREIGABE_WARTET, LEBENSZYKLUS_IN_ARBEIT, LEBENSZYKLUS_ABGESCHLOSSEN, FREIGABE_RUECKFRAGE) + ).fetchone() + return { + "alle": row["alle"] or 0, + "wartet": row["wartet"] or 0, + "in_arbeit": row["in_arbeit"] or 0, + "abgeschlossen": row["abgeschlossen"] or 0, + "rueckfrage": row["rueckfrage"] or 0, + } + + +def katalog_projekte(conn): + """Oeffentlicher Katalog (fuer SuS): + - alle in_arbeit-Projekte (man sieht, woran andere gerade arbeiten) + - alle abgeschlossenen mit freigabe_status='freigegeben' + + NICHT: wartet_auf_freigabe oder rueckfrage — die sind noch nicht + 'oeffentlich' und sollen nicht im Katalog auftauchen. + """ + return conn.execute( + "SELECT p.id, p.titel, p.beschreibung_md, p.status_lebenszyklus, " + " p.selbsteinschaetzung, p.abgeschlossen_am, p.geaendert_am, " + " p.makerworld_url, " + " p.modellier_software_slug, sw.titel AS software_titel, " + " p.drucker_id_used, d.name AS drucker_name, " + " p.profil_id, pr.vorname, pr.tier, " + " (p.cover_blob IS NOT NULL) AS hat_cover, " + " (SELECT COUNT(*) FROM projekt_datei WHERE projekt_id = p.id) AS datei_anzahl " + "FROM projekt p " + "LEFT JOIN profil pr ON pr.id = p.profil_id " + "LEFT JOIN software_seite sw ON sw.slug = p.modellier_software_slug " + "LEFT JOIN drucker d ON d.id = p.drucker_id_used " + "WHERE p.sichtbar_in_ag = 1 " + "AND ( " + " p.status_lebenszyklus = 'in_arbeit' " + " OR (p.status_lebenszyklus = 'abgeschlossen' AND p.freigabe_status = 'freigegeben') " + ") " + "ORDER BY COALESCE(p.abgeschlossen_am, p.geaendert_am) DESC" + ).fetchall() + + +def aus_vorlage_anlegen(conn, profil_id: int, vorlage_id: int): + """Legt ein neues Projekt mit Verknuepfung zu vorlage_id an. + + Verknuepfung-Modus (kein Klon, kein Copy): nur vorlage_projekt_id wird + gesetzt. Titel kommt mit "Wiederholt: ..." als Vorschlag — SuS aendert + in der Werkstatt. Beschreibung, Software, Drucker, Files bleiben leer. + + Pruefungen: + - SuS darf neues Projekt anlegen (darf_neues_anlegen). + - Vorlage existiert + ist sichtbar (in_arbeit oder freigegeben). + + Returns: + (neue_projekt_id, None) bei Erfolg + (None, fehler_text) bei Fehler + """ + darf, grund = darf_neues_anlegen(conn, profil_id) + if not darf: + return (None, grund) + + vorlage = conn.execute( + "SELECT id, titel, status_lebenszyklus, freigabe_status, sichtbar_in_ag " + "FROM projekt WHERE id = ?", + (vorlage_id,) + ).fetchone() + if not vorlage or not vorlage["sichtbar_in_ag"]: + return (None, "Vorlage nicht gefunden oder nicht sichtbar.") + # Wartet-auf-Freigabe und Rueckfrage-Projekte koennen NICHT als Vorlage dienen + if vorlage["status_lebenszyklus"] == LEBENSZYKLUS_ABGESCHLOSSEN and vorlage["freigabe_status"] != FREIGABE_FREIGEGEBEN: + return (None, "Diese Vorlage ist noch nicht freigegeben.") + + titel_vorschlag = f"Wiederholt: {vorlage['titel']}" + cur = conn.execute( + "INSERT INTO projekt (profil_id, titel, vorlage_projekt_id, status_lebenszyklus, freigabe_status) " + "VALUES (?, ?, ?, 'in_arbeit', 'in_arbeit')", + (profil_id, titel_vorschlag, vorlage_id) + ) + conn.commit() + return (cur.lastrowid, None) + + +def wiederholungen_von(conn, projekt_id: int): + """Liefert alle Projekte, die dieses als vorlage_projekt_id haben. + + Mit Person-Info fuer die Anzeige. + """ + return conn.execute( + "SELECT p.id, p.titel, p.status_lebenszyklus, p.freigabe_status, " + " p.profil_id, pr.vorname, pr.tier " + "FROM projekt p LEFT JOIN profil pr ON pr.id = p.profil_id " + "WHERE p.vorlage_projekt_id = ? " + "ORDER BY p.id", + (projekt_id,) + ).fetchall() + + +def vorlage_info(conn, vorlage_id: int): + """Daten der Vorlage fuer das Banner im Detail.""" + return conn.execute( + "SELECT p.id, p.titel, p.profil_id, pr.vorname, pr.tier " + "FROM projekt p LEFT JOIN profil pr ON pr.id = p.profil_id " + "WHERE p.id = ?", + (vorlage_id,) + ).fetchone() + + +def projekt_dateien_fuer_katalog(conn, projekt_id: int): + """Nur die Metadaten fuer die Datei-Liste im Katalog-Detail (keine BLOBs).""" + return conn.execute( + "SELECT id, dateiname, mime_type, groesse_bytes, art " + "FROM projekt_datei WHERE projekt_id = ? " + "ORDER BY art, dateiname", + (projekt_id,) + ).fetchall() + + +def review_queue(conn): + """Alle Projekte, die auf Lehrer-Freigabe warten.""" + return conn.execute( + "SELECT p.id, p.titel, p.beschreibung_md, p.abgeschlossen_am, " + " p.selbsteinschaetzung, p.selbsteinschaetzung_planung, " + " p.selbsteinschaetzung_ausfuehrung, p.was_gelernt, p.was_klappte_nicht, " + " p.profil_id, pr.vorname, pr.tier, " + " (p.cover_blob IS NOT NULL) AS hat_cover " + "FROM projekt p LEFT JOIN profil pr ON pr.id = p.profil_id " + "WHERE p.freigabe_status = ? " + "ORDER BY p.abgeschlossen_am ASC", + (FREIGABE_WARTET,) + ).fetchall() diff --git a/services/slots.py b/services/slots.py new file mode 100644 index 0000000..81ad652 --- /dev/null +++ b/services/slots.py @@ -0,0 +1,117 @@ +"""Slot-Helper: Konflikt-Pruefung, Belegung, Validierung. + +Reine Daten-Operationen ohne Flask-Abhaengigkeit. Wird von SuS- und +Admin-Routen gleichermassen genutzt. + +Konvention: Zeitfelder in der DB sind ISO-Strings (datetime.isoformat()), +in Python arbeiten wir mit datetime-Objekten. +""" +from datetime import datetime, timedelta + +# Validierungs-Defaults +MIN_DAUER = timedelta(minutes=15) +MAX_DAUER = timedelta(hours=24) +# Slot-Statuswerte, die gegen Konflikte zaehlen (alles ausser storniert/beendet). +BELEGT_STATUS = ("reserviert", "aktiv") + + +def parse_datetime_local(wert: str) -> datetime | None: + """Liest den Wert eines (YYYY-MM-DDTHH:MM).""" + if not wert: + return None + try: + return datetime.fromisoformat(wert) + except ValueError: + return None + + +def validiere_zeitraum(start: datetime, ende: datetime, jetzt: datetime | None = None) -> str | None: + """Prueft den Zeitraum eines neuen Slots. None = OK, sonst Fehlertext (deutsch).""" + if not start or not ende: + return "Start- und Endzeit muessen gesetzt sein." + if ende <= start: + return "Die Endzeit muss nach der Startzeit liegen." + jetzt = jetzt or datetime.now() + # Toleranz: 5 Minuten in der Vergangenheit erlauben, damit "jetzt"-Buchungen klappen + if start < jetzt - timedelta(minutes=5): + return "Der Slot darf nicht in der Vergangenheit beginnen." + dauer = ende - start + if dauer < MIN_DAUER: + return f"Mindestdauer sind {int(MIN_DAUER.total_seconds() // 60)} Minuten." + if dauer > MAX_DAUER: + return f"Maximaldauer sind {int(MAX_DAUER.total_seconds() // 3600)} Stunden." + return None + + +def konflikt_check(conn, drucker_id: int, start: datetime, ende: datetime, exclude_slot_id: int | None = None): + """Liefert eine Liste kollidierender Slots (sqlite3.Row) auf dem Drucker. + + Standard-Intervall-Ueberschneidung: Konflikt wenn start_a < ende_b UND start_b < ende_a. + Stornierte/beendete Slots blockieren nicht. + """ + sql = ( + "SELECT id, profil_id, start_zeit, ende_zeit, status, notiz " + "FROM slot WHERE drucker_id = ? " + f"AND status IN ({','.join('?' for _ in BELEGT_STATUS)}) " + "AND start_zeit < ? AND ende_zeit > ?" + ) + params = [drucker_id, *BELEGT_STATUS, ende.isoformat(), start.isoformat()] + if exclude_slot_id is not None: + sql += " AND id != ?" + params.append(exclude_slot_id) + return conn.execute(sql, params).fetchall() + + +def aktuelle_belegung(conn, drucker_id: int, jetzt: datetime | None = None): + """Liefert den gerade aktiven Slot auf dem Drucker (oder None).""" + jetzt = jetzt or datetime.now() + return conn.execute( + f"SELECT s.id, s.profil_id, s.start_zeit, s.ende_zeit, s.status, s.notiz, " + f" p.vorname, p.tier " + f"FROM slot s LEFT JOIN profil p ON p.id = s.profil_id " + f"WHERE s.drucker_id = ? AND s.status IN ({','.join('?' for _ in BELEGT_STATUS)}) " + f"AND s.start_zeit <= ? AND s.ende_zeit > ? " + f"ORDER BY s.start_zeit DESC LIMIT 1", + [drucker_id, *BELEGT_STATUS, jetzt.isoformat(), jetzt.isoformat()] + ).fetchone() + + +def naechste_slots(conn, drucker_id: int, n: int = 5, ab: datetime | None = None): + """Liefert die naechsten n bevorstehenden Slots (start in der Zukunft).""" + ab = ab or datetime.now() + return conn.execute( + f"SELECT s.id, s.profil_id, s.start_zeit, s.ende_zeit, s.status, s.notiz, " + f" p.vorname, p.tier " + f"FROM slot s LEFT JOIN profil p ON p.id = s.profil_id " + f"WHERE s.drucker_id = ? AND s.status IN ({','.join('?' for _ in BELEGT_STATUS)}) " + f"AND s.start_zeit >= ? " + f"ORDER BY s.start_zeit ASC LIMIT ?", + [drucker_id, *BELEGT_STATUS, ab.isoformat(), n] + ).fetchall() + + +def profil_darf_reservieren(conn, profil_id: int) -> bool: + """Prueft das Lehrer-Freischalt-Flag fuer dieses Profil.""" + if not profil_id: + return False + row = conn.execute( + "SELECT reservieren_freigeschaltet FROM profil WHERE id = ?", + (profil_id,) + ).fetchone() + return bool(row and row["reservieren_freigeschaltet"]) + + +def slots_eines_profils(conn, profil_id: int, kommend_nur: bool = True): + """Slots eines SuS — default nur bevorstehende und aktive, nicht beendete.""" + sql = ( + "SELECT s.id, s.drucker_id, s.start_zeit, s.ende_zeit, s.status, s.notiz, " + " d.name AS drucker_name, d.modell " + "FROM slot s LEFT JOIN drucker d ON d.id = s.drucker_id " + "WHERE s.profil_id = ? " + ) + params: list = [profil_id] + if kommend_nur: + sql += "AND s.status IN ('reserviert', 'aktiv') AND s.ende_zeit >= ? " + params.append(datetime.now().isoformat()) + sql += "ORDER BY s.start_zeit ASC" + return conn.execute(sql, params).fetchall() diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html index 8970200..58c9570 100644 --- a/templates/admin_dashboard.html +++ b/templates/admin_dashboard.html @@ -5,11 +5,70 @@

Dashboard

+{# ====== Schnellnavigation (Verwaltungs-Karten) ====== #} + + {# ====== Schnell-Zahlen ====== #}
diff --git a/templates/admin_drucker.html b/templates/admin_drucker.html new file mode 100644 index 0000000..d6638fe --- /dev/null +++ b/templates/admin_drucker.html @@ -0,0 +1,157 @@ +{% extends "base.html" %} +{% block title %}Drucker — Admin{% endblock %} +{% block content %} + +
+

Drucker-Verwaltung

+ +
+ +
+ Hinweis: Live-Status via Bambu-Cloud-API kommt mit Etappe 5 (siehe + docs/offene-fragen.md — Bambu-Account muss noch geklaert werden). + Bis dahin pflegst du den Status hier manuell. +
+ +
+ {% for d in drucker_liste %} +
+ +
+

{{ d.name }}

+ + {{ d.modell }}{% if not d.geschlossenes_gehaeuse %} · offen{% endif %} + +
+ + {# ============== Status setzen ============== #} +
+ Status +
+ + + + +
+ {% if d.gepollt_am %} +
+ zuletzt: {{ d.gepollt_am[:16].replace('T', ' ') }} + {% if d.manuell_gesetzt %}· manuell{% endif %} +
+ {% endif %} +
+ + {# ============== Freigabe/Sperre ============== #} +
+ Reservierungs-Freigabe + {% if d.freigegeben %} +
✅ freigegeben
+
+ + + +
+ {% else %} +
+ 🔒 gesperrt{% if d.gesperrt_grund %}: {{ d.gesperrt_grund }}{% endif %} +
+
+ + +
+ {% endif %} +
+ + {# ============== Inline-Edit: URLs + Notiz ============== #} +
+ + Links & Notiz (klick zum Editieren) + + +
+ Manual: + + {% if d.manual_url %}{{ d.manual_url }} + {% elif d.manual %}{{ d.manual }} + {% else %}[setzen]{% endif %} + + {% if d.manual %}{% endif %} +
+ +
+ Troubleshooting: + + {% if d.troubleshooting_url %}{{ d.troubleshooting_url }} + {% elif d.troubleshooting %}{{ d.troubleshooting }} + {% else %}[setzen]{% endif %} + + {% if d.troubleshooting %}{% endif %} +
+ +
+ Notiz: + + {{ d.notiz or '[setzen]' }} + +
+ +
+ Tipp: Wenn Manual/Troubleshooting leer, wird der Modell-Default benutzt (kursiv angezeigt). +
+
+ +
+ {% endfor %} +
+ +{% endblock %} + +{% block scripts %} +{# Minimalistischer Inline-Edit-Handler (prompt-basiert, kein contenteditable). + Kompatibel zum bestehenden /admin/inline-edit-Endpunkt mit der Whitelist + in routes/admin.py. #} + +{% endblock %} diff --git a/templates/admin_profile.html b/templates/admin_profile.html index ca9c926..743bf30 100644 --- a/templates/admin_profile.html +++ b/templates/admin_profile.html @@ -54,6 +54,7 @@ Tier AG Status + Reservieren Angelegt Aktionen @@ -66,6 +67,17 @@ {{ tier_name(p.tier) }} {{ p.ag_name or p.ag_id }} {% if p.aktiv %}aktiv{% else %}inaktiv{% endif %} + +
+ {% if p.reservieren_freigeschaltet %} + + {% else %} + + {% endif %} +
+ {{ p.erstellt_am[:10] if p.erstellt_am else '' }}
+

Projekte: Wartend auf Freigabe

+ ← Dashboard +
+ +

+ SuS haben diese Projekte als "Gelernt" (Misserfolg) abgeschlossen — schau drueber, dann freigeben + oder Rueckfrage stellen. Erst danach darf der SuS ein neues Projekt anfangen. +

+ +{% if queue %} + +{% else %} +
+

Keine offenen Reviews. 👍

+
+{% endif %} + +{% endblock %} diff --git a/templates/admin_projekt_review.html b/templates/admin_projekt_review.html new file mode 100644 index 0000000..31e2091 --- /dev/null +++ b/templates/admin_projekt_review.html @@ -0,0 +1,152 @@ +{% extends "base.html" %} +{% block title %}Review: {{ p.titel }}{% endblock %} +{% block content %} + +
+

Review: {{ p.titel }}

+ ← Review-Queue +
+ +
+ + {# ============== Cover ============== #} +
+ {% if p.hat_cover %} + + {% else %} +
+ 🎯 +
+ {% endif %} + {% if profil %} +
+
{{ tier_emoji(profil.tier) }}
+ {{ profil.vorname }} +
+ {% endif %} +
+ abgeschlossen {{ p.abgeschlossen_am[:16].replace('T', ' ') if p.abgeschlossen_am else '—' }} +
+
+ +
+ {# ============== Beschreibung ============== #} +
+

Beschreibung

+ {% if p.beschreibung_md %} +
{{ p.beschreibung_md|markdown }}
+ {% else %} +

Keine Beschreibung.

+ {% endif %} +
+ + {# ============== Selbstbewertung ============== #} +
+

Selbstbewertung

+
+
+
Gesamt
+
+ {% if p.selbsteinschaetzung == 'zufrieden' %}🟢 + {% elif p.selbsteinschaetzung == 'mittel' %}🟡 + {% elif p.selbsteinschaetzung == 'gelernt' %}🔴{% endif %} +
+
{{ p.selbsteinschaetzung or '—' }}
+
+
+
Planung
+
+ {% if p.selbsteinschaetzung_planung == 'gut' %}🟢 + {% elif p.selbsteinschaetzung_planung == 'ok' %}🟡 + {% elif p.selbsteinschaetzung_planung == 'schwierig' %}🔴 + {% else %}—{% endif %} +
+
{{ p.selbsteinschaetzung_planung or '—' }}
+
+
+
Ausfuehrung
+
+ {% if p.selbsteinschaetzung_ausfuehrung == 'gut' %}🟢 + {% elif p.selbsteinschaetzung_ausfuehrung == 'ok' %}🟡 + {% elif p.selbsteinschaetzung_ausfuehrung == 'schwierig' %}🔴 + {% else %}—{% endif %} +
+
{{ p.selbsteinschaetzung_ausfuehrung or '—' }}
+
+
+ {% if p.was_gelernt %} +
+ Was gelernt: +
{{ p.was_gelernt }}
+
+ {% endif %} + {% if p.was_klappte_nicht %} +
+ Was klappte nicht: +
{{ p.was_klappte_nicht }}
+
+ {% endif %} +
+ + {# ============== Rueckfragen-Historie ============== #} + {% if rueckfragen %} +
+

Rueckfragen-Historie

+ {% for r in rueckfragen %} +
+
+ {{ r.erstellt_am[:16].replace('T', ' ') }}{% if r.status == 'erledigt' %} · ✓ erledigt{% endif %} +
+
{{ r.text }}
+
+ {% endfor %} +
+ {% endif %} + + {# ============== Aktionen ============== #} +
+

Deine Entscheidung

+ + + + + +
+ + ❓ Rueckfrage stellen + +
+ +
+ +
+
+
+ +
+ + 🗑️ Hart loeschen + +

+ Nur fuer Spam, Penismodelle, Copyright-Verletzungen. Wird im aenderung_log dokumentiert. +

+
+ +
+
+
+
+
+ +{% endblock %} diff --git a/templates/admin_projekte.html b/templates/admin_projekte.html new file mode 100644 index 0000000..84a8a54 --- /dev/null +++ b/templates/admin_projekte.html @@ -0,0 +1,90 @@ +{% extends "base.html" %} +{% block title %}Projekte — Admin{% endblock %} +{% block content %} + +
+

Projekte

+ ← Dashboard +
+ +{# ============== Filter-Buttons ============== #} +
+ {% set filter_links = [ + ('alle', 'Alle', zaehler.alle), + ('wartet', '⏳ wartet auf Freigabe', zaehler.wartet), + ('rueckfrage', '❓ Rueckfrage', zaehler.rueckfrage), + ('in_arbeit', '🔧 in Arbeit', zaehler.in_arbeit), + ('abgeschlossen', '✅ abgeschlossen', zaehler.abgeschlossen), + ] %} + {% for key, label, anzahl in filter_links %} + + {{ label }} ({{ anzahl }}) + + {% endfor %} +
+ +{% if projekte %} + +{% else %} +
+

+ Keine Projekte + {% if aktueller_filter != 'alle' %} mit Filter "{{ aktueller_filter }}" + — alle anzeigen{% endif %}. +

+
+{% endif %} + +{% endblock %} diff --git a/templates/admin_slot_neu.html b/templates/admin_slot_neu.html new file mode 100644 index 0000000..3cf7530 --- /dev/null +++ b/templates/admin_slot_neu.html @@ -0,0 +1,98 @@ +{% extends "base.html" %} +{% block title %}Slot fuer SuS anlegen — Admin{% endblock %} +{% block content %} + +
+

Slot fuer einen SuS anlegen

+ ← Slot-Uebersicht +
+ +{% if not profile %} +
+ Keine aktiven SuS-Profile. + Erst ein Profil anlegen. +
+{% elif not drucker_liste %} +
+ Keine Drucker eingerichtet. +
+{% else %} + +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ Hinweis: Du kannst auch fuer SuS reservieren, die noch nicht freigeschaltet + sind, und auch fuer gesperrte Drucker (du bekommst dann eine Warnung). +
+ +
+ Abbrechen + +
+
+
+ +{% endif %} + +{% endblock %} diff --git a/templates/admin_slots.html b/templates/admin_slots.html new file mode 100644 index 0000000..f11948a --- /dev/null +++ b/templates/admin_slots.html @@ -0,0 +1,78 @@ +{% extends "base.html" %} +{% block title %}Slots — Admin{% endblock %} +{% block content %} + +
+

Slot-Uebersicht

+ +
+ +

+ Alle Reservierungen, deren Ende noch nicht laenger als 2 Stunden her ist. Sortiert nach Start-Zeit. +

+ +{% if slots %} +
+ + + + + + + + + + + + + + + {% for s in slots %} + + + + + + + + + + + {% endfor %} + +
StartEndeDauerDruckerSuSStatusNotiz
{{ s.start_zeit|iso_format('%d.%m. %H:%M') }}{{ s.ende_zeit|iso_format('%H:%M') }}{{ s.start_zeit|dauer_min(s.ende_zeit) }} min + {{ s.drucker_name or '—' }} + {% if s.drucker_name and not s.freigegeben %} +
🔒 Drucker gesperrt
+ {% endif %} +
+ {% if s.profil_id %} + {{ tier_emoji(s.tier or '') }} {{ s.vorname or '—' }} + {% else %} + unbekannt + {% endif %} + + {% if s.status == 'reserviert' %}reserviert + {% elif s.status == 'aktiv' %}aktiv + {% elif s.status == 'beendet' %}beendet + {% elif s.status == 'storniert' %}storniert + {% else %}{{ s.status }}{% endif %} + {{ s.notiz or '' }} +
+ +
+
+
+{% else %} +
+

Keine bevorstehenden Reservierungen. + Eine fuer einen SuS anlegen. +

+
+{% endif %} + +{% endblock %} diff --git a/templates/admin_software.html b/templates/admin_software.html new file mode 100644 index 0000000..84f3158 --- /dev/null +++ b/templates/admin_software.html @@ -0,0 +1,178 @@ +{% extends "base.html" %} +{% block title %}Software — Admin{% endblock %} +{% block content %} +
+

Software-Karten

+ +
+ + +
+

Alle Karten

+ {% if karten %} + + + + + + + + + + + + + + + + {% for k in karten %} + + + + + + + + + + + + {% endfor %} + +
TitelSlugKategorieSort.PlattformHauptlinkStatusAktionen
+ {%- if k.kategorie == 'app' -%}📱 + {%- elif k.kategorie == 'generator' -%}🧩 + {%- else -%}🖥️ + {%- endif -%} + {{ k.titel }} +
{{ k.kurzbeschreibung }}
+
{{ k.slug }}{{ k.kategorie }}{{ k.sortierung }}{{ k.plattform or '—' }} + {% if k.url %}{% endif %} + {% if k.tutorial_url %} T↗{% endif %} + {% if k.app_store_url %} {% endif %} + {% if k.play_store_url %} {% endif %} + + {% if k.aktiv %}aktiv + {% else %}inaktiv{% endif %} + + bearbeiten +
+ +
+
+ +
+
+ {% else %} +

Noch keine Karten. Leg unten die erste an, oder fuehre die Initial-Migration aus + (migrations/2026-05-25_software_sektion.py).

+ {% endif %} +
+ + +
+

Neue Karte anlegen

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ Nur bei Apps +
+
+ + +
+
+ + +
+
+
+ +
+ + +

+ Erst das Bild ueber FTP / SCP nach static/img/software/ kopieren, dann den Dateinamen hier eintragen. +

+
+ +
+ +
+
+
+ +{% endblock %} diff --git a/templates/admin_software_bild.html b/templates/admin_software_bild.html new file mode 100644 index 0000000..caf1777 --- /dev/null +++ b/templates/admin_software_bild.html @@ -0,0 +1,354 @@ +{% extends "base.html" %} +{% block title %}Bild: {{ karte.titel }} — Admin{% endblock %} + +{% block content %} +

← Zurueck zur Karte

+ +
+

Bild fuer: {{ karte.titel }}

+

+ Slug: {{ karte.slug }} · Kategorie: {{ karte.kategorie }} +

+
+ + +
+

1. Screenshot einfuegen

+

+ Kopiere ein Bild in die Zwischenablage (z.B. Screenshot mit Snipping Tool) + und druecke Strg+V + hier auf der Seite. Oder waehle eine Datei aus. +

+ +
+

Strg+V zum Einfuegen

+

oder klicken um Datei auszuwaehlen

+ +
+
+ + + + + + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/admin_software_edit.html b/templates/admin_software_edit.html new file mode 100644 index 0000000..8911272 --- /dev/null +++ b/templates/admin_software_edit.html @@ -0,0 +1,138 @@ +{% extends "base.html" %} +{% block title %}{{ karte.titel }} bearbeiten — Admin{% endblock %} +{% block content %} +
+

Karte bearbeiten: {{ karte.titel }}

+ +
+ +{# ---- Bild-Sektion ---- #} +
+

Bild

+
+
+ {% if karte.hat_bild_blob %} + + {% elif karte.bild_pfad %} + + {% else %} +
+ {%- if karte.kategorie == 'app' -%}📱 + {%- elif karte.kategorie == 'generator' -%}🧩 + {%- else -%}🖥️ + {%- endif -%} +
+ {% endif %} +
+
+

+ Logo / Screenshot fuer die Karte. Wird auf der oeffentlichen Software-Seite angezeigt. +

+ + Bild einfuegen / aendern + + {% if karte.hat_bild_blob %} +
+ +
+ {% endif %} +
+
+
+ +{# ---- Daten-Sektion ---- #} +
+

Inhalt

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ Nur bei Apps +
+
+ + +
+
+ + +
+
+
+ +
+ + +
+ +
+ Abbrechen + +
+
+
+ +{% endblock %} diff --git a/templates/cockpit.html b/templates/cockpit.html index 726e076..03e664a 100644 --- a/templates/cockpit.html +++ b/templates/cockpit.html @@ -99,7 +99,7 @@
{% else %} -
+

Heute eingecheckt: {% if status.check_in_heute.modus == 'weiter_wie_bisher' %}Arbeite weiter @@ -110,6 +110,85 @@ {% else %}{{ status.check_in_heute.modus }}{% endif %} {% if status.check_in_heute.freitext %} — „{{ status.check_in_heute.freitext }}"{% endif %}

+
+ +
+
+{% endif %} + +{# ====== PROJEKT-KARTE — ganz oben, damit "Hier weitermachen" sofort sichtbar ist ====== #} +{% if status.hat_projekte or not status.ist_neu or status.projekt.letztes %} +
+

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

+ + {% if status.projekt.aktiv %} + {# === Aktives Projekt: Weitermachen-Karte === #} +
+
+ {% if status.projekt.aktiv.hat_cover %} + + {% else %} +
🎯
+ {% endif %} +
+
+

{{ status.projekt.aktiv.titel }}

+ {% if status.projekt.aktiv.beschreibung_md %} +
+ {{ status.projekt.aktiv.beschreibung_md|markdown }} +
+ {% endif %} + +
+
+ + {% elif status.projekt.letztes and status.projekt.letztes.freigabe_status == 'wartet_auf_freigabe' %} +
+ ⏳ Wartet auf Freigabe: + {{ status.projekt.letztes.titel }} +
+ Der Lehrer schaut drueber. Sobald freigegeben, kannst du ein neues Projekt anfangen. +
+
+ + {% elif status.projekt.letztes and status.projekt.letztes.freigabe_status == 'rueckfrage' %} +
+ ❓ Rueckfrage offen zu: + {{ status.projekt.letztes.titel }} +
+ Siehe Rueckfrage-Banner oben, beantworte sie — dann kann der Lehrer freigeben. +
+
+ + {% else %} +

+ Du hast gerade kein laufendes Projekt. Bereit fuer was Neues? +

+ {% if status.projekt.darf_neues %} + + Neues Projekt anfangen + {% else %} +
+ {{ status.projekt.sperr_grund }} +
+ {% endif %} + {% endif %} + + {% if status.projekt.aktiv or status.projekt.letztes %} + + {% endif %}
{% endif %} @@ -163,30 +242,6 @@
{% 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 %}
diff --git a/templates/drucker.html b/templates/drucker.html new file mode 100644 index 0000000..7cd5ca7 --- /dev/null +++ b/templates/drucker.html @@ -0,0 +1,163 @@ +{% extends "base.html" %} +{% block title %}Drucker{% endblock %} +{% block content %} + + + +
+

Drucker

+ Meine Reservierungen +
+

+ Aktueller Stand unserer 4 Bambu-Lab-Drucker. Kein Drucken ohne Reservierung — es + muss immer klar sein, wer gerade an welchem Drucker druckt. +

+ +{% if not darf_reservieren %} +
+ Reservierung noch nicht freigeschaltet. + Du kannst die Drucker bisher nur ansehen. Lass dich beim Lehrer freischalten, + dann kannst du selbst Slots reservieren. +
+{% endif %} + +{% set gesperrte = drucker_liste | selectattr('freigegeben', 'equalto', 0) | list %} +{% if gesperrte %} +
+ ⚠ {{ gesperrte|length }} Drucker derzeit gesperrt: + {{ gesperrte | map(attribute='name') | join(', ') }} + — Details auf den jeweiligen Kacheln. +
+{% endif %} + +
+ {% for d in drucker_liste %} +
+
+

{{ d.name }}

+
+ {{ d.modell }}{% if not d.geschlossenes_gehaeuse %} · offen{% endif %} +
+
+ + {# Sperre-Banner hat Vorrang vor allem anderen #} + {% if not d.freigegeben %} +
+ Gesperrt{% if d.gesperrt_grund %} — {{ d.gesperrt_grund }}{% endif %} +
Reservierung derzeit nicht moeglich.
+
+ {% endif %} + + {# Status-Zeile #} +
+ {% if d.status == 'printing' %}🟢 druckt + {% elif d.status == 'idle' %}⚪ frei + {% elif d.status == 'paused' %}🟡 pausiert + {% elif d.status == 'finished' %}✅ fertig — Druck abholen + {% elif d.status == 'failed' %}🔴 Fehler + {% elif d.status == 'offline' %}⚫ offline + {% else %}⚪ kein Status + {% endif %} +
+ + {# Job-Details wenn aktiv #} + {% 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 %} + + {# Notiz vom Lehrer (auch wenn nicht gesperrt) #} + {% if d.notiz and d.freigegeben %} +
ℹ️ {{ d.notiz }}
+ {% endif %} + + {# Hinweis wenn noch keine API-Daten #} + {% if not d.status %} +
+ (Live-Status via Bambu-API kommt mit Etappe 5. Bis dahin pflegt der Lehrer den Status manuell.) +
+ {% elif d.manuell_gesetzt %} +
vom Lehrer gesetzt
+ {% endif %} + + {# Belegung: aktuell + naechste 3 Slots #} +
+ {% if d.belegung %} +
🔖 Jetzt belegt: + {{ tier_emoji(d.belegung.tier or '') }} {{ d.belegung.vorname or '—' }} + bis {{ d.belegung.ende_zeit|iso_format('%H:%M') }} Uhr +
+ {% else %} +
🔖 Aktuell frei.
+ {% endif %} + {% if d.kommend %} +
+ Naechste: + {% for s in d.kommend %} + {{ s.start_zeit|iso_format('%d.%m. %H:%M') }}–{{ s.ende_zeit|iso_format('%H:%M') }} + ({{ s.vorname or '—' }}){% if not loop.last %}, {% endif %} + {% endfor %} +
+ {% endif %} +
+ + {# Reservieren-Button #} + {% if d.freigegeben and darf_reservieren %} + + Diesen Drucker reservieren + {% elif d.freigegeben and not darf_reservieren %} +
+ Reservierung ist fuer dich noch nicht freigeschaltet. +
+ {% endif %} + + {# Manual + Troubleshooting #} + +
+ {% endfor %} +
+ +{% if not drucker_liste %} +
+

Noch keine Drucker eingerichtet.

+
+{% endif %} + +{% endblock %} diff --git a/templates/profil_slot_neu.html b/templates/profil_slot_neu.html new file mode 100644 index 0000000..18550af --- /dev/null +++ b/templates/profil_slot_neu.html @@ -0,0 +1,79 @@ +{% extends "base.html" %} +{% block title %}Drucker reservieren{% endblock %} +{% block content %} + +
+

Drucker reservieren

+ Meine Slots +
+ +{% if not drucker_liste %} +
+ Kein Drucker frei. Alle Drucker sind derzeit gesperrt (Wartung / Reparatur). + Frag den Lehrer, wann wieder reserviert werden kann. +
+{% else %} + +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ Regeln: Mindest-Dauer 15 Minuten, Maximum 24 Stunden. + Achte darauf, dass dein Druck in deinem Slot fertig wird — sonst kann der naechste SuS nicht starten. +
+ +
+ Abbrechen + +
+
+
+ +{% endif %} + +{% endblock %} diff --git a/templates/profil_slots.html b/templates/profil_slots.html new file mode 100644 index 0000000..46794a0 --- /dev/null +++ b/templates/profil_slots.html @@ -0,0 +1,75 @@ +{% extends "base.html" %} +{% block title %}Meine Slots{% endblock %} +{% block content %} + +
+

Meine Drucker-Reservierungen

+
+ {% if darf_reservieren %} + + Neue Reservierung + {% endif %} + Drucker-Uebersicht +
+
+ +{% if not darf_reservieren %} +
+ Reservierung noch nicht freigeschaltet. + Bitte den Lehrer kurz darum — er muss dich einmalig freischalten, danach kannst du selbst reservieren. +
+{% endif %} + +{% if slots %} +
+ + + + + + + + + + + + + + {% for s in slots %} + + + + + + + + + + {% endfor %} + +
DruckerStartEndeDauerStatusNotiz
{{ s.drucker_name }} +
{{ s.modell }}
+
{{ s.start_zeit|iso_format('%d.%m. %H:%M') }}{{ s.ende_zeit|iso_format('%d.%m. %H:%M') }}{{ s.start_zeit|dauer_min(s.ende_zeit) }} min + {% if s.status == 'reserviert' %}reserviert + {% elif s.status == 'aktiv' %}aktiv + {% else %}{{ s.status }}{% endif %} + {{ s.notiz or '' }} +
+ +
+
+
+{% else %} +
+

+ Du hast aktuell keine bevorstehenden Reservierungen. + {% if darf_reservieren %} + Jetzt eine Reservierung anlegen. + {% endif %} +

+
+{% endif %} + +{% endblock %} diff --git a/templates/projekt_abschluss.html b/templates/projekt_abschluss.html new file mode 100644 index 0000000..9166144 --- /dev/null +++ b/templates/projekt_abschluss.html @@ -0,0 +1,129 @@ +{% extends "base.html" %} +{% block title %}{{ p.titel }} abschliessen{% endblock %} +{% block content %} + + + +
+

"{{ p.titel }}" abschliessen

+ Abbrechen +
+ +
+ Ehrlich bleiben. Niemand wird benotet — die Bewertung ist fuer dich selbst, + damit du beim naechsten Projekt besser einschaetzen kannst, was du planen musst. +
+ +
+
+ + {# ============== Gesamt ============== #} +
+
Wie ist es insgesamt gelaufen? *
+
+ "Gelernt" heisst: hat nicht so geklappt, aber ich hab dabei was verstanden. + Bei "Gelernt" schaut der Lehrer kurz drueber, bevor du was Neues anfaengst. +
+
+ + + +
+
+ + {# ============== Planung ============== #} +
+
Wie gut war deine Planung im Vorhinein?
+
Hattest du vorher klar, was du bauen willst und wie?
+
+ + + +
+
+ + {# ============== Ausfuehrung ============== #} +
+
Wie gut hat die Ausfuehrung geklappt?
+
Modellieren, Slicen, Drucken — wie ist das gelaufen?
+
+ + + +
+
+ + {# ============== Freitexte ============== #} +
+ +
+ 1-2 Saetze reichen. Was nimmst du mit fuer's naechste Mal? +
+ +
+ +
+ +
+ Nur wenn was schief lief. Hilft dem Lehrer beim Drueberschauen. +
+ +
+ +
+ Abbrechen + +
+
+
+ +{% endblock %} diff --git a/templates/projekt_bearbeiten.html b/templates/projekt_bearbeiten.html new file mode 100644 index 0000000..52a3af2 --- /dev/null +++ b/templates/projekt_bearbeiten.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% block title %}{{ p.titel }} bearbeiten{% endblock %} +{% block content %} + +
+

Projekt bearbeiten

+ ← Zurueck +
+ +
+
+
+ + +
+
+ + +
+ Markdown: **fett**, *kursiv*, Listen mit -, Ueberschriften mit #. +
+
+
+ Abbrechen + +
+
+
+ +{% endblock %} diff --git a/templates/projekt_cover.html b/templates/projekt_cover.html new file mode 100644 index 0000000..f0910ef --- /dev/null +++ b/templates/projekt_cover.html @@ -0,0 +1,290 @@ +{% extends "base.html" %} +{% block title %}Cover-Bild: {{ p.titel }}{% endblock %} + +{% block content %} +

← Zurueck zum Projekt

+ +
+

Cover-Bild fuer: {{ p.titel }}

+

+ Ein Foto vom Druck oder ein Screenshot vom Modell — wird im Cockpit und spaeter im Katalog gezeigt. +

+
+ + +
+

1. Bild einfuegen

+

+ Kopiere ein Bild in die Zwischenablage (Snipping Tool, Handy-Foto) und druecke + Strg+V + hier auf der Seite. Oder waehle eine Datei aus. +

+ +
+

Strg+V zum Einfuegen

+

oder klicken um Datei auszuwaehlen

+ +
+
+ + + + + + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/projekt_detail.html b/templates/projekt_detail.html new file mode 100644 index 0000000..a0c7638 --- /dev/null +++ b/templates/projekt_detail.html @@ -0,0 +1,405 @@ +{% extends "base.html" %} +{% block title %}{{ p.titel }}{% endblock %} +{% block content %} + +
+

{{ p.titel }}

+ +
+ +{# ============== Lineage-Banner: Vorlage + Wiederholungen ============== #} +{% if vorlage %} +
+ 🔗 Vorlage: + {{ vorlage.titel }} + von {{ tier_emoji(vorlage.tier or '') }} {{ vorlage.vorname or '—' }} +
+ Du machst dein eigenes Projekt — die Vorlage ist nur Inspiration. Eigener Twist erwuenscht! +
+
+{% endif %} + +{% if wiederholungen %} +
+ 🔁 Wurde wiederholt von: + {% for w in wiederholungen %} + {{ tier_emoji(w.tier or '') }} + {{ w.vorname or '—' }}{% if not loop.last %}, {% endif %} + {% endfor %} +
+{% endif %} + +{# ============== Status-Zeile ============== #} +
+ {% if p.status_lebenszyklus == 'in_arbeit' %} + 🔧 in Arbeit + {% else %} + ✅ abgeschlossen + {% if p.selbsteinschaetzung == 'zufrieden' %}🟢 zufrieden + {% elif p.selbsteinschaetzung == 'mittel' %}🟡 mittel + {% elif p.selbsteinschaetzung == 'gelernt' %}🔴 gelernt + {% endif %} + {% if p.freigabe_status == 'wartet_auf_freigabe' %}⏳ wartet auf Freigabe + {% elif p.freigabe_status == 'rueckfrage' %}❓ Rueckfrage offen + {% elif p.freigabe_status == 'freigegeben' %}✓ freigegeben + {% endif %} + {% endif %} +
+ +
+ + {# ============== Cover-Bild ============== #} +
+ {% if p.hat_cover %} + + {% else %} +
+ 🎯 +
+ {% endif %} +
+ + {% if p.hat_cover %}Bild aendern{% else %}Bild hinzufuegen{% endif %} + + {% if p.hat_cover %} +
+ +
+ {% endif %} +
+
+ + {# ============== Beschreibung + Eval ============== #} +
+
+

Beschreibung

+ {% if p.beschreibung_md %} +
{{ p.beschreibung_md|markdown }}
+ {% else %} +

Noch keine Beschreibung.

+ {% endif %} + {% if p.status_lebenszyklus == 'in_arbeit' %} + + {% endif %} +
+ + {% if p.status_lebenszyklus == 'abgeschlossen' %} +
+

Deine Bewertung

+
+
+
Gesamt
+
+ {% if p.selbsteinschaetzung == 'zufrieden' %}🟢 + {% elif p.selbsteinschaetzung == 'mittel' %}🟡 + {% elif p.selbsteinschaetzung == 'gelernt' %}🔴 + {% endif %} +
+
{{ p.selbsteinschaetzung or '—' }}
+
+
+
Planung
+
+ {% if p.selbsteinschaetzung_planung == 'gut' %}🟢 + {% elif p.selbsteinschaetzung_planung == 'ok' %}🟡 + {% elif p.selbsteinschaetzung_planung == 'schwierig' %}🔴 + {% else %}—{% endif %} +
+
{{ p.selbsteinschaetzung_planung or '—' }}
+
+
+
Ausfuehrung
+
+ {% if p.selbsteinschaetzung_ausfuehrung == 'gut' %}🟢 + {% elif p.selbsteinschaetzung_ausfuehrung == 'ok' %}🟡 + {% elif p.selbsteinschaetzung_ausfuehrung == 'schwierig' %}🔴 + {% else %}—{% endif %} +
+
{{ p.selbsteinschaetzung_ausfuehrung or '—' }}
+
+
+ {% if p.was_gelernt %} +
+ Was gelernt: +
{{ p.was_gelernt }}
+
+ {% endif %} + {% if p.was_klappte_nicht %} +
+ Was nicht klappte: +
{{ p.was_klappte_nicht }}
+
+ {% endif %} +
+ {% endif %} + + {# ============== Werkstatt (nur waehrend in_arbeit) ============== #} + {% if p.status_lebenszyklus == 'in_arbeit' %} +
+

🔧 Werkstatt

+

+ Hier alles zum Projekt — Beschreibung, Software, Drucker, MakerWorld-Link und Dateien. +

+
+
+ + +
+
+ + +
+ Markdown: **fett**, *kursiv*, Listen mit -. +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+
+ + {# ============== Dateien-Bereich ============== #} +

Dateien

+

+ STL, 3MF, Fotos. Mindestens eine Datei + ein Cover-Bild sind noetig, um das Projekt abzuschliessen. +

+ + {% if dateien %} + + + + + + {% for d in dateien %} + + + + + + + {% endfor %} + +
DateiTypGroesse
+ {{ d.dateiname }} + {{ d.art }} + {{ (d.groesse_bytes // 1024) if d.groesse_bytes else 0 }} KB + +
+ +
+
+ {% else %} +

+ Noch keine Dateien. +

+ {% endif %} + + {# Upload-Zone #} +
+
📁 Dateien hier ablegen
+
+ oder klicken — STL, 3MF, JPG, PNG · max. 30 MB pro Datei +
+ +
+
+
+ {% endif %} + + {# ============== Aktionen ============== #} +
+ {% if p.status_lebenszyklus == 'in_arbeit' %} +

Naechster Schritt

+
+ + 📷 {% if p.hat_cover %}Cover aendern{% else %}Cover-Bild hinzufuegen{% endif %} + + {% if darf_abschliessen %} + + ✅ Projekt abschliessen + + {% else %} + + {% endif %} +
+ {% if not darf_abschliessen %} +
+ Noch nicht abschliessbar: +
    + {% for f in abschluss_fehlende %}
  • {{ f }}
  • {% endfor %} +
+
+ {% endif %} + {% elif p.freigabe_status == 'wartet_auf_freigabe' %} +
+ Wartet auf Freigabe vom Lehrer. + Sobald er drueber geschaut hat, kannst du ein neues Projekt anfangen. +
+ {% elif p.freigabe_status == 'rueckfrage' %} +
+ Lehrer hat eine Rueckfrage. + Schau ins Cockpit fuer die Details und beantworte sie. +
+ {% else %} +
+ Freigegeben. + {% if p.freigegeben_am %}Am {{ p.freigegeben_am[:10] }}.{% endif %} + Du kannst jetzt ein neues Projekt anfangen. +
+ {% endif %} +
+
+
+ +{% endblock %} + +{% block scripts %} +{# Upload-JS nur wenn Werkstatt sichtbar ist (also in_arbeit). #} +{% if p.status_lebenszyklus == 'in_arbeit' %} + +{% endif %} +{% endblock %} diff --git a/templates/projekt_liste.html b/templates/projekt_liste.html new file mode 100644 index 0000000..bcf6fc6 --- /dev/null +++ b/templates/projekt_liste.html @@ -0,0 +1,66 @@ +{% extends "base.html" %} +{% block title %}Meine Projekte{% endblock %} +{% block content %} + +
+

Meine Projekte

+
+ {% if darf_neues %} + + Neues Projekt + {% endif %} + Cockpit +
+
+ +{% if not darf_neues and sperr_grund %} +
+ {{ sperr_grund }} +
+{% endif %} + +{% if projekte %} + +{% else %} +
+

+ Du hast noch keine Projekte. + {% if darf_neues %}Leg dein erstes an.{% endif %} +

+
+{% endif %} + +{% endblock %} diff --git a/templates/projekt_neu.html b/templates/projekt_neu.html new file mode 100644 index 0000000..b94e182 --- /dev/null +++ b/templates/projekt_neu.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %}Neues Projekt{% endblock %} +{% block content %} + +
+

Neues Projekt anlegen

+ Abbrechen +
+ +
+ Commitment. Mit dem Anlegen entscheidest du dich, dieses Projekt durchzuziehen. + Du kannst es spaeter abschliessen und selbst bewerten — und erst dann wieder ein neues anfangen. +
+ +
+
+
+ + +
+
+ + +
+ Markdown geht: **fett**, *kursiv*, Listen mit -. +
+
+
+ Abbrechen + +
+
+
+ +{% endblock %} diff --git a/templates/projekte_katalog.html b/templates/projekte_katalog.html new file mode 100644 index 0000000..dbd8fb0 --- /dev/null +++ b/templates/projekte_katalog.html @@ -0,0 +1,279 @@ +{% extends "base.html" %} +{% block title %}Projekte{% endblock %} +{% block content %} + + + +

Projekte

+

+ Was die AG gerade macht — laufende und fertige Projekte. Klick auf eine Zeile fuer Details. + Spalten-Header klicken zum Sortieren. +

+ +{% if projekte %} +
+ + + + + + + + + + + + + + {% for p in projekte %} + {# ====== Kompakte Zeile ====== #} + + + + + + + + + + + {# ====== Detail-Zeile (versteckt bis Klick) ====== #} + + + + {% endfor %} + +
Titel▲▼Wer▲▼Status▲▼Erfolg▲▼Datum▲▼MW
+
+ {% if p.hat_cover %} + + {% else %}🎯{% endif %} +
+
{{ p.titel }}{{ tier_emoji(p.tier or '') }} {{ p.vorname or '—' }} + {% if p.status_lebenszyklus == 'in_arbeit' %}🔧 laeuft + {% else %}✅ fertig{% endif %} + + {% if p.selbsteinschaetzung == 'zufrieden' %}🟢 + {% elif p.selbsteinschaetzung == 'mittel' %}🟡 + {% elif p.selbsteinschaetzung == 'gelernt' %}🔴 + {% else %}—{% endif %} + + {{ (p.abgeschlossen_am or p.geaendert_am or '')|iso_format('%d.%m.%y') }} + + {% if p.makerworld_url %} + 🌐 + {% endif %} +
+
+
+ {% if p.hat_cover %} + + {% else %} +
🎯
+ {% endif %} +
+
+

{{ p.titel }}

+
+ {{ tier_emoji(p.tier or '') }} {{ p.vorname or '—' }} + {% if p.software_titel %} · 🖥️ {{ p.software_titel }}{% endif %} + {% if p.drucker_name %} · 🖨️ {{ p.drucker_name }}{% endif %} + {% if p.abgeschlossen_am %} · abgeschlossen {{ p.abgeschlossen_am|iso_format('%d.%m.%Y') }}{% endif %} +
+ + {% if p.beschreibung_md %} +
{{ p.beschreibung_md|markdown }}
+ {% else %} +

Keine Beschreibung.

+ {% endif %} + + {% if p.dateien %} + + {% endif %} + + {% if p.makerworld_url %} + + {% endif %} + + {# ====== "Sowas mache ich auch"-Button ====== #} +
+ {% if p.profil_id == eigene_profil_id %} + + Dein eigenes Projekt. + + {% elif darf_wiederholen %} +
+ +
+ {% else %} + + ℹ️ {{ wiederhol_sperr_grund }} + + {% endif %} +
+
+
+
+
+{% else %} +
+

Noch keine Projekte im Katalog.

+
+{% endif %} + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/software.html b/templates/software.html new file mode 100644 index 0000000..66de231 --- /dev/null +++ b/templates/software.html @@ -0,0 +1,167 @@ +{% extends "base.html" %} +{% block title %}Software{% endblock %} +{% block content %} + +{# Kleine, lokale CSS-Erganzungen fuer die Software-Karten. + Bewusst inline, damit alle Stil-Anpassungen auf einen Blick sichtbar sind. + Wenn die Site mehr eigene Stile bekommt, ziehen wir sie nach static/. #} + + +{# =========================================================================== + Karten-Makro: wird unten in jeder Sektion verwendet. + Muss VOR den Aufrufen stehen — Jinja2 mit extends ist da streng. + =========================================================================== #} +{% macro karte(k) %} +
+ {# Bild: Crop-Upload (BLOB) > static-Fallback > Emoji-Platzhalter #} + {% if k.hat_bild_blob %} + + {% elif k.bild_pfad %} + + {% else %} +
+ {%- if k.kategorie == 'app' -%}📱 + {%- elif k.kategorie == 'generator' -%}🧩 + {%- else -%}🖥️ + {%- endif -%} +
+ {% endif %} + +

{{ k.titel }}

+ {% if k.plattform %}
{{ k.plattform }}
{% endif %} +
{{ k.kurzbeschreibung }}
+ +
+ {% if k.url %} + + {% if k.kategorie == 'generator' %}Zum Generator ↗{% else %}Zur Software ↗{% endif %} + + {% endif %} + {% if k.tutorial_url %} + + Tutorial ↗ + + {% endif %} + {% if k.app_store_url %} + + App Store + + {% endif %} + {% if k.play_store_url %} + + ▶ Google Play + + {% endif %} +
+
+{% endmacro %} + +

Software

+

+ Diese Programme nutzen wir in der AG. Den Umgang lernst du in den + Lektionen — hier ist nur die Uebersicht. +

+ +{# ---- Sektion: Software ---- #} +{% if gruppen.software %} +
+

Programme

+

Auf Schul-PCs installiert oder im Browser nutzbar.

+
+
+ {% for k in gruppen.software %} + {{ karte(k) }} + {% endfor %} +
+{% endif %} + +{# ---- Sektion: Apps ---- #} +{% if gruppen.app %} +
+

Handy-Apps

+

Aufs eigene Smartphone laden — QR-Codes zum Scannen folgen.

+
+
+ {% for k in gruppen.app %} + {{ karte(k) }} + {% endfor %} +
+{% endif %} + +{# ---- Sektion: Generatoren ---- #} +{% if gruppen.generator %} +
+

Online-Generatoren

+

Webseiten, auf denen du fertige 3D-Modelle nach deinen Wuenschen erzeugen kannst — kein CAD noetig.

+
+
+ {% for k in gruppen.generator %} + {{ karte(k) }} + {% endfor %} +
+{% endif %} + +{# Wenn gar nichts da ist (Migration noch nicht gelaufen?) #} +{% if not gruppen.software and not gruppen.app and not gruppen.generator %} +
+

Noch keine Software eingetragen. + {% if session.admin %}Im Admin anlegen.{% endif %} +

+
+{% endif %} + +{% endblock %}