Externe Datei-Links + Power-User-Rolle + PAROL6-Demo

Block A — Externe Links:
  - projekt_datei.extern_url: eine "Datei" kann Upload (BLOB) ODER Link sein
  - Link-Route /cockpit/projekt/<id>/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) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 17:12:45 +02:00
parent 3482356c13
commit ae68da996f
16 changed files with 474 additions and 58 deletions

View File

@@ -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,

View File

@@ -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()

View File

@@ -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()

View File

@@ -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/<int:slot_id>/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/<int:karte_id>/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/<int:karte_id>/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/<int:karte_id>/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/<int:karte_id>/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/<int:karte_id>/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/<int:drucker_id>/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/<int:drucker_id>/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

View File

@@ -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/<int:projekt_id>/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/<int:projekt_id>/datei/<int:datei_id>/loeschen", methods=["POST"])
@login_required
def projekt_datei_loeschen(projekt_id, datei_id):
@@ -845,6 +882,10 @@ def projekt_abschluss(projekt_id):
if ausfuehrung and ausfuehrung not in EVAL_QUALITAET:
ausfuehrung = None
# 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()

162
scripts/seed_demo_parol6.py Normal file
View File

@@ -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> <pdf-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> <pdf-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()

View File

@@ -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)

View File

@@ -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()

View File

@@ -6,7 +6,8 @@
<h1>Drucker-Verwaltung</h1>
<div>
<a href="{{ url_for('oeffentlich.drucker') }}" class="btn btn-secondary">SuS-Ansicht</a>
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-secondary">&larr; Dashboard</a>
{% if session.admin %}<a href="{{ url_for('admin.dashboard') }}" class="btn btn-secondary">&larr; Dashboard</a>
{% else %}<a href="{{ url_for('profil.cockpit') }}" class="btn btn-secondary">&larr; Cockpit</a>{% endif %}
</div>
</div>

View File

@@ -13,14 +13,21 @@
<div class="card">
<h2>Neues Profil anlegen</h2>
<form method="post" action="{{ url_for('admin.profil_neu') }}"
style="display: grid; grid-template-columns: 1fr 1fr 1fr auto; gap: 0.6rem; align-items: end;">
style="display: grid; grid-template-columns: 1.2fr 1fr 1.4fr 1fr auto; gap: 0.6rem; align-items: end;">
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">Vorname</label>
<input type="text" name="vorname" required style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
</div>
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">Rolle</label>
<select name="rolle" id="rolle-select" style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
<option value="schueler">Schueler</option>
<option value="power">🔧 Power-User</option>
</select>
</div>
<div id="tier-feld">
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">Tier-Icon</label>
<select name="tier" required style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
<select name="tier" id="tier-select" style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
<option value="">— waehlen —</option>
{% for t in tiere %}
<option value="{{ t.slug }}">{{ t.emoji }} {{ t.name }}</option>
@@ -39,7 +46,25 @@
</form>
<p style="margin-top: 0.8rem; font-size: 0.85rem; color: var(--text-light);">
Eine 4-stellige PIN wird automatisch erzeugt und nach dem Speichern <strong>einmal</strong> angezeigt.
<strong>Power-User</strong> (fortgeschrittene SuS oder du selbst) brauchen kein Tier und duerfen
Drucker + Software verwalten + Slots fuer andere anlegen + mehrere Projekte fuehren.
</p>
<script>
// Tier-Auswahl ausblenden, wenn Power-User gewaehlt wird
(function() {
var rolle = document.getElementById('rolle-select');
var tierFeld = document.getElementById('tier-feld');
var tierSelect = document.getElementById('tier-select');
function sync() {
var istPower = rolle.value === 'power';
tierFeld.style.opacity = istPower ? '0.4' : '1';
tierSelect.required = !istPower;
tierSelect.disabled = istPower;
}
rolle.addEventListener('change', sync);
sync();
})();
</script>
</div>
<!-- Liste -->
@@ -62,9 +87,11 @@
<tbody>
{% for p in profile %}
<tr style="{% if not p.aktiv %}opacity: 0.5;{% endif %}">
<td style="font-size: 1.5rem;">{{ tier_emoji(p.tier) }}</td>
<td><strong>{{ p.vorname }}</strong></td>
<td>{{ tier_name(p.tier) }}</td>
<td style="font-size: 1.5rem;">{% if p.rolle == 'power' %}🔧{% else %}{{ tier_emoji(p.tier) }}{% endif %}</td>
<td><strong>{{ p.vorname }}</strong>
{% if p.rolle == 'power' %}<span class="badge badge-mittel" style="margin-left: 0.3rem;">Power</span>{% endif %}
</td>
<td>{% if p.rolle == 'power' %}<span style="color: var(--text-light);"></span>{% else %}{{ tier_name(p.tier) }}{% endif %}</td>
<td>{{ p.ag_name or p.ag_id }}</td>
<td>{% if p.aktiv %}<span class="badge badge-anfaenger">aktiv</span>{% else %}<span class="badge">inaktiv</span>{% endif %}</td>
<td>

