Software-Sektion + Drucker-Verwaltung + Slot-System + Selbstlern-Loop

Vier zusammenhaengende Iterationen, die das Funktions-Geruest der AG aufbauen.
Alle 6 Migrationen sind idempotent und legen Schema-Erweiterungen + initiale
Seeds an (Mock-Projekte nur, falls Profil 100 existiert — auf prod nicht).

Software-Sektion (Etappe 3):
  - software_seite-Tabelle: schlanker Karten-Katalog (slug, titel, urls,
    plattform, kategorie, sortierung, Cover-BLOB)
  - 7 Initial-Karten (Bambu Studio/Handy, Printables, MakerWorld,
    TinkerCAD, Onshape, Gridfinity-Generator)
  - Admin-Verwaltung: Liste, Anlegen-Form, Bearbeiten, Aktiv-Toggle,
    Hart-Loeschen, Bild-Upload via Crop-Tool (Strg+V/Drag&Drop, 400x400)

Drucker-Verwaltung (Etappe 5-vorgezogen):
  - drucker.freigegeben + gesperrt_grund (Lehrer-Sperre fuer Reparatur)
  - drucker.manual_url + troubleshooting_url (Override fuer Modell-Defaults)
  - SuS-View /drucker (eingeloggt): Kacheln + Sperre-Banner + Belegung
  - Admin /admin/drucker: Status-Override (idle/printing/failed/...),
    Sperren/Freigeben mit Grund, Inline-Edit fuer URLs

Slot-System / Reservierung (Etappe 7-vorgezogen):
  - profil.reservieren_freigeschaltet (Lehrer schaltet manuell frei,
    ersetzt L1-Gate-Automatismus)
  - services/slots.py: Konflikt-Check, Belegungs-Helper
  - SuS-Routen /cockpit/slot/neu (mit Konflikt-Check), /cockpit/slots,
    Stornieren
  - Admin /admin/slots Uebersicht + /admin/slot/neu (Lehrer reserviert
    fuer SuS) + Hart-Loeschen + Profil-Freigabe-Toggle

Selbstlern-Loop (Etappe 6 — Kern):
  - projekt-Spalten: 3-Achsen-Eval (Gesamt + Planung + Ausfuehrung),
    was_klappte_nicht, cover_blob, modellier_software_slug,
    drucker_id_used, makerworld_url
  - Regel: max. 1 aktives Projekt pro SuS, neues nur nach Freigabe
    (auto bei zufrieden/mittel, Lehrer-Review bei gelernt)
  - SuS-Werkstatt-Sektion: Titel + Beschreibung + Software-Dropdown +
    Drucker-Dropdown + MakerWorld-URL + Files-Upload (drag&drop,
    base64, max 30 MB)
  - Cover-Bild via Crop-Tool (Re-use admin_software_bild-Pattern)
  - Abschluss-Block: Pflicht Cover + mindestens 1 Datei
  - Eval-Form: visuelle 3x3 Radio-Karten + 2 Freitexte
  - Lehrer-Review: /admin/projekte mit Filter (wartet/in_arbeit/
    abgeschlossen/rueckfrage), Aktionen freigeben/rueckfrage/loeschen
  - Oeffentlicher Katalog /projekte: Sortier-Tabelle (Titel/Person/
    Status/Erfolg/Datum), aufklappbare Detail mit Cover/Beschreibung/
    Datei-Downloads/MakerWorld-Link
  - Wiederhol-Logik: "Sowas mache ich auch" via Verknuepfung
    (vorlage_projekt_id), bidirektionale Lineage-Banner

Cockpit-Refactoring:
  - Projekt-Karte ganz oben (vor Onboarding) mit "Hier weitermachen"
  - Check-In-Quick-Actions: nach Auswahl direkt zu /cockpit/projekt/neu,
    /drucker, /lektionen, /cockpit/projekt/<aktiv>
  - "Modus aendern"-Button im Eingecheckt-Banner
  - Admin-Dashboard-Hub mit Karten: SuS-Profile, Software, Lektionen,
    Drucker, Reservierungen, Projekte (Badge bei wartenden), Alph

Bug-Fixes:
  - Admin-Login redirected jetzt auf /admin/dashboard statt /admin/profile
  - app.py: neue Jinja-Filter iso_format + dauer_min
  - .gitignore: .claude/ ausschliessen (lokale launch.json)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 19:04:18 +02:00
parent 66f6dd96dc
commit 8cbd803328
40 changed files with 5721 additions and 55 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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