From ae68da996fdf813ac6c3d346e530f01031aca2a9 Mon Sep 17 00:00:00 2001 From: Hans Puettmann Date: Sat, 30 May 2026 17:12:45 +0200 Subject: [PATCH] Externe Datei-Links + Power-User-Rolle + PAROL6-Demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Block A โ€” Externe Links: - projekt_datei.extern_url: eine "Datei" kann Upload (BLOB) ODER Link sein - Link-Route /cockpit/projekt//link, Werkstatt-UI mit Link-Feld - Katalog + Detail rendern Links als "Oeffnen โ†—", Uploads als Download - Upload-Limit 30 -> 60 MB (grosse Bambu-3MF); darueber: externer Link Block B โ€” Power-User-Rolle: - profil.rolle ('schueler'|'power'); Login setzt Session-Rolle - Decorator power_oder_admin; ist_power-Helper - Login-Kacheln: Power mit ๐Ÿ”ง statt Tier; Admin-Profilanlage mit Rollen-Dropdown (Tier nur bei schueler Pflicht, Power kriegt Platzhalter-Slug 'power') - Cockpit: kein Check-In fuer Power, stattdessen "Power-Werkzeuge"-Karte - Power-Projekt-Regeln: mehrere parallel erlaubt, Abschluss direkt 'freigegeben' - Rechte power_oder_admin: Drucker-Verwaltung, Software-Katalog, Slot-fuer-SuS, inline-edit (beschraenkt auf drucker+software_seite) - Bleibt admin-only: SuS-Profile, Projekt-Freigaben, Admin-Dashboard - Power-relevante Admin-Templates: Zurueck-Link rollenabhaengig (Cockpit statt Dashboard) Block C โ€” PAROL6-Demo: - scripts/seed_demo_parol6.py: Power-Profil 'Herb' + PAROL6-Roboterarm-Projekt - GitHub-Repo als Link, Bauanleitung-PDF als BLOB, Cover aus 3MF-Render - abgeschlossen+freigegeben -> im Katalog, als Vorlage waehlbar Co-Authored-By: Claude Opus 4.7 (1M context) --- database.py | 12 +- migrations/2026-05-30_profil_rolle.py | 33 ++++ .../2026-05-30_projekt_datei_extern_url.py | 32 ++++ routes/admin.py | 63 ++++--- routes/profil.py | 53 +++++- scripts/seed_demo_parol6.py | 162 ++++++++++++++++++ services/auth.py | 24 +++ services/projekte.py | 29 +++- templates/admin_drucker.html | 3 +- templates/admin_profile.html | 37 +++- templates/admin_slots.html | 3 +- templates/admin_software.html | 3 +- templates/cockpit.html | 21 ++- templates/login.html | 13 +- templates/projekt_detail.html | 34 +++- templates/projekte_katalog.html | 10 +- 16 files changed, 474 insertions(+), 58 deletions(-) create mode 100644 migrations/2026-05-30_profil_rolle.py create mode 100644 migrations/2026-05-30_projekt_datei_extern_url.py create mode 100644 scripts/seed_demo_parol6.py diff --git a/database.py b/database.py index b069626..bb11285 100644 --- a/database.py +++ b/database.py @@ -49,6 +49,11 @@ CREATE TABLE IF NOT EXISTS profil ( -- 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, + -- Rolle: 'schueler' (Default) | 'power'. Power-User sind fortgeschrittene + -- SuS (oder Herb selbst): duerfen Drucker/Software verwalten + Slots fuer + -- andere + mehrere Demo-Projekte, aber KEINE SuS-Verwaltung/Freigaben. + -- Kein Tier-Zwang, kein Check-In. + rolle TEXT DEFAULT 'schueler', erstellt_am DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(ag_id, vorname, tier), FOREIGN KEY (ag_id) REFERENCES ag_mitgliedschaft(id) @@ -136,11 +141,12 @@ CREATE INDEX IF NOT EXISTS idx_projekt_sichtbar ON projekt(sichtbar_in_ag, freig CREATE TABLE IF NOT EXISTS projekt_datei ( id INTEGER PRIMARY KEY AUTOINCREMENT, projekt_id INTEGER NOT NULL, - dateiname TEXT NOT NULL, + dateiname TEXT NOT NULL, -- Anzeigename (auch fuer Links) mime_type TEXT, groesse_bytes INTEGER, - inhalt BLOB, - art TEXT NOT NULL, -- 'cover_foto'|'stl'|'3mf'|'foto'|'sonstiges' + inhalt BLOB, -- NULL bei externen Links + extern_url TEXT, -- gesetzt = externer Link (GitHub/MakerWorld/...), inhalt dann NULL + art TEXT NOT NULL, -- 'cover_foto'|'stl'|'3mf'|'foto'|'link'|'sonstiges' hochgeladen_am DATETIME DEFAULT CURRENT_TIMESTAMP, hochgeladen_von INTEGER, FOREIGN KEY (projekt_id) REFERENCES projekt(id) ON DELETE CASCADE, diff --git a/migrations/2026-05-30_profil_rolle.py b/migrations/2026-05-30_profil_rolle.py new file mode 100644 index 0000000..9121d5b --- /dev/null +++ b/migrations/2026-05-30_profil_rolle.py @@ -0,0 +1,33 @@ +"""Migration 2026-05-30: profil.rolle einfuehren. + +Rollen-Modell fuer Power-User (fortgeschrittene SuS oder Herb selbst): +- 'schueler' (Default): aktueller Stand +- 'power': darf Drucker/Software verwalten, Slots fuer andere anlegen, + mehrere Demo-Projekte fuehren (direkt freigegeben), KEINE SuS-Verwaltung + und KEINE Projekt-Freigaben. Kein Tier-Zwang, kein Check-In. + +Idempotent. Bestehende Profile bleiben 'schueler'. +""" +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 "rolle" in spalten: + conn.close() + print("[migration] profil.rolle existiert bereits.") + return + conn.execute("ALTER TABLE profil ADD COLUMN rolle TEXT DEFAULT 'schueler'") + conn.execute("UPDATE profil SET rolle = 'schueler' WHERE rolle IS NULL") + conn.commit() + conn.close() + print("[migration] profil.rolle ergaenzt (alle bestehenden Profile = 'schueler').") + + +if __name__ == "__main__": + main() diff --git a/migrations/2026-05-30_projekt_datei_extern_url.py b/migrations/2026-05-30_projekt_datei_extern_url.py new file mode 100644 index 0000000..e5ce783 --- /dev/null +++ b/migrations/2026-05-30_projekt_datei_extern_url.py @@ -0,0 +1,32 @@ +"""Migration 2026-05-30: projekt_datei.extern_url einfuehren. + +Erlaubt externe Links als "Datei" (GitHub-Repo, MakerWorld, Printables, +gehostete grosse Druckdateien) statt alles als BLOB in die DB zu pressen. + +- extern_url gesetzt -> inhalt BLOB ist NULL, art = 'link' +- extern_url NULL -> klassischer Upload (BLOB) + +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(projekt_datei)")} + if "extern_url" in spalten: + conn.close() + print("[migration] projekt_datei.extern_url existiert bereits.") + return + conn.execute("ALTER TABLE projekt_datei ADD COLUMN extern_url TEXT") + conn.commit() + conn.close() + print("[migration] projekt_datei.extern_url ergaenzt.") + + +if __name__ == "__main__": + main() diff --git a/routes/admin.py b/routes/admin.py index a4a1b10..93db2e3 100644 --- a/routes/admin.py +++ b/routes/admin.py @@ -11,7 +11,7 @@ import secrets from flask import Blueprint, render_template, request, redirect, url_for, session, flash, jsonify, abort from database import get_db -from services.auth import pin_hashen, admin_passwort_pruefen, admin_required +from services.auth import pin_hashen, admin_passwort_pruefen, admin_required, power_oder_admin from tiere import TIERE, tier_emoji, tier_name bp = Blueprint("admin", __name__, url_prefix="/admin") @@ -69,10 +69,10 @@ def profile_liste(): conn = get_db() profile = conn.execute( "SELECT p.id, p.vorname, p.tier, p.aktiv, p.erstellt_am, " - " p.ag_id, p.reservieren_freigeschaltet, ag.name AS ag_name " + " p.ag_id, p.reservieren_freigeschaltet, p.rolle, 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" + "ORDER BY p.aktiv DESC, p.rolle DESC, p.vorname COLLATE NOCASE" ).fetchall() ags = conn.execute("SELECT id, name FROM ag_mitgliedschaft WHERE status = 'aktiv'").fetchall() conn.close() @@ -91,9 +91,17 @@ def profil_neu(): vorname = (request.form.get("vorname") or "").strip() tier = (request.form.get("tier") or "").strip() ag_id = (request.form.get("ag_id") or "").strip() + rolle = (request.form.get("rolle") or "schueler").strip() + if rolle not in ("schueler", "power"): + rolle = "schueler" + + # Power-User brauchen kein Tier (Login zeigt ๐Ÿ”ง). Platzhalter-Slug, + # damit der NOT-NULL- + UNIQUE-Constraint erfuellt ist. + if rolle == "power" and not tier: + tier = "power" if not (vorname and tier and ag_id): - flash("Bitte Vorname, Tier und AG angeben.", "error") + flash("Bitte Vorname, Tier (bei SuS) und AG angeben.", "error") return redirect(url_for("admin.profile_liste")) # PIN generieren: 4-stellig, mit fuehrenden Nullen @@ -103,9 +111,9 @@ def profil_neu(): conn = get_db() try: cur = conn.execute( - "INSERT INTO profil (vorname, tier, pin_hash, ag_id, aktiv) " - "VALUES (?, ?, ?, ?, 1)", - (vorname, tier, pin_hash, ag_id) + "INSERT INTO profil (vorname, tier, pin_hash, ag_id, aktiv, rolle) " + "VALUES (?, ?, ?, ?, 1, ?)", + (vorname, tier, pin_hash, ag_id, rolle) ) conn.commit() neue_id = cur.lastrowid @@ -195,8 +203,13 @@ def profil_pin_reset(profil_id): # Inline-Edit (Etappe 1) # =========================================================================== +# Tabellen, die Power-User (ohne Admin-Session) inline editieren duerfen. +# Lektion + Einstellung bleiben Lehrer-Sache. +POWER_INLINE_TABELLEN = {"drucker", "software_seite"} + + @bp.route("/inline-edit", methods=["POST"]) -@admin_required +@power_oder_admin def inline_edit(): """Universal-Endpunkt fuer Inline-Edit. Prueft Whitelist + loggt Aenderung.""" tabelle = (request.form.get("tabelle") or "").strip() @@ -207,6 +220,9 @@ def inline_edit(): # Whitelist-Pruefung if tabelle not in ERLAUBTE_INLINE_TABELLEN: return jsonify(ok=False, error=f"Tabelle '{tabelle}' nicht erlaubt"), 400 + # Power-User (nicht Admin) duerfen nur Drucker + Software inline editieren + if not session.get("admin") and tabelle not in POWER_INLINE_TABELLEN: + return jsonify(ok=False, error="Dafuer fehlen dir die Rechte"), 403 if feld not in ERLAUBTE_INLINE_TABELLEN[tabelle]: return jsonify(ok=False, error=f"Feld '{feld}' nicht erlaubt"), 400 try: @@ -263,7 +279,7 @@ def profile_karten(): # =========================================================================== @bp.route("/slots") -@admin_required +@power_oder_admin def slot_uebersicht(): """Uebersicht aller bevorstehenden + heute aktiven Slots.""" from datetime import datetime, timedelta @@ -287,7 +303,7 @@ def slot_uebersicht(): @bp.route("/slot/neu", methods=["GET", "POST"]) -@admin_required +@power_oder_admin def slot_neu_lehrer(): """Lehrer reserviert im Namen eines SuS โ€” ignoriert das Freischalt-Flag.""" from datetime import datetime, timedelta @@ -389,7 +405,7 @@ def slot_neu_lehrer(): @bp.route("/slot//loeschen", methods=["POST"]) -@admin_required +@power_oder_admin def slot_loeschen(slot_id): """Slot hart loeschen (Lehrer-Override). Ein 'stornieren'-Status haetten wir auch, aber Lehrer-Aufraeumarbeiten sind oft endgueltig. @@ -467,7 +483,7 @@ def _software_clean_form(form): @bp.route("/software") -@admin_required +@power_oder_admin def software_liste(): """Verwaltung der Software-Karten โ€” Liste + Anlegen-Formular auf einer Seite.""" conn = get_db() @@ -481,7 +497,7 @@ def software_liste(): @bp.route("/software/neu", methods=["POST"]) -@admin_required +@power_oder_admin def software_neu(): try: daten = _software_clean_form(request.form) @@ -516,7 +532,7 @@ def software_neu(): @bp.route("/software//bearbeiten", methods=["GET", "POST"]) -@admin_required +@power_oder_admin 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 @@ -571,7 +587,7 @@ def software_bearbeiten(karte_id): @bp.route("/software//aktiv", methods=["POST"]) -@admin_required +@power_oder_admin def software_aktiv_toggle(karte_id): conn = get_db() conn.execute( @@ -584,7 +600,7 @@ def software_aktiv_toggle(karte_id): @bp.route("/software//loeschen", methods=["POST"]) -@admin_required +@power_oder_admin def software_loeschen(karte_id): """Hart loeschen โ€” wir haben keine FK-Beziehungen zu Software-Karten, das ist sicher.""" conn = get_db() @@ -745,7 +761,7 @@ def projekt_admin_loeschen(projekt_id): # =========================================================================== @bp.route("/software//bild", methods=["GET", "POST"]) -@admin_required +@power_oder_admin def software_bild(karte_id): """Bild fuer eine Software-Karte hochladen (Paste + Crop, base64 ueber JSON). @@ -795,7 +811,7 @@ def software_bild(karte_id): @bp.route("/software//bild/loeschen", methods=["POST"]) -@admin_required +@power_oder_admin def software_bild_loeschen(karte_id): conn = get_db() conn.execute( @@ -867,9 +883,12 @@ def admin_lektionen(): @bp.route("/drucker") -@admin_required +@power_oder_admin def admin_drucker(): - """Drucker-Verwaltung: Status setzen, Freigabe-Toggle, Links pflegen.""" + """Drucker-Verwaltung: Status setzen, Freigabe-Toggle, Links pflegen. + + Power-User (fortgeschrittene SuS / Herb) duerfen das mitverwalten. + """ from services.drucker_links import drucker_links conn = get_db() rows = conn.execute( @@ -901,7 +920,7 @@ DRUCKER_STATUS_OPTIONEN = ( @bp.route("/drucker//status", methods=["POST"]) -@admin_required +@power_oder_admin def drucker_status_setzen(drucker_id): """Lehrer setzt den Status eines Druckers manuell.""" from datetime import datetime @@ -940,7 +959,7 @@ def drucker_status_setzen(drucker_id): @bp.route("/drucker//freigabe", methods=["POST"]) -@admin_required +@power_oder_admin 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 diff --git a/routes/profil.py b/routes/profil.py index 6254a61..cdc2eab 100644 --- a/routes/profil.py +++ b/routes/profil.py @@ -39,8 +39,8 @@ bp = Blueprint("profil", __name__) def login(): conn = get_db() profile = conn.execute( - "SELECT id, vorname, tier FROM profil WHERE aktiv = 1 " - "ORDER BY vorname COLLATE NOCASE, tier" + "SELECT id, vorname, tier, rolle FROM profil WHERE aktiv = 1 " + "ORDER BY rolle DESC, vorname COLLATE NOCASE, tier" ).fetchall() conn.close() return render_template("login.html", profile=profile) @@ -50,7 +50,7 @@ def login(): def login_pin(profil_id): conn = get_db() profil = conn.execute( - "SELECT id, vorname, tier, pin_hash, aktiv FROM profil WHERE id = ?", + "SELECT id, vorname, tier, pin_hash, aktiv, rolle FROM profil WHERE id = ?", (profil_id,) ).fetchone() conn.close() @@ -77,6 +77,7 @@ def login_pin(profil_id): session["profil_id"] = profil["id"] session["vorname"] = profil["vorname"] session["tier"] = profil["tier"] + session["rolle"] = profil["rolle"] or "schueler" return redirect(url_for("profil.cockpit")) else: fehlversuch_loggen(profil_id) @@ -681,8 +682,9 @@ def projekt_werkstatt_speichern(projekt_id): 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 +# Max-Upload-Groesse (60 MB) โ€” Bambu-3MF mit mehreren Platten + Thumbnails +# werden schnell gross. Wer noch groesser braucht, nutzt einen externen Link. +MAX_UPLOAD_BYTES = 60 * 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: @@ -741,6 +743,41 @@ def projekt_datei_upload(projekt_id): return jsonify(ok=True, id=neue_id, art=art, groesse=len(blob)) +@bp.route("/cockpit/projekt//link", methods=["POST"]) +@login_required +def projekt_link_hinzufuegen(projekt_id): + """Externen Link als 'Datei' hinzufuegen (GitHub, MakerWorld, gehostete 3MF, ...).""" + 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 koennen keine Links mehr aufnehmen.", "error") + return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id)) + + name = (request.form.get("dateiname") or "").strip() + url = (request.form.get("extern_url") or "").strip() + if not url: + flash("Bitte eine URL angeben.", "error") + return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id)) + if not (url.startswith("http://") or url.startswith("https://")): + flash("Der Link muss mit http:// oder https:// beginnen.", "error") + return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id)) + if not name: + name = url # Fallback: URL als Anzeigename + + conn = get_db() + conn.execute( + "INSERT INTO projekt_datei (projekt_id, dateiname, extern_url, art, hochgeladen_von) " + "VALUES (?, ?, ?, 'link', ?)", + (projekt_id, name, url, session["profil_id"]) + ) + conn.commit() + conn.close() + flash("Link hinzugefuegt.", "success") + return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id)) + + @bp.route("/cockpit/projekt//datei//loeschen", methods=["POST"]) @login_required def projekt_datei_loeschen(projekt_id, datei_id): @@ -845,7 +882,11 @@ def projekt_abschluss(projekt_id): if ausfuehrung and ausfuehrung not in EVAL_QUALITAET: ausfuehrung = None - neuer_freigabe_status = freigabe_nach_eval(gesamt) + # Power-User-Projekte (Demos) gehen direkt in den Katalog, kein Lehrer-Review. + if session.get("rolle") == "power": + neuer_freigabe_status = "freigegeben" + else: + neuer_freigabe_status = freigabe_nach_eval(gesamt) conn = get_db() conn.execute( diff --git a/scripts/seed_demo_parol6.py b/scripts/seed_demo_parol6.py new file mode 100644 index 0000000..f3cbcd7 --- /dev/null +++ b/scripts/seed_demo_parol6.py @@ -0,0 +1,162 @@ +"""Seed: Power-Profil 'Herb' + PAROL6-Demo-Projekt. + +Legt (idempotent) an: +1. Ein Power-User-Profil 'Herb' (rolle='power', kein Tier). PIN wird beim + ersten Anlegen generiert + ausgegeben; bei spaeteren Laeufen unveraendert. +2. Das Demo-Projekt 'PAROL6 โ€” 6-Achsen-Roboterarm': + - Macher: Herb (Power) + - Drucker: P1S #1 + - Status: abgeschlossen + freigegeben (Power -> direkt im Katalog, als Vorlage waehlbar) + - Cover: aus der 3MF extrahiertes Render (thumbnail_middle.png) + - Dateien: GitHub-Repo (Link) + Bauanleitung-PDF (BLOB) + - Die 3MF selbst wird NICHT gespeichert (nur verlinkt via GitHub-STLs). + +Aufruf: + python scripts/seed_demo_parol6.py <3mf-pfad> + +Das 3MF wird nur zum Cover-Extrahieren gelesen, nicht als BLOB abgelegt. +""" +import sys +import zipfile +import secrets +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from database import get_db +from services.auth import pin_hashen + +GITHUB_URL = "https://github.com/PCrnjak/PAROL6-Desktop-robot-arm" +PROJEKT_TITEL = "PAROL6 โ€” 6-Achsen-Roboterarm" +AG_ID = "lessing-3d-ag" + +BESCHREIBUNG = """Ein **6-Achsen-Roboterarm zum Selberbauen** โ€” Open Source, komplett 3D-druckbar. + +Das **PAROL6** ist ein Roboterarm fuer den Schreibtisch mit 6 Bewegungsachsen โ€” so wie die +grossen Industrie-Roboter, nur klein. Entwickelt von Petar Crnjak, Lizenz GPL-3.0 (frei nutzbar). + +## Was dazugehoert +- **3D-Druck:** alle Teile sind druckbar (hier vorbereitet fuer den P1S). Die STLs liegen im GitHub-Repo. +- **Elektronik:** Schrittmotoren + eine eigene Steuerplatine (muss bestellt werden). +- **Steuerung:** ueber eine Python-API, optional mit ROS2/MoveIt simulierbar. + +## Schwierigkeit: anspruchsvoll +Das ist ein **Langzeit-Projekt**. Drucken ist nur der erste Schritt โ€” danach kommen Zusammenbau, +Elektronik und Programmierung. Nichts fuer einen Nachmittag, aber ein tolles Ziel, wenn du tiefer +einsteigen willst. + +## Quellen +- **Projektseite (GitHub):** alle Dateien, Stueckliste, Doku โ€” siehe Link unten. +- **Bauanleitung:** das angehaengte PDF (81 Seiten).""" + +COVER_KANDIDATEN = [ + "Auxiliaries/.thumbnails/thumbnail_middle.png", + "Metadata/top_1.png", + "Metadata/plate_1.png", +] + + +def hole_cover(dreimf_pfad: str): + """Extrahiert das beste Render aus der 3MF. Returns (blob, mime) oder (None, None).""" + try: + with zipfile.ZipFile(dreimf_pfad) as z: + namen = set(z.namelist()) + for kandidat in COVER_KANDIDATEN: + if kandidat in namen: + return (z.read(kandidat), "image/png") + except Exception as e: + print(f" [warn] Cover-Extraktion fehlgeschlagen: {e}") + return (None, None) + + +def hole_pdf(pdf_pfad: str): + """Liest das PDF als BLOB. Returns (blob, dateiname) oder (None, None).""" + try: + with open(pdf_pfad, "rb") as f: + return (f.read(), Path(pdf_pfad).name) + except Exception as e: + print(f" [warn] PDF-Lesen fehlgeschlagen: {e}") + return (None, None) + + +def main(): + if len(sys.argv) < 3: + print("Aufruf: python scripts/seed_demo_parol6.py <3mf-pfad> ") + sys.exit(1) + dreimf_pfad, pdf_pfad = sys.argv[1], sys.argv[2] + + conn = get_db() + + # --- 1. Power-Profil 'Herb' --- + herb = conn.execute( + "SELECT id, vorname, rolle FROM profil WHERE vorname = ? AND rolle = 'power'", + ("Herb",) + ).fetchone() + if herb: + herb_id = herb["id"] + print(f"[seed] Power-Profil 'Herb' existiert bereits (id={herb_id}).") + else: + pin = f"{secrets.randbelow(10000):04d}" + cur = conn.execute( + "INSERT INTO profil (vorname, tier, pin_hash, ag_id, aktiv, rolle) " + "VALUES (?, 'power', ?, ?, 1, 'power')", + ("Herb", pin_hashen(pin), AG_ID) + ) + conn.commit() + herb_id = cur.lastrowid + print(f"[seed] Power-Profil 'Herb' angelegt (id={herb_id}). PIN: {pin} <-- AUFSCHREIBEN!") + + # --- 2. PAROL6-Projekt --- + schon = conn.execute( + "SELECT id FROM projekt WHERE profil_id = ? AND titel = ?", + (herb_id, PROJEKT_TITEL) + ).fetchone() + if schon: + print(f"[seed] Projekt '{PROJEKT_TITEL}' existiert bereits (id={schon['id']}) โ€” nichts zu tun.") + conn.close() + return + + # Drucker P1S #1 + drucker = conn.execute("SELECT id FROM drucker WHERE name = 'P1S #1'").fetchone() + drucker_id = drucker["id"] if drucker else None + + cover_blob, cover_mime = hole_cover(dreimf_pfad) + + cur = conn.execute( + """INSERT INTO projekt + (profil_id, titel, beschreibung_md, status_lebenszyklus, abgeschlossen_am, + selbsteinschaetzung, selbsteinschaetzung_planung, selbsteinschaetzung_ausfuehrung, + was_gelernt, freigabe_status, freigegeben_am, sichtbar_in_ag, + drucker_id_used, cover_blob, cover_mime) + VALUES (?, ?, ?, 'abgeschlossen', CURRENT_TIMESTAMP, + 'zufrieden', 'gut', 'gut', + 'Ein anspruchsvolles Open-Source-Projekt als Inspiration fuer Fortgeschrittene.', + 'freigegeben', CURRENT_TIMESTAMP, 1, + ?, ?, ?)""", + (herb_id, PROJEKT_TITEL, BESCHREIBUNG, drucker_id, cover_blob, cover_mime) + ) + projekt_id = cur.lastrowid + + # GitHub-Link als "Datei" + conn.execute( + "INSERT INTO projekt_datei (projekt_id, dateiname, extern_url, art, hochgeladen_von) " + "VALUES (?, ?, ?, 'link', ?)", + (projekt_id, "PAROL6-Projekt auf GitHub (STLs + Doku)", GITHUB_URL, herb_id) + ) + + # PDF als BLOB + pdf_blob, pdf_name = hole_pdf(pdf_pfad) + if pdf_blob: + conn.execute( + "INSERT INTO projekt_datei (projekt_id, dateiname, mime_type, groesse_bytes, inhalt, art, hochgeladen_von) " + "VALUES (?, ?, 'application/pdf', ?, ?, 'sonstiges', ?)", + (projekt_id, "Bauanleitung (81 Seiten, PDF)", len(pdf_blob), pdf_blob, herb_id) + ) + + conn.commit() + conn.close() + print(f"[seed] Projekt '{PROJEKT_TITEL}' angelegt (id={projekt_id}).") + print(f" Cover: {'ja' if cover_blob else 'NEIN'} | PDF: {'ja ('+str(len(pdf_blob)//1024)+' KB)' if pdf_blob else 'NEIN'} | GitHub-Link: ja | Drucker: P1S #1") + + +if __name__ == "__main__": + main() diff --git a/services/auth.py b/services/auth.py index 1fca5fb..485656d 100644 --- a/services/auth.py +++ b/services/auth.py @@ -123,6 +123,30 @@ def admin_required(f): return wrapper +def ist_power() -> bool: + """True, wenn der eingeloggte Nutzer ein Power-User-Profil ist.""" + return session.get("rolle") == "power" + + +def power_oder_admin(f): + """Erlaubt Admin-Session ODER eingeloggtes Power-User-Profil. + + Fuer Bereiche, die fortgeschrittene SuS mitverwalten duerfen + (Drucker, Software-Katalog, Slots fuer andere) โ€” NICHT fuer + SuS-Verwaltung oder Projekt-Freigaben. + """ + @wraps(f) + def wrapper(*args, **kwargs): + if session.get("admin") or ist_power(): + return f(*args, **kwargs) + # Power-Profil eingeloggt aber falsche Rolle, oder gar nicht eingeloggt + if session.get("profil_id"): + flash("Dafuer fehlen dir die Rechte.", "error") + return redirect(url_for("profil.cockpit")) + return redirect(url_for("admin.admin_login")) + return wrapper + + def onboarding_l1_fertig(f): """V2: blockiert Slot-Routes, bis L1 (Sicherheits-Lektion) abgeschlossen ist.""" @wraps(f) diff --git a/services/projekte.py b/services/projekte.py index 89ea791..78e5a0b 100644 --- a/services/projekte.py +++ b/services/projekte.py @@ -49,13 +49,23 @@ def letztes_projekt(conn, profil_id: int): ).fetchone() +def ist_power_profil(conn, profil_id: int) -> bool: + """True, wenn das Profil die Rolle 'power' hat.""" + row = conn.execute("SELECT rolle FROM profil WHERE id = ?", (profil_id,)).fetchone() + return bool(row and row["rolle"] == "power") + + def darf_neues_anlegen(conn, profil_id: int): - """Prueft, ob der SuS aktuell ein neues Projekt anlegen darf. + """Prueft, ob der Nutzer aktuell ein neues Projekt anlegen darf. + + Power-User duerfen IMMER (mehrere Demo-Projekte parallel). Fuer normale + SuS gilt die 1-Projekt-Regel + Freigabe-Gate. Returns: (darf: bool, grund: str | None) - grund ist der Sperr-Text fuer die UI, wenn darf=False. """ + if ist_power_profil(conn, profil_id): + return (True, None) 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) @@ -104,11 +114,11 @@ def projekt_holen(conn, projekt_id: int): def dateien_eines_projekts(conn, projekt_id: int): - """Alle hochgeladenen Dateien eines Projekts (ohne BLOB-Inhalt โ€” nur Metadaten).""" + """Alle Dateien + Links eines Projekts (ohne BLOB-Inhalt โ€” nur Metadaten).""" return conn.execute( - "SELECT id, dateiname, mime_type, groesse_bytes, art, hochgeladen_am " + "SELECT id, dateiname, mime_type, groesse_bytes, art, extern_url, hochgeladen_am " "FROM projekt_datei WHERE projekt_id = ? " - "ORDER BY hochgeladen_am ASC", + "ORDER BY (extern_url IS NOT NULL) DESC, hochgeladen_am ASC", (projekt_id,) ).fetchall() @@ -317,11 +327,14 @@ def vorlage_info(conn, vorlage_id: int): def projekt_dateien_fuer_katalog(conn, projekt_id: int): - """Nur die Metadaten fuer die Datei-Liste im Katalog-Detail (keine BLOBs).""" + """Nur die Metadaten fuer die Datei-Liste im Katalog-Detail (keine BLOBs). + + Links (extern_url) zuerst, dann Uploads. + """ return conn.execute( - "SELECT id, dateiname, mime_type, groesse_bytes, art " + "SELECT id, dateiname, mime_type, groesse_bytes, art, extern_url " "FROM projekt_datei WHERE projekt_id = ? " - "ORDER BY art, dateiname", + "ORDER BY (extern_url IS NOT NULL) DESC, art, dateiname", (projekt_id,) ).fetchall() diff --git a/templates/admin_drucker.html b/templates/admin_drucker.html index d6638fe..16986a6 100644 --- a/templates/admin_drucker.html +++ b/templates/admin_drucker.html @@ -6,7 +6,8 @@