View File

@@ -6,7 +6,8 @@
<h1>Slot-Uebersicht</h1>
<div>
<a href="{{ url_for('admin.slot_neu_lehrer') }}" class="btn btn-primary">+ Neuer Slot fuer SuS</a>
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-secondary">&larr; Dashboard</a>
{% if session.admin %}<a href="{{ url_for('admin.dashboard') }}" class="btn btn-secondary">&larr; Dashboard</a>
{% else %}<a href="{{ url_for('profil.cockpit') }}" class="btn btn-secondary">&larr; Cockpit</a>{% endif %}
</div>
</div>

View File

@@ -5,7 +5,8 @@
<h1>Software-Karten</h1>
<div>
<a href="{{ url_for('oeffentlich.software') }}" class="btn btn-secondary">Oeffentliche Seite ansehen</a>
<a href="{{ url_for('admin.admin_logout') }}" class="btn btn-secondary">Admin-Logout</a>
{% if session.admin %}<a href="{{ url_for('admin.admin_logout') }}" class="btn btn-secondary">Admin-Logout</a>
{% else %}<a href="{{ url_for('profil.cockpit') }}" class="btn btn-secondary">&larr; Cockpit</a>{% endif %}
</div>
</div>

View File

@@ -53,7 +53,25 @@
</div>
{% endfor %}
{# ====== Check-In-Modal — wenn heute noch nichts gewaehlt wurde ====== #}
{# ====== POWER-WERKZEUGE (nur fuer Power-User) ====== #}
{% if session.rolle == 'power' %}
<div class="card" style="border-left: 4px solid var(--primary);">
<h2>🔧 Power-Werkzeuge</h2>
<p style="color: var(--text-light); font-size: 0.9rem; margin-bottom: 0.8rem;">
Du bist Power-User — hier deine Verwaltungs-Bereiche. SuS-Profile und Projekt-Freigaben
bleiben dem Lehrer-Admin vorbehalten.
</p>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
<a href="{{ url_for('admin.admin_drucker') }}" class="btn btn-secondary">🖨️ Drucker verwalten</a>
<a href="{{ url_for('admin.software_liste') }}" class="btn btn-secondary">🖥️ Software-Katalog</a>
<a href="{{ url_for('admin.slot_neu_lehrer') }}" class="btn btn-secondary">🔖 Slot fuer SuS</a>
<a href="{{ url_for('profil.projekt_neu') }}" class="btn btn-primary">+ Projekt anlegen</a>
</div>
</div>
{% endif %}
{# ====== Check-In-Modal — wenn heute noch nichts gewaehlt wurde (nicht fuer Power) ====== #}
{% if session.rolle != 'power' %}
{% if not status.check_in_heute %}
<div class="card" style="border-top: 4px solid var(--primary);">
<h2>Was machst du heute? 🎯</h2>
@@ -117,6 +135,7 @@
</form>
</div>
{% 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 %}

View File

@@ -10,10 +10,17 @@
{% if profile %}
<div class="grid" style="grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; max-width: 900px; margin: 0 auto;">
{% for p in profile %}
<a href="{{ url_for('profil.login_pin', profil_id=p.id) }}" class="card profil-kachel">
<a href="{{ url_for('profil.login_pin', profil_id=p.id) }}"
class="card profil-kachel{% if p.rolle == 'power' %} profil-power{% endif %}">
{% if p.rolle == 'power' %}
<div style="font-size: 3rem;">🔧</div>
<div style="font-weight: 600; margin-top: 0.3rem;">{{ p.vorname }}</div>
<div style="font-size: 0.8rem; color: var(--text-light);">Power-User</div>
{% else %}
<div style="font-size: 3rem;">{{ tier_emoji(p.tier) }}</div>
<div style="font-weight: 600; margin-top: 0.3rem;">{{ p.vorname }}</div>
<div style="font-size: 0.8rem; color: var(--text-light);">{{ tier_name(p.tier) }}</div>
{% endif %}
</a>
{% endfor %}
</div>
@@ -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);
}
</style>
{% endblock %}

View File

@@ -217,22 +217,26 @@
{% if dateien %}
<table style="margin-top: 0.5rem;">
<thead>
<tr><th>Datei</th><th>Typ</th><th>Groesse</th><th></th></tr>
<tr><th>Datei / Link</th><th>Typ</th><th>Groesse</th><th></th></tr>
</thead>
<tbody id="dateien-tbody">
{% for d in dateien %}
<tr>
<td>
<a href="{{ url_for('api.projekt_datei_download', projekt_id=p.id, datei_id=d.id) }}">{{ d.dateiname }}</a>
{% if d.extern_url %}
🔗 <a href="{{ d.extern_url }}" target="_blank" rel="noopener noreferrer">{{ d.dateiname }} ↗</a>
{% else %}
📎 <a href="{{ url_for('api.projekt_datei_download', projekt_id=p.id, datei_id=d.id) }}">{{ d.dateiname }}</a>
{% endif %}
</td>
<td><span class="badge">{{ d.art }}</span></td>
<td style="font-size: 0.85rem; color: var(--text-light);">
{{ (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 %}
</td>
<td>
<form method="post" action="{{ url_for('profil.projekt_datei_loeschen', projekt_id=p.id, datei_id=d.id) }}" style="display:inline;"
onsubmit="return confirm('Datei {{ d.dateiname }} loeschen?')">
<button class="btn btn-secondary" style="padding: 0.2rem 0.5rem; font-size: 0.8rem; color: var(--danger);">loeschen</button>
onsubmit="return confirm('{{ d.dateiname }} entfernen?')">
<button class="btn btn-secondary" style="padding: 0.2rem 0.5rem; font-size: 0.8rem; color: var(--danger);">entfernen</button>
</form>
</td>
</tr>
@@ -241,7 +245,7 @@
</table>
{% else %}
<p style="color: var(--text-light); margin-top: 0.5rem;">
<em>Noch keine Dateien.</em>
<em>Noch keine Dateien oder Links.</em>
</p>
{% endif %}
@@ -250,13 +254,29 @@
padding: 1.5rem; text-align: center; cursor: pointer; background: #fafafa;">
<div style="font-size: 1.1rem; color: var(--text-light);">📁 Dateien hier ablegen</div>
<div style="font-size: 0.85rem; color: var(--text-light); margin-top: 0.3rem;">
oder klicken — STL, 3MF, JPG, PNG · max. 30 MB pro Datei
oder klicken — STL, 3MF, JPG, PNG · max. 60 MB pro Datei
</div>
<input type="file" id="datei-input" multiple
accept=".stl,.3mf,image/jpeg,image/png,image/webp"
style="display: none;">
<div id="upload-status" style="margin-top: 0.5rem; font-size: 0.85rem;"></div>
</div>
{# Link statt Upload #}
<form method="post" action="{{ url_for('profil.projekt_link_hinzufuegen', projekt_id=p.id) }}"
style="margin-top: 0.8rem; display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: flex-end;">
<div style="flex: 1; min-width: 140px;">
<label style="display:block; font-size: 0.8rem; color: var(--text-light);">Oder Link statt Datei — Name</label>
<input type="text" name="dateiname" maxlength="120" placeholder="z.B. Modell auf GitHub"
style="padding: 0.4rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
</div>
<div style="flex: 2; min-width: 200px;">
<label style="display:block; font-size: 0.8rem; color: var(--text-light);">URL</label>
<input type="url" name="extern_url" placeholder="https://..."
style="padding: 0.4rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
</div>
<button class="btn btn-secondary" style="font-size: 0.85rem;">🔗 Link hinzufuegen</button>
</form>
</div>
{% endif %}

View File

@@ -144,14 +144,20 @@
{% if p.dateien %}
<div class="kat-dateien">
<strong style="font-size: 0.85rem;">Dateien:</strong>
<strong style="font-size: 0.85rem;">Dateien &amp; Links:</strong>
{% for d in p.dateien %}
<a href="{{ url_for('api.projekt_datei_download', projekt_id=p.id, datei_id=d.id) }}">
{% if d.extern_url %}
<a href="{{ d.extern_url }}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation();">
🔗 {{ d.dateiname }} ↗
</a>
{% else %}
<a href="{{ url_for('api.projekt_datei_download', projekt_id=p.id, datei_id=d.id) }}" onclick="event.stopPropagation();">
📎 {{ d.dateiname }}
<span style="color: var(--text-light); font-size: 0.75rem;">
({{ d.art }}, {{ (d.groesse_bytes // 1024) if d.groesse_bytes else 0 }} KB)
</span>
</a>
{% endif %}
{% endfor %}
</div>
{% endif %}