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>
355 lines
14 KiB
Python
355 lines
14 KiB
Python
"""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 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 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)
|
|
"""
|
|
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)
|
|
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 Dateien + Links eines Projekts (ohne BLOB-Inhalt — nur Metadaten)."""
|
|
return conn.execute(
|
|
"SELECT id, dateiname, mime_type, groesse_bytes, art, extern_url, hochgeladen_am "
|
|
"FROM projekt_datei WHERE projekt_id = ? "
|
|
"ORDER BY (extern_url IS NOT NULL) DESC, 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).
|
|
|
|
Links (extern_url) zuerst, dann Uploads.
|
|
"""
|
|
return conn.execute(
|
|
"SELECT id, dateiname, mime_type, groesse_bytes, art, extern_url "
|
|
"FROM projekt_datei WHERE projekt_id = ? "
|
|
"ORDER BY (extern_url IS NOT NULL) DESC, 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()
|