Drucker-Verwaltung

SuS-Ansicht - ← Dashboard + {% if session.admin %}← Dashboard + {% else %}← Cockpit{% endif %}
diff --git a/templates/admin_profile.html b/templates/admin_profile.html index 743bf30..9c3ecf7 100644 --- a/templates/admin_profile.html +++ b/templates/admin_profile.html @@ -13,14 +13,21 @@

Neues Profil anlegen

+ style="display: grid; grid-template-columns: 1.2fr 1fr 1.4fr 1fr auto; gap: 0.6rem; align-items: end;">
+ + +
+
- {% for t in tiere %} @@ -39,7 +46,25 @@

Eine 4-stellige PIN wird automatisch erzeugt und nach dem Speichern einmal angezeigt. + Power-User (fortgeschrittene SuS oder du selbst) brauchen kein Tier und duerfen + Drucker + Software verwalten + Slots fuer andere anlegen + mehrere Projekte fuehren.

+
@@ -62,9 +87,11 @@ {% for p in profile %} - {{ tier_emoji(p.tier) }} - {{ p.vorname }} - {{ tier_name(p.tier) }} + {% if p.rolle == 'power' %}๐Ÿ”ง{% else %}{{ tier_emoji(p.tier) }}{% endif %} + {{ p.vorname }} + {% if p.rolle == 'power' %}Power{% endif %} + + {% if p.rolle == 'power' %}โ€”{% else %}{{ tier_name(p.tier) }}{% endif %} {{ p.ag_name or p.ag_id }} {% if p.aktiv %}aktiv{% else %}inaktiv{% endif %} diff --git a/templates/admin_slots.html b/templates/admin_slots.html index f11948a..b6e86c6 100644 --- a/templates/admin_slots.html +++ b/templates/admin_slots.html @@ -6,7 +6,8 @@

Slot-Uebersicht

+ Neuer Slot fuer SuS - ← Dashboard + {% if session.admin %}← Dashboard + {% else %}← Cockpit{% endif %}
diff --git a/templates/admin_software.html b/templates/admin_software.html index 84f3158..3456521 100644 --- a/templates/admin_software.html +++ b/templates/admin_software.html @@ -5,7 +5,8 @@

Software-Karten

Oeffentliche Seite ansehen - Admin-Logout + {% if session.admin %}Admin-Logout + {% else %}← Cockpit{% endif %}
diff --git a/templates/cockpit.html b/templates/cockpit.html index 03e664a..dd81124 100644 --- a/templates/cockpit.html +++ b/templates/cockpit.html @@ -53,7 +53,25 @@ {% endfor %} -{# ====== Check-In-Modal โ€” wenn heute noch nichts gewaehlt wurde ====== #} +{# ====== POWER-WERKZEUGE (nur fuer Power-User) ====== #} +{% if session.rolle == 'power' %} +
+

๐Ÿ”ง Power-Werkzeuge

+

+ Du bist Power-User โ€” hier deine Verwaltungs-Bereiche. SuS-Profile und Projekt-Freigaben + bleiben dem Lehrer-Admin vorbehalten. +

+ +
+{% endif %} + +{# ====== Check-In-Modal โ€” wenn heute noch nichts gewaehlt wurde (nicht fuer Power) ====== #} +{% if session.rolle != 'power' %} {% if not status.check_in_heute %}

Was machst du heute? ๐ŸŽฏ

@@ -117,6 +135,7 @@
{% endif %} +{% endif %}{# Ende: Check-In nur fuer Nicht-Power #} {# ====== PROJEKT-KARTE โ€” ganz oben, damit "Hier weitermachen" sofort sichtbar ist ====== #} {% if status.hat_projekte or not status.ist_neu or status.projekt.letztes %} diff --git a/templates/login.html b/templates/login.html index de08d99..811f263 100644 --- a/templates/login.html +++ b/templates/login.html @@ -10,10 +10,17 @@ {% if profile %} @@ -40,5 +47,9 @@ transform: translateY(-3px); box-shadow: 0 6px 20px rgba(0,0,0,0.12); } +.profil-power { + border-color: var(--primary); + background: linear-gradient(180deg, #fff, #EBF5FB); +} {% endblock %} diff --git a/templates/projekt_detail.html b/templates/projekt_detail.html index a0c7638..08f7ee8 100644 --- a/templates/projekt_detail.html +++ b/templates/projekt_detail.html @@ -217,22 +217,26 @@ {% if dateien %} - + {% for d in dateien %} @@ -241,7 +245,7 @@
DateiTypGroesse
Datei / LinkTypGroesse
- {{ d.dateiname }} + {% if d.extern_url %} + ๐Ÿ”— {{ d.dateiname }} โ†— + {% else %} + ๐Ÿ“Ž {{ d.dateiname }} + {% endif %} {{ d.art }} - {{ (d.groesse_bytes // 1024) if d.groesse_bytes else 0 }} KB + {% if d.extern_url %}โ€”{% else %}{{ (d.groesse_bytes // 1024) if d.groesse_bytes else 0 }} KB{% endif %}
- + onsubmit="return confirm('{{ d.dateiname }} entfernen?')"> +
{% else %}

- Noch keine Dateien. + Noch keine Dateien oder Links.

{% endif %} @@ -250,13 +254,29 @@ padding: 1.5rem; text-align: center; cursor: pointer; background: #fafafa;">
๐Ÿ“ Dateien hier ablegen
- oder klicken โ€” STL, 3MF, JPG, PNG ยท max. 30 MB pro Datei + oder klicken โ€” STL, 3MF, JPG, PNG ยท max. 60 MB pro Datei
+ + {# Link statt Upload #} +
+
+ + +
+
+ + +
+ +
{% endif %} diff --git a/templates/projekte_katalog.html b/templates/projekte_katalog.html index dbd8fb0..472c7e6 100644 --- a/templates/projekte_katalog.html +++ b/templates/projekte_katalog.html @@ -144,14 +144,20 @@ {% if p.dateien %} {% endif %}