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

1
.gitignore vendored
View File

@@ -15,6 +15,7 @@ backups/
# Editor
.vscode/
.idea/
.claude/
*.swp
.DS_Store

23
app.py
View File

@@ -104,6 +104,29 @@ import markdown as _markdown
from markupsafe import Markup
@app.template_filter("iso_format")
def iso_format_filter(iso_str, format_str="%H:%M"):
"""Parsed einen ISO-Datetime-String und formatiert ihn (strftime)."""
from datetime import datetime
try:
dt = datetime.fromisoformat(iso_str)
except (TypeError, ValueError):
return iso_str or ""
return dt.strftime(format_str)
@app.template_filter("dauer_min")
def dauer_min_filter(start_iso, ende_iso):
"""Berechnet Dauer in ganzen Minuten zwischen zwei ISO-Datetime-Strings."""
from datetime import datetime
try:
s = datetime.fromisoformat(start_iso)
e = datetime.fromisoformat(ende_iso)
except (TypeError, ValueError):
return ""
return int((e - s).total_seconds() // 60)
@app.template_filter("markdown")
def markdown_filter(text):
"""Rendert Markdown zu HTML und markiert das Ergebnis als 'safe',

View File

@@ -45,6 +45,10 @@ CREATE TABLE IF NOT EXISTS profil (
pin_hash TEXT NOT NULL, -- bcrypt
ag_id TEXT NOT NULL, -- FK ag_mitgliedschaft
aktiv INTEGER DEFAULT 1,
-- Lehrer schaltet SuS manuell zur Drucker-Reservierung frei (ersetzt
-- den geplanten L1-Gate-Automatismus, weil manche SuS zuhause schon
-- drucken oder schneller durch L1 sind als die Doku festhalten kann).
reservieren_freigeschaltet INTEGER DEFAULT 0,
erstellt_am DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(ag_id, vorname, tier),
FOREIGN KEY (ag_id) REFERENCES ag_mitgliedschaft(id)
@@ -99,8 +103,17 @@ CREATE TABLE IF NOT EXISTS projekt (
-- Lebenszyklus
status_lebenszyklus TEXT DEFAULT 'in_arbeit', -- 'in_arbeit'|'abgeschlossen'
abgeschlossen_am DATETIME,
selbsteinschaetzung TEXT, -- 'zufrieden'|'mittel'|'gelernt' (E-015)
was_gelernt TEXT, -- optionaler Freitext
selbsteinschaetzung TEXT, -- 'zufrieden'|'mittel'|'gelernt' (E-015) — Gesamt
selbsteinschaetzung_planung TEXT, -- 'gut'|'ok'|'schwierig' — wie war die Planung?
selbsteinschaetzung_ausfuehrung TEXT, -- 'gut'|'ok'|'schwierig' — wie war die Umsetzung?
was_gelernt TEXT,
was_klappte_nicht TEXT, -- optionaler Freitext, hilft Lehrer beim Review
cover_blob BLOB, -- Cover-Foto (Crop-Tool, 400x400 JPEG)
cover_mime TEXT,
-- Werkstatt-Erfassung (Etappe 6 Phase D, mit Herb 2026-05-25)
modellier_software_slug TEXT, -- FK auf software_seite.slug (welche Hauptsoftware)
drucker_id_used INTEGER, -- FK auf drucker.id (welcher Drucker wurde benutzt)
makerworld_url TEXT, -- optional: SuS hat Projekt zusaetzlich auf MakerWorld veroeffentlicht -- optionaler Freitext
-- Freigabe-Workflow (E-017)
freigabe_status TEXT DEFAULT 'in_arbeit', -- 'in_arbeit'|'wartet_auf_freigabe'|'freigegeben'|'rueckfrage'
freigegeben_am DATETIME,
@@ -195,7 +208,14 @@ CREATE TABLE IF NOT EXISTS drucker (
serial_nr TEXT, -- fuer Cloud-API
geschlossenes_gehaeuse INTEGER DEFAULT 1, -- A1 mini = 0
aktiv INTEGER DEFAULT 1,
notiz TEXT
notiz TEXT,
-- Freigabe-Steuerung (Etappe 5-vorgezogen): Lehrer kann Drucker
-- voruebergehend von Reservierung ausschliessen (Reparatur, Wartung).
freigegeben INTEGER DEFAULT 1, -- 1 = SuS koennen reservieren, 0 = gesperrt
gesperrt_grund TEXT, -- nur relevant wenn freigegeben=0
-- Optional: Drucker-spezifische URL-Overrides ueber dem Modell-Default
manual_url TEXT,
troubleshooting_url TEXT
);
CREATE TABLE IF NOT EXISTS drucker_status (
@@ -264,6 +284,31 @@ CREATE TABLE IF NOT EXISTS lektion (
aktiv INTEGER DEFAULT 1
);
-- ===========================================================================
-- Software-Sektion (Etappe 3 — Karten mit Bild + Kurzbeschreibung + Links)
-- ===========================================================================
-- Wir halten uns absichtlich raus: SuS haben die Software ohnehin auf den
-- Schul-PCs / im Browser und machen eigene Erfahrungen. Wir geben nur die
-- Auswahl vor (Karten-Liste mit Hauptlink + Tutorial-Link, ggf. Store-Links
-- mit QR-Code). Keine Detail-Seite.
CREATE TABLE IF NOT EXISTS software_seite (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT UNIQUE NOT NULL, -- 'bambu-studio', 'gridfinity', ...
titel TEXT NOT NULL,
kurzbeschreibung TEXT NOT NULL, -- 1-2 Saetze, sichtbar auf der Karte
url TEXT NOT NULL, -- Hauptlink (Startseite / Download)
tutorial_url TEXT, -- optionaler deutscher Einstieg
plattform TEXT, -- Klartext: 'Browser', 'Windows/macOS/Linux', ...
app_store_url TEXT, -- nur bei Apps
play_store_url TEXT, -- nur bei Apps
bild_pfad TEXT, -- alt: relativ zu static/img/software/, optional Fallback
bild_blob BLOB, -- per Crop-Tool hochgeladen (Default-Quelle)
bild_mime TEXT, -- z.B. 'image/jpeg'
kategorie TEXT NOT NULL DEFAULT 'software', -- 'software'|'app'|'generator'
sortierung INTEGER DEFAULT 100,
aktiv INTEGER DEFAULT 1
);
-- ===========================================================================
-- Einstellungen (Inline-Edit-Konfig) + Audit-Log
-- ===========================================================================

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

View File

@@ -5,6 +5,7 @@ Etappe 2: Drill-Down-Dashboard (V5).
Etappe 6: Projekt-Review-Queue.
Etappe 7: Druckauftrag-Freigabe + Slot-Priorisierung.
"""
import base64
import secrets
from flask import Blueprint, render_template, request, redirect, url_for, session, flash, jsonify, abort
@@ -24,10 +25,13 @@ ERLAUBTE_INLINE_TABELLEN = {
# Tabelle -> Set erlaubter Felder
"lektion": {"titel", "aufgabe_md", "tipp_md", "extern_link"},
"einstellung": {"wert"},
"drucker": {"name", "notiz"},
"drucker": {"name", "notiz", "manual_url", "troubleshooting_url", "gesperrt_grund"},
"software_seite": {
"titel", "kurzbeschreibung", "url", "tutorial_url",
"plattform", "app_store_url", "play_store_url", "bild_pfad",
},
# spaeter:
# "projekt": {"titel", "beschreibung_md"} -- mit Status-Check, Etappe 6
# "software_seite": {"titel", "inhalt_md", "url", "quickstart_md"} -- Etappe 3
}
@@ -43,7 +47,7 @@ def admin_login():
if admin_passwort_pruefen(eingabe):
session["admin"] = True
flash("Admin-Modus aktiv.", "success")
return redirect(url_for("admin.profile_liste"))
return redirect(url_for("admin.dashboard"))
flash("Falsches Passwort.", "error")
return render_template("admin_login.html")
@@ -65,7 +69,7 @@ def profile_liste():
conn = get_db()
profile = conn.execute(
"SELECT p.id, p.vorname, p.tier, p.aktiv, p.erstellt_am, "
" p.ag_id, ag.name AS ag_name "
" p.ag_id, p.reservieren_freigeschaltet, ag.name AS ag_name "
"FROM profil p "
"LEFT JOIN ag_mitgliedschaft ag ON p.ag_id = ag.id "
"ORDER BY p.aktiv DESC, p.vorname COLLATE NOCASE"
@@ -134,6 +138,29 @@ def profil_aktiv_toggle(profil_id):
return redirect(url_for("admin.profile_liste"))
@bp.route("/profil/<int:profil_id>/reservieren-toggle", methods=["POST"])
@admin_required
def profil_reservieren_toggle(profil_id):
"""Lehrer schaltet die Reservierungs-Freigabe pro SuS um."""
conn = get_db()
conn.execute(
"UPDATE profil SET reservieren_freigeschaltet = "
"CASE reservieren_freigeschaltet WHEN 1 THEN 0 ELSE 1 END WHERE id = ?",
(profil_id,)
)
conn.commit()
row = conn.execute(
"SELECT vorname, reservieren_freigeschaltet FROM profil WHERE id = ?", (profil_id,)
).fetchone()
conn.close()
if row:
if row["reservieren_freigeschaltet"]:
flash(f"{row['vorname']} darf jetzt Drucker reservieren.", "success")
else:
flash(f"Reservierungs-Freigabe fuer {row['vorname']} aufgehoben.", "success")
return redirect(url_for("admin.profile_liste"))
@bp.route("/profil/<int:profil_id>/pin-reset", methods=["POST"])
@admin_required
def profil_pin_reset(profil_id):
@@ -231,6 +258,556 @@ def profile_karten():
return render_template("admin_profile_karten.html", profile=profile)
# ===========================================================================
# Slot-Verwaltung (Iteration B2) — Lehrer reserviert, sieht, loescht
# ===========================================================================
@bp.route("/slots")
@admin_required
def slot_uebersicht():
"""Uebersicht aller bevorstehenden + heute aktiven Slots."""
from datetime import datetime, timedelta
conn = get_db()
# Heute beendete bleiben sichtbar (manche sind erst gerade fertig)
grenze = (datetime.now() - timedelta(hours=2)).isoformat()
slots = conn.execute(
"SELECT s.id, s.drucker_id, s.profil_id, s.start_zeit, s.ende_zeit, "
" s.status, s.notiz, "
" d.name AS drucker_name, d.modell, d.freigegeben, "
" p.vorname, p.tier "
"FROM slot s "
"LEFT JOIN drucker d ON d.id = s.drucker_id "
"LEFT JOIN profil p ON p.id = s.profil_id "
"WHERE s.ende_zeit >= ? "
"ORDER BY s.start_zeit ASC",
(grenze,)
).fetchall()
conn.close()
return render_template("admin_slots.html", slots=slots)
@bp.route("/slot/neu", methods=["GET", "POST"])
@admin_required
def slot_neu_lehrer():
"""Lehrer reserviert im Namen eines SuS — ignoriert das Freischalt-Flag."""
from datetime import datetime, timedelta
from services.slots import (
parse_datetime_local, validiere_zeitraum, konflikt_check
)
conn = get_db()
profile = conn.execute(
"SELECT id, vorname, tier FROM profil WHERE aktiv = 1 "
"ORDER BY vorname COLLATE NOCASE, tier"
).fetchall()
drucker_liste = conn.execute(
"SELECT id, name, modell, freigegeben FROM drucker "
"WHERE aktiv = 1 ORDER BY modell, name"
).fetchall()
jetzt = datetime.now()
default_start = (jetzt + timedelta(minutes=5)).replace(second=0, microsecond=0)
default_ende = default_start + timedelta(hours=1)
if request.method == "POST":
try:
profil_id = int(request.form.get("profil_id") or 0)
drucker_id = int(request.form.get("drucker_id") or 0)
except ValueError:
profil_id = drucker_id = 0
start = parse_datetime_local(request.form.get("start_zeit") or "")
ende = parse_datetime_local(request.form.get("ende_zeit") or "")
notiz = (request.form.get("notiz") or "").strip() or None
# Basis-Validierung
if not profil_id:
conn.close()
flash("Bitte SuS-Profil waehlen.", "error")
return redirect(url_for("admin.slot_neu_lehrer"))
if not drucker_id:
conn.close()
flash("Bitte Drucker waehlen.", "error")
return redirect(url_for("admin.slot_neu_lehrer"))
drucker = conn.execute(
"SELECT id, name, freigegeben FROM drucker WHERE id = ? AND aktiv = 1",
(drucker_id,)
).fetchone()
if not drucker:
conn.close()
flash("Drucker nicht gefunden.", "error")
return redirect(url_for("admin.slot_neu_lehrer"))
# Lehrer darf auch fuer gesperrte Drucker reservieren (z.B. nach Reparatur),
# aber wir warnen kurz via Flash.
if not drucker["freigegeben"]:
flash(f"Achtung: '{drucker['name']}' ist gesperrt — der Slot wird trotzdem angelegt.", "success")
fehler = validiere_zeitraum(start, ende, jetzt)
if fehler:
conn.close()
flash(fehler, "error")
return redirect(url_for("admin.slot_neu_lehrer"))
konflikte = konflikt_check(conn, drucker_id, start, ende)
if konflikte:
k = konflikte[0]
conn.close()
flash(
f"Zeitraum-Konflikt: ein anderer Slot blockiert den Drucker von "
f"{k['start_zeit'][11:16]} bis {k['ende_zeit'][11:16]}.",
"error"
)
return redirect(url_for("admin.slot_neu_lehrer"))
conn.execute(
"INSERT INTO slot (drucker_id, profil_id, start_zeit, ende_zeit, status, notiz) "
"VALUES (?, ?, ?, ?, 'reserviert', ?)",
(drucker_id, profil_id, start.isoformat(), ende.isoformat(), notiz)
)
conn.commit()
conn.close()
flash("Slot angelegt.", "success")
return redirect(url_for("admin.slot_uebersicht"))
# GET — Form
conn.close()
# Vorauswahl via Query-Param (drucker_id und/oder profil_id)
try:
vor_drucker = int(request.args.get("drucker_id", 0))
except ValueError:
vor_drucker = 0
try:
vor_profil = int(request.args.get("profil_id", 0))
except ValueError:
vor_profil = 0
return render_template(
"admin_slot_neu.html",
profile=profile, drucker_liste=drucker_liste,
default_start=default_start, default_ende=default_ende,
vor_drucker=vor_drucker, vor_profil=vor_profil,
)
@bp.route("/slot/<int:slot_id>/loeschen", methods=["POST"])
@admin_required
def slot_loeschen(slot_id):
"""Slot hart loeschen (Lehrer-Override). Ein 'stornieren'-Status haetten
wir auch, aber Lehrer-Aufraeumarbeiten sind oft endgueltig.
"""
conn = get_db()
row = conn.execute(
"SELECT s.id, p.vorname FROM slot s LEFT JOIN profil p ON p.id = s.profil_id WHERE s.id = ?",
(slot_id,)
).fetchone()
if not row:
conn.close()
flash("Slot nicht gefunden.", "error")
return redirect(url_for("admin.slot_uebersicht"))
conn.execute("DELETE FROM slot WHERE id = ?", (slot_id,))
conn.commit()
conn.close()
flash(f"Slot von {row['vorname'] or ''} geloescht.", "success")
return redirect(url_for("admin.slot_uebersicht"))
# ===========================================================================
# Software-Sektion verwalten (Etappe 3)
# ===========================================================================
KATEGORIEN = ("software", "app", "generator")
def _software_clean_form(form):
"""Liest ein Software-Formular und gibt ein dict zurueck.
Leere Strings werden zu None konvertiert (sauberer in der DB).
Wirft ValueError mit deutscher Meldung, wenn Pflichtfelder fehlen
oder die Kategorie unbekannt ist.
"""
def s(name):
v = (form.get(name) or "").strip()
return v or None
slug = s("slug")
titel = s("titel")
kurz = s("kurzbeschreibung")
url = s("url")
kategorie = s("kategorie") or "software"
if not slug:
raise ValueError("Slug fehlt.")
if not titel:
raise ValueError("Titel fehlt.")
if not kurz:
raise ValueError("Kurzbeschreibung fehlt.")
if not url:
raise ValueError("Hauptlink (URL) fehlt.")
if kategorie not in KATEGORIEN:
raise ValueError(f"Kategorie '{kategorie}' unbekannt.")
# Sortierung: leer = ans Ende
try:
sortierung = int(form.get("sortierung") or 100)
except ValueError:
sortierung = 100
return {
"slug": slug,
"titel": titel,
"kurzbeschreibung": kurz,
"url": url,
"tutorial_url": s("tutorial_url"),
"plattform": s("plattform"),
"app_store_url": s("app_store_url"),
"play_store_url": s("play_store_url"),
"bild_pfad": s("bild_pfad"),
"kategorie": kategorie,
"sortierung": sortierung,
}
@bp.route("/software")
@admin_required
def software_liste():
"""Verwaltung der Software-Karten — Liste + Anlegen-Formular auf einer Seite."""
conn = get_db()
karten = conn.execute(
"SELECT id, slug, titel, kurzbeschreibung, url, tutorial_url, plattform, "
" app_store_url, play_store_url, bild_pfad, kategorie, sortierung, aktiv "
"FROM software_seite ORDER BY kategorie, sortierung, titel"
).fetchall()
conn.close()
return render_template("admin_software.html", karten=karten, kategorien=KATEGORIEN)
@bp.route("/software/neu", methods=["POST"])
@admin_required
def software_neu():
try:
daten = _software_clean_form(request.form)
except ValueError as e:
flash(str(e), "error")
return redirect(url_for("admin.software_liste"))
conn = get_db()
schon_da = conn.execute(
"SELECT 1 FROM software_seite WHERE slug = ?", (daten["slug"],)
).fetchone()
if schon_da:
conn.close()
flash(f"Slug '{daten['slug']}' ist bereits vergeben.", "error")
return redirect(url_for("admin.software_liste"))
conn.execute(
"""INSERT INTO software_seite
(slug, titel, kurzbeschreibung, url, tutorial_url,
plattform, app_store_url, play_store_url, bild_pfad,
kategorie, sortierung, aktiv)
VALUES
(:slug, :titel, :kurzbeschreibung, :url, :tutorial_url,
:plattform, :app_store_url, :play_store_url, :bild_pfad,
:kategorie, :sortierung, 1)""",
daten
)
conn.commit()
conn.close()
flash(f"'{daten['titel']}' angelegt.", "success")
return redirect(url_for("admin.software_liste"))
@bp.route("/software/<int:karte_id>/bearbeiten", methods=["GET", "POST"])
@admin_required
def software_bearbeiten(karte_id):
"""Bearbeiten-Seite fuer eine Software-Karte. Gleiches Form-Layout wie /neu,
aber alle Felder vorbefuellt. Slug kann hier NICHT geaendert werden (waere
Inkonsistenz, wenn jemand einen Link teilt) — fuer Slug-Umbenennung muesste
man loeschen + neu anlegen.
"""
conn = get_db()
karte = conn.execute(
"SELECT id, slug, titel, kurzbeschreibung, url, tutorial_url, plattform, "
" app_store_url, play_store_url, bild_pfad, kategorie, sortierung, aktiv, "
" (bild_blob IS NOT NULL) AS hat_bild_blob "
"FROM software_seite WHERE id = ?", (karte_id,)
).fetchone()
conn.close()
if not karte:
flash("Karte nicht gefunden.", "error")
return redirect(url_for("admin.software_liste"))
if request.method == "POST":
try:
# Slug aus der bestehenden Karte uebernehmen (kein Edit), Rest aus Form
form_daten = dict(request.form)
form_daten["slug"] = karte["slug"]
daten = _software_clean_form(form_daten)
except ValueError as e:
flash(str(e), "error")
return redirect(url_for("admin.software_bearbeiten", karte_id=karte_id))
conn = get_db()
conn.execute(
"""UPDATE software_seite SET
titel = :titel,
kurzbeschreibung = :kurzbeschreibung,
url = :url,
tutorial_url = :tutorial_url,
plattform = :plattform,
app_store_url = :app_store_url,
play_store_url = :play_store_url,
bild_pfad = :bild_pfad,
kategorie = :kategorie,
sortierung = :sortierung
WHERE id = :id""",
{**daten, "id": karte_id}
)
conn.commit()
conn.close()
flash(f"'{daten['titel']}' gespeichert.", "success")
return redirect(url_for("admin.software_liste"))
return render_template("admin_software_edit.html", karte=karte, kategorien=KATEGORIEN)
@bp.route("/software/<int:karte_id>/aktiv", methods=["POST"])
@admin_required
def software_aktiv_toggle(karte_id):
conn = get_db()
conn.execute(
"UPDATE software_seite SET aktiv = CASE aktiv WHEN 1 THEN 0 ELSE 1 END WHERE id = ?",
(karte_id,)
)
conn.commit()
conn.close()
return redirect(url_for("admin.software_liste"))
@bp.route("/software/<int:karte_id>/loeschen", methods=["POST"])
@admin_required
def software_loeschen(karte_id):
"""Hart loeschen — wir haben keine FK-Beziehungen zu Software-Karten, das ist sicher."""
conn = get_db()
row = conn.execute(
"SELECT titel FROM software_seite WHERE id = ?", (karte_id,)
).fetchone()
if not row:
conn.close()
flash("Karte nicht gefunden.", "error")
return redirect(url_for("admin.software_liste"))
conn.execute("DELETE FROM software_seite WHERE id = ?", (karte_id,))
conn.commit()
conn.close()
flash(f"'{row['titel']}' geloescht.", "success")
return redirect(url_for("admin.software_liste"))
# ===========================================================================
# Projekt-Review (Selbstlern-Loop, Etappe 6)
# ===========================================================================
@bp.route("/projekte")
@admin_required
def projekte_uebersicht():
"""Alle Projekte aller SuS — mit Status-Filter."""
from services.projekte import alle_projekte, status_zaehler
aktueller_filter = (request.args.get("filter") or "alle").strip()
if aktueller_filter not in ("alle", "wartet", "in_arbeit", "abgeschlossen", "rueckfrage"):
aktueller_filter = "alle"
conn = get_db()
projekte = alle_projekte(conn, None if aktueller_filter == "alle" else aktueller_filter)
zaehler = status_zaehler(conn)
conn.close()
return render_template(
"admin_projekte.html",
projekte=projekte, zaehler=zaehler, aktueller_filter=aktueller_filter
)
@bp.route("/projekt-freigabe")
@admin_required
def projekt_freigabe():
"""Alias auf die Uebersicht mit Filter=wartet (Kompatibilitaet zu alten Links)."""
return redirect(url_for("admin.projekte_uebersicht", filter="wartet"))
@bp.route("/projekt/<int:projekt_id>")
@admin_required
def projekt_review(projekt_id):
"""Detail-Sicht eines Projekts mit Lehrer-Aktionen."""
from services.projekte import projekt_holen
conn = get_db()
p = projekt_holen(conn, projekt_id)
if not p:
conn.close()
flash("Projekt nicht gefunden.", "error")
return redirect(url_for("admin.projekt_freigabe"))
profil = conn.execute(
"SELECT id, vorname, tier FROM profil WHERE id = ?", (p["profil_id"],)
).fetchone()
rueckfragen = conn.execute(
"SELECT id, text, status, erstellt_am, erledigt_am "
"FROM projekt_rueckfrage WHERE projekt_id = ? ORDER BY erstellt_am DESC",
(projekt_id,)
).fetchall()
conn.close()
return render_template(
"admin_projekt_review.html",
p=p, profil=dict(profil) if profil else None,
rueckfragen=rueckfragen,
)
@bp.route("/projekt/<int:projekt_id>/freigeben", methods=["POST"])
@admin_required
def projekt_freigeben(projekt_id):
"""Lehrer setzt freigabe_status = 'freigegeben'."""
conn = get_db()
row = conn.execute(
"SELECT titel FROM projekt WHERE id = ?", (projekt_id,)
).fetchone()
if not row:
conn.close()
flash("Projekt nicht gefunden.", "error")
return redirect(url_for("admin.projekt_freigabe"))
conn.execute(
"UPDATE projekt SET freigabe_status = 'freigegeben', "
"freigegeben_am = CURRENT_TIMESTAMP WHERE id = ?",
(projekt_id,)
)
conn.commit()
conn.close()
flash(f"Projekt '{row['titel']}' freigegeben.", "success")
return redirect(url_for("admin.projekt_freigabe"))
@bp.route("/projekt/<int:projekt_id>/rueckfrage", methods=["POST"])
@admin_required
def projekt_rueckfrage(projekt_id):
"""Lehrer stellt Rueckfrage — SuS sieht sie im Cockpit und kann sie beantworten."""
text = (request.form.get("text") or "").strip()
if not text:
flash("Rueckfrage darf nicht leer sein.", "error")
return redirect(url_for("admin.projekt_review", projekt_id=projekt_id))
conn = get_db()
row = conn.execute(
"SELECT profil_id, titel FROM projekt WHERE id = ?", (projekt_id,)
).fetchone()
if not row:
conn.close()
flash("Projekt nicht gefunden.", "error")
return redirect(url_for("admin.projekt_freigabe"))
conn.execute(
"INSERT INTO projekt_rueckfrage (projekt_id, von_profil_id, text, status) "
"VALUES (?, ?, ?, 'offen')",
(projekt_id, None, text) # von_profil_id NULL = Lehrer (kein eigenes Profil)
)
conn.execute(
"UPDATE projekt SET freigabe_status = 'rueckfrage' WHERE id = ?",
(projekt_id,)
)
conn.commit()
conn.close()
flash(f"Rueckfrage zu '{row['titel']}' an SuS geschickt.", "success")
return redirect(url_for("admin.projekt_freigabe"))
@bp.route("/projekt/<int:projekt_id>/loeschen", methods=["POST"])
@admin_required
def projekt_admin_loeschen(projekt_id):
"""Lehrer loescht ein Projekt hart (z.B. Penismodelle, Copyright).
Audit-Log: in aenderung_log dokumentiert, weil das eine destruktive Aktion
ist, die kein SuS aufrufen kann.
"""
conn = get_db()
row = conn.execute(
"SELECT titel, profil_id FROM projekt WHERE id = ?", (projekt_id,)
).fetchone()
if not row:
conn.close()
flash("Projekt nicht gefunden.", "error")
return redirect(url_for("admin.projekt_freigabe"))
conn.execute(
"INSERT INTO aenderung_log (tabelle, datensatz_id, feld, alter_wert, neuer_wert) "
"VALUES (?, ?, ?, ?, ?)",
("projekt", projekt_id, "DELETE", row["titel"], "")
)
conn.execute("DELETE FROM projekt WHERE id = ?", (projekt_id,))
conn.commit()
conn.close()
flash(f"Projekt '{row['titel']}' geloescht.", "success")
return redirect(url_for("admin.projekt_freigabe"))
# ===========================================================================
# Software-Bild: Crop-Upload + Loeschen
# ===========================================================================
@bp.route("/software/<int:karte_id>/bild", methods=["GET", "POST"])
@admin_required
def software_bild(karte_id):
"""Bild fuer eine Software-Karte hochladen (Paste + Crop, base64 ueber JSON).
GET: zeigt das Crop-Tool (analog admin_bauteil_bild.html aus arduino.lehrstun.de).
POST: erwartet JSON {bild_base64: 'data:image/jpeg;base64,...'}, speichert BLOB.
"""
conn = get_db()
karte = conn.execute(
"SELECT id, slug, titel, kategorie FROM software_seite WHERE id = ?", (karte_id,)
).fetchone()
conn.close()
if not karte:
if request.method == "POST":
return jsonify(error="Karte nicht gefunden"), 404
flash("Karte nicht gefunden.", "error")
return redirect(url_for("admin.software_liste"))
if request.method == "POST":
data = request.get_json(silent=True) or {}
bild_str = data.get("bild_base64")
if not bild_str:
return jsonify(error="Kein Bild empfangen"), 400
# Format: 'data:image/jpeg;base64,/9j/4AAQ...' -> Mime + Roh-Base64 trennen
mime = "image/jpeg"
if "," in bild_str:
header, bild_str = bild_str.split(",", 1)
if ":" in header and ";" in header:
mime = header.split(":")[1].split(";")[0]
try:
blob = base64.b64decode(bild_str)
except Exception:
return jsonify(error="Bild konnte nicht dekodiert werden"), 400
conn = get_db()
conn.execute(
"UPDATE software_seite SET bild_blob = ?, bild_mime = ? WHERE id = ?",
(blob, mime, karte_id)
)
conn.commit()
conn.close()
return jsonify(ok=True)
return render_template("admin_software_bild.html", karte=karte)
@bp.route("/software/<int:karte_id>/bild/loeschen", methods=["POST"])
@admin_required
def software_bild_loeschen(karte_id):
conn = get_db()
conn.execute(
"UPDATE software_seite SET bild_blob = NULL, bild_mime = NULL WHERE id = ?",
(karte_id,)
)
conn.commit()
conn.close()
flash("Bild geloescht.", "success")
return redirect(url_for("admin.software_bearbeiten", karte_id=karte_id))
# ===========================================================================
# Stubs fuer spaetere Etappen
# ===========================================================================
@@ -247,6 +824,10 @@ def dashboard():
wer_haengt_fest,
dashboard_zahlen,
)
from services.projekte import warten_auf_freigabe_anzahl
conn = get_db()
projekte_warten = warten_auf_freigabe_anzahl(conn)
conn.close()
return render_template(
"admin_dashboard.html",
heute=wer_ist_heute_hier(),
@@ -255,6 +836,7 @@ def dashboard():
drucker=drucker_status_alle(),
haengen_fest=wer_haengt_fest(),
zahlen=dashboard_zahlen(),
projekte_warten=projekte_warten,
)
@@ -287,10 +869,96 @@ def admin_lektionen():
@bp.route("/drucker")
@admin_required
def admin_drucker():
abort(501) # Etappe 5
"""Drucker-Verwaltung: Status setzen, Freigabe-Toggle, Links pflegen."""
from services.drucker_links import drucker_links
conn = get_db()
rows = conn.execute(
"SELECT d.id, d.name, d.modell, d.geschlossenes_gehaeuse, d.aktiv, "
" d.notiz, d.freigegeben, d.gesperrt_grund, d.manual_url, d.troubleshooting_url, "
" s.status, s.job_name, s.progress_pct, s.restzeit_min, "
" s.fehlermeldung, s.gepollt_am, s.manuell_gesetzt "
"FROM drucker d "
"LEFT JOIN drucker_status s ON s.drucker_id = d.id "
"ORDER BY d.modell, d.name"
).fetchall()
conn.close()
drucker_view = []
for d in rows:
links = drucker_links(d)
drucker_view.append({**dict(d), "manual": links["manual"], "troubleshooting": links["troubleshooting"]})
return render_template("admin_drucker.html", drucker_liste=drucker_view, status_optionen=DRUCKER_STATUS_OPTIONEN)
@bp.route("/projekt-freigabe")
# Status-Optionen (Auswahl im Lehrer-UI). Manuelle Pflege bis die Bambu-API live ist.
DRUCKER_STATUS_OPTIONEN = (
("idle", "⚪ frei"),
("printing", "🟢 druckt"),
("paused", "🟡 pausiert"),
("finished", "✅ fertig — Druck abholen"),
("failed", "🔴 Fehler"),
("offline", "⚫ offline"),
)
@bp.route("/drucker/<int:drucker_id>/status", methods=["POST"])
@admin_required
def projekt_freigabe():
abort(501) # Etappe 6
def drucker_status_setzen(drucker_id):
"""Lehrer setzt den Status eines Druckers manuell."""
from datetime import datetime
neuer_status = (request.form.get("status") or "").strip()
erlaubt = {s for s, _ in DRUCKER_STATUS_OPTIONEN}
if neuer_status and neuer_status not in erlaubt:
flash(f"Status '{neuer_status}' ungueltig.", "error")
return redirect(url_for("admin.admin_drucker"))
job_name = (request.form.get("job_name") or "").strip() or None
fehlermeldung = (request.form.get("fehlermeldung") or "").strip() or None
conn = get_db()
# Upsert in drucker_status (PRIMARY KEY drucker_id)
existiert = conn.execute(
"SELECT 1 FROM drucker_status WHERE drucker_id = ?", (drucker_id,)
).fetchone()
if existiert:
conn.execute(
"UPDATE drucker_status SET status = ?, job_name = ?, fehlermeldung = ?, "
" manuell_gesetzt = 1, gepollt_am = ? "
"WHERE drucker_id = ?",
(neuer_status or None, job_name, fehlermeldung, datetime.now().isoformat(), drucker_id)
)
else:
conn.execute(
"INSERT INTO drucker_status (drucker_id, status, job_name, fehlermeldung, "
" manuell_gesetzt, gepollt_am) "
"VALUES (?, ?, ?, ?, 1, ?)",
(drucker_id, neuer_status or None, job_name, fehlermeldung, datetime.now().isoformat())
)
conn.commit()
conn.close()
flash("Status aktualisiert.", "success")
return redirect(url_for("admin.admin_drucker"))
@bp.route("/drucker/<int:drucker_id>/freigabe", methods=["POST"])
@admin_required
def drucker_freigabe_toggle(drucker_id):
"""Drucker fuer Reservierung freigeben oder sperren (mit optionalem Grund)."""
neuer_wert = 1 if request.form.get("freigeben") == "1" else 0
grund = (request.form.get("gesperrt_grund") or "").strip() or None
# Wenn freigegeben, Grund leeren — wirkt sauberer
if neuer_wert == 1:
grund = None
conn = get_db()
conn.execute(
"UPDATE drucker SET freigegeben = ?, gesperrt_grund = ? WHERE id = ?",
(neuer_wert, grund, drucker_id)
)
conn.commit()
conn.close()
if neuer_wert == 1:
flash("Drucker freigegeben.", "success")
else:
flash("Drucker gesperrt." + (f" Grund: {grund}" if grund else ""), "success")
return redirect(url_for("admin.admin_drucker"))

View File

@@ -4,11 +4,79 @@ Etappe 5: /api/drucker/status (Live-Status fuer /drucker)
Etappe 6: /api/projekte/suche (Filter-Live-Suche im Katalog)
Etappe 5: /api/slots/heute (Slot-Polling)
"""
from flask import Blueprint, jsonify, abort
import base64
from flask import Blueprint, jsonify, abort, Response
from database import get_db
bp = Blueprint("api", __name__, url_prefix="/api")
# 1x1 transparentes PNG als Default-Bild fuer Karten ohne BLOB
_LEER_PNG = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQI12NgAAIABQABnil7QQAAAAlFTkSuQmCC"
)
@bp.route("/software/<int:karte_id>/bild")
def software_bild(karte_id):
"""Liefert das Bild einer Software-Karte aus (BLOB aus DB).
Wird vom oeffentlichen Karten-Template + vom Admin-Edit-Preview genutzt.
Wenn kein BLOB hinterlegt ist, liefert 1x1-PNG zurueck (kein 404, damit
img-Tags nicht broken sind).
"""
conn = get_db()
row = conn.execute(
"SELECT bild_blob, bild_mime FROM software_seite WHERE id = ?", (karte_id,)
).fetchone()
conn.close()
if not row or not row["bild_blob"]:
return Response(_LEER_PNG, mimetype="image/png")
return Response(row["bild_blob"], mimetype=row["bild_mime"] or "image/jpeg")
@bp.route("/projekt/<int:projekt_id>/datei/<int:datei_id>")
def projekt_datei_download(projekt_id, datei_id):
"""Datei-Download (BLOB-Stream).
Liefert mit Content-Disposition: attachment, damit der Browser einen
Download-Dialog zeigt (statt das STL inline anzuzeigen).
"""
conn = get_db()
row = conn.execute(
"SELECT dateiname, mime_type, inhalt FROM projekt_datei "
"WHERE id = ? AND projekt_id = ?",
(datei_id, projekt_id)
).fetchone()
conn.close()
if not row or not row["inhalt"]:
abort(404)
resp = Response(row["inhalt"], mimetype=row["mime_type"] or "application/octet-stream")
# Dateiname url-sicher kodieren — sonst Stolperfalle bei Umlauten
from urllib.parse import quote
resp.headers["Content-Disposition"] = f'attachment; filename="{quote(row["dateiname"])}"'
return resp
@bp.route("/projekt/<int:projekt_id>/cover")
def projekt_cover(projekt_id):
"""Cover-Foto eines Projekts (BLOB) ausliefern.
Wird vom Cockpit + Katalog + Lehrer-Review genutzt. Wenn kein BLOB
hinterlegt ist, liefert 1x1-PNG, damit img-Tags nicht broken sind.
"""
conn = get_db()
row = conn.execute(
"SELECT cover_blob, cover_mime FROM projekt WHERE id = ?", (projekt_id,)
).fetchone()
conn.close()
if not row or not row["cover_blob"]:
return Response(_LEER_PNG, mimetype="image/png")
return Response(row["cover_blob"], mimetype=row["cover_mime"] or "image/jpeg")
@bp.route("/drucker/status")
def drucker_status():
"""Etappe 5: JSON aller 4 Drucker mit Status/Restzeit."""

View File

@@ -4,7 +4,10 @@ Etappe 1: nur Startseite + Error-Handler portiert.
Etappe 3: Lektionen, Software, Projekt-Katalog, Troubleshooting.
Etappe 5: Drucker-Live-View.
"""
from flask import Blueprint, render_template, abort
from flask import Blueprint, render_template, abort, session
from services.auth import login_required
from services.drucker_links import drucker_links
bp = Blueprint("oeffentlich", __name__)
@@ -47,22 +50,53 @@ def lektion_detail(nummer):
@bp.route("/software")
def software():
abort(501)
"""Software-Sektion: Karten-Uebersicht, gruppiert nach Kategorie.
Etappe 3 — Karten-Konzept (Inhalt: 1-2 Saetze + Links). Die SuS lernen
den Umgang mit den Tools in den Lektionen (siehe docs/lektionen-plan.md),
hier nur der Katalog.
"""
from database import get_db
conn = get_db()
karten = conn.execute(
"SELECT id, slug, titel, kurzbeschreibung, url, tutorial_url, "
" plattform, app_store_url, play_store_url, bild_pfad, kategorie, "
" (bild_blob IS NOT NULL) AS hat_bild_blob "
"FROM software_seite WHERE aktiv = 1 "
"ORDER BY kategorie, sortierung, titel"
).fetchall()
conn.close()
@bp.route("/software/<slug>")
def software_detail(slug):
abort(501)
# Nach Kategorie gruppieren, damit das Template einfach iterieren kann
gruppen = {"software": [], "app": [], "generator": []}
for k in karten:
gruppen.setdefault(k["kategorie"], []).append(k)
return render_template("software.html", gruppen=gruppen)
@bp.route("/projekte")
@login_required
def projekte():
abort(501)
@bp.route("/projekte/<int:projekt_id>")
def projekt_detail(projekt_id):
abort(501)
"""Oeffentlicher Katalog (fuer eingeloggte SuS)."""
from database import get_db
from services.projekte import katalog_projekte, projekt_dateien_fuer_katalog, darf_neues_anlegen
eigene_id = session["profil_id"]
conn = get_db()
projekte_roh = katalog_projekte(conn)
darf_wiederholen, sperr_grund = darf_neues_anlegen(conn, eigene_id)
projekte_view = []
for p in projekte_roh:
dateien = projekt_dateien_fuer_katalog(conn, p["id"])
projekte_view.append({**dict(p), "dateien": [dict(d) for d in dateien]})
conn.close()
return render_template(
"projekte_katalog.html",
projekte=projekte_view,
eigene_profil_id=eigene_id,
darf_wiederholen=darf_wiederholen,
wiederhol_sperr_grund=sperr_grund,
)
@bp.route("/troubleshooting")
@@ -71,5 +105,47 @@ def troubleshooting():
@bp.route("/drucker")
@login_required
def drucker():
abort(501)
"""Drucker-Uebersicht fuer eingeloggte SuS.
Zeigt alle aktiven Drucker mit Status, Manual-/Troubleshooting-Links
und einem Banner bei Sperrung. Belegung-Anzeige (heute) folgt mit
Iteration B (Slot-Reservierung).
"""
from database import get_db
from services.slots import aktuelle_belegung, naechste_slots, profil_darf_reservieren
conn = get_db()
drucker_liste = conn.execute(
"SELECT d.id, d.name, d.modell, d.geschlossenes_gehaeuse, d.notiz, "
" d.freigegeben, d.gesperrt_grund, d.manual_url, d.troubleshooting_url, "
" s.status, s.job_name, s.progress_pct, s.restzeit_min, "
" s.fehlermeldung, s.gepollt_am, s.manuell_gesetzt "
"FROM drucker d "
"LEFT JOIN drucker_status s ON s.drucker_id = d.id "
"WHERE d.aktiv = 1 "
"ORDER BY d.modell, d.name"
).fetchall()
darf_reservieren = profil_darf_reservieren(conn, session.get("profil_id"))
# Pro Drucker: Links + aktuelle Belegung + naechste 3 Slots
drucker_view = []
for d in drucker_liste:
links = drucker_links(d)
belegung = aktuelle_belegung(conn, d["id"])
kommend = naechste_slots(conn, d["id"], n=3)
drucker_view.append({
**dict(d),
"manual": links["manual"],
"troubleshooting": links["troubleshooting"],
"belegung": dict(belegung) if belegung else None,
"kommend": [dict(s) for s in kommend],
})
conn.close()
return render_template(
"drucker.html",
drucker_liste=drucker_view,
darf_reservieren=darf_reservieren,
)

View File

@@ -5,7 +5,8 @@ Etappe 2: Cockpit progressiv (V1), Check-In (V3 + E-021), Lektion-Status, Paarun
Etappe 6: Projekt-Workflow.
Etappe 7: Druckauftraege + Slots.
"""
from datetime import date
import base64
from datetime import date, datetime, timedelta
from flask import (
Blueprint, render_template, request, redirect, url_for,
@@ -137,6 +138,43 @@ def check_in():
(session["profil_id"], date.today().isoformat(), modus, freitext)
)
conn.commit()
# Quick-Action: je nach Modus direkt zum passenden Ort weiterleiten.
# Falls die Aktion blockiert ist (z.B. neues_projekt waehrend Lehrer-Review-Wartung),
# leitet die jeweilige Route auf /cockpit mit Flash-Meldung weiter.
profil_id = session["profil_id"]
if modus == "neues_projekt":
conn.close()
return redirect(url_for("profil.projekt_neu"))
if modus == "drucken":
conn.close()
return redirect(url_for("oeffentlich.drucker"))
if modus == "onboarding":
conn.close()
return redirect(url_for("oeffentlich.lektionen"))
if modus == "weiter_wie_bisher":
from services.projekte import aktives_projekt
aktiv = aktives_projekt(conn, profil_id)
conn.close()
if aktiv:
return redirect(url_for("profil.projekt_detail", projekt_id=aktiv["id"]))
# Kein aktives Projekt mehr — nutzer wollte weitermachen, gibt aber nichts zum Weitermachen
flash("Du hast gerade kein laufendes Projekt — leg ein neues an?", "success")
return redirect(url_for("profil.cockpit"))
conn.close()
return redirect(url_for("profil.cockpit"))
@bp.route("/cockpit/check-in/zuruecksetzen", methods=["POST"])
@login_required
def check_in_zuruecksetzen():
"""Heutigen Check-In zuruecksetzen — Modal erscheint beim naechsten Cockpit wieder."""
conn = get_db()
conn.execute(
"DELETE FROM check_in WHERE profil_id = ? AND besuch_datum = ?",
(session["profil_id"], date.today().isoformat())
)
conn.commit()
conn.close()
return redirect(url_for("profil.cockpit"))
@@ -333,15 +371,624 @@ def paarung_aufloesen():
# ===========================================================================
# Stubs fuer spaetere Etappen
# Slot-Reservierung (Iteration B1)
# ===========================================================================
@bp.route("/cockpit/slots")
@login_required
def slots():
abort(501) # Etappe 6/7
"""Liste der eigenen Slots — bevorstehende und gerade aktive."""
from services.slots import slots_eines_profils, profil_darf_reservieren
conn = get_db()
eigene = slots_eines_profils(conn, session["profil_id"], kommend_nur=True)
darf = profil_darf_reservieren(conn, session["profil_id"])
conn.close()
return render_template("profil_slots.html", slots=eigene, darf_reservieren=darf)
@bp.route("/cockpit/slot/neu", methods=["GET", "POST"])
@login_required
def slot_neu():
"""SuS legt einen neuen Reservierungs-Slot an."""
from services.slots import (
parse_datetime_local, validiere_zeitraum, konflikt_check, profil_darf_reservieren
)
profil_id = session["profil_id"]
conn = get_db()
darf = profil_darf_reservieren(conn, profil_id)
conn.close()
if not darf:
flash(
"Reservierung ist fuer dich noch nicht freigeschaltet — bitte den Lehrer kurz darum.",
"error"
)
return redirect(url_for("oeffentlich.drucker"))
conn = get_db()
drucker_liste = conn.execute(
"SELECT id, name, modell, freigegeben, gesperrt_grund "
"FROM drucker WHERE aktiv = 1 AND freigegeben = 1 ORDER BY modell, name"
).fetchall()
conn.close()
# Default: heute, in 5 Minuten, 60 min Dauer
jetzt = datetime.now()
default_start = (jetzt + timedelta(minutes=5)).replace(second=0, microsecond=0)
default_ende = default_start + timedelta(hours=1)
if request.method == "POST":
try:
drucker_id = int(request.form.get("drucker_id") or 0)
except ValueError:
drucker_id = 0
start = parse_datetime_local(request.form.get("start_zeit") or "")
ende = parse_datetime_local(request.form.get("ende_zeit") or "")
notiz = (request.form.get("notiz") or "").strip() or None
# Drucker validieren
conn = get_db()
drucker = conn.execute(
"SELECT id, name, freigegeben FROM drucker WHERE id = ? AND aktiv = 1",
(drucker_id,)
).fetchone()
if not drucker:
conn.close()
flash("Drucker nicht gefunden.", "error")
return render_template(
"profil_slot_neu.html",
drucker_liste=drucker_liste,
default_start=default_start, default_ende=default_ende,
vorgewaehlt_drucker=drucker_id,
eingabe_start=request.form.get("start_zeit"),
eingabe_ende=request.form.get("ende_zeit"),
eingabe_notiz=request.form.get("notiz"),
)
if not drucker["freigegeben"]:
conn.close()
flash(f"'{drucker['name']}' ist derzeit gesperrt. Bitte anderen Drucker waehlen.", "error")
return redirect(url_for("profil.slot_neu"))
# Zeitraum validieren
fehler = validiere_zeitraum(start, ende, jetzt)
if fehler:
conn.close()
flash(fehler, "error")
return render_template(
"profil_slot_neu.html",
drucker_liste=drucker_liste,
default_start=default_start, default_ende=default_ende,
vorgewaehlt_drucker=drucker_id,
eingabe_start=request.form.get("start_zeit"),
eingabe_ende=request.form.get("ende_zeit"),
eingabe_notiz=request.form.get("notiz"),
)
# Konflikt-Check
konflikte = konflikt_check(conn, drucker_id, start, ende)
if konflikte:
k = konflikte[0]
conn.close()
flash(
f"Zeitraum-Konflikt: ein anderer Slot blockiert den Drucker von "
f"{k['start_zeit'][11:16]} bis {k['ende_zeit'][11:16]}.",
"error"
)
return render_template(
"profil_slot_neu.html",
drucker_liste=drucker_liste,
default_start=default_start, default_ende=default_ende,
vorgewaehlt_drucker=drucker_id,
eingabe_start=request.form.get("start_zeit"),
eingabe_ende=request.form.get("ende_zeit"),
eingabe_notiz=request.form.get("notiz"),
)
# Speichern
conn.execute(
"INSERT INTO slot (drucker_id, profil_id, start_zeit, ende_zeit, status, notiz) "
"VALUES (?, ?, ?, ?, 'reserviert', ?)",
(drucker_id, profil_id, start.isoformat(), ende.isoformat(), notiz)
)
conn.commit()
conn.close()
flash(f"Slot reserviert: {drucker['name']} von {start:%d.%m. %H:%M} bis {ende:%H:%M}.", "success")
return redirect(url_for("profil.slots"))
# GET — Form anzeigen. Drucker-Vorauswahl via Query-Param (von /drucker)
try:
vorgewaehlt_drucker = int(request.args.get("drucker_id", 0))
except ValueError:
vorgewaehlt_drucker = 0
return render_template(
"profil_slot_neu.html",
drucker_liste=drucker_liste,
default_start=default_start, default_ende=default_ende,
vorgewaehlt_drucker=vorgewaehlt_drucker,
eingabe_start=None, eingabe_ende=None, eingabe_notiz=None,
)
@bp.route("/cockpit/slot/<int:slot_id>/stornieren", methods=["POST"])
@login_required
def slot_stornieren(slot_id):
"""Eigenen Slot stornieren — nur eigene, nur wenn nicht schon beendet/storniert."""
conn = get_db()
slot = conn.execute(
"SELECT id, profil_id, status FROM slot WHERE id = ?", (slot_id,)
).fetchone()
if not slot or slot["profil_id"] != session["profil_id"]:
conn.close()
flash("Slot nicht gefunden.", "error")
return redirect(url_for("profil.slots"))
if slot["status"] in ("storniert", "beendet"):
conn.close()
flash("Slot ist bereits abgeschlossen.", "error")
return redirect(url_for("profil.slots"))
conn.execute("UPDATE slot SET status = 'storniert' WHERE id = ?", (slot_id,))
conn.commit()
conn.close()
flash("Slot storniert.", "success")
return redirect(url_for("profil.slots"))
# ===========================================================================
# Projekte / Selbstlern-Loop (Etappe 6 — Kern-Mechanik)
# ===========================================================================
#
# Regeln: siehe services/projekte.py — kurz: max. 1 aktives Projekt, neues
# nur nach Freigabe (auto bei Erfolg, durch Lehrer bei Misserfolg).
@bp.route("/cockpit/projekte")
@login_required
def projekte_liste():
"""Eigene Projekt-Liste (aktiv + abgeschlossen)."""
from services.projekte import liste_eigener, darf_neues_anlegen
conn = get_db()
eigene = liste_eigener(conn, session["profil_id"])
darf, grund = darf_neues_anlegen(conn, session["profil_id"])
conn.close()
return render_template(
"projekt_liste.html", projekte=eigene, darf_neues=darf, sperr_grund=grund
)
@bp.route("/cockpit/projekt/neu", methods=["GET", "POST"])
@login_required
def projekt_neu():
"""Neues Projekt anlegen — mit Block-Check (siehe darf_neues_anlegen)."""
from services.projekte import darf_neues_anlegen
conn = get_db()
darf, grund = darf_neues_anlegen(conn, session["profil_id"])
if not darf:
conn.close()
flash(grund, "error")
return redirect(url_for("profil.cockpit"))
if request.method == "POST":
titel = (request.form.get("titel") or "").strip()
beschreibung = (request.form.get("beschreibung_md") or "").strip() or None
if not titel:
conn.close()
flash("Bitte einen Titel angeben.", "error")
return render_template("projekt_neu.html", eingabe_titel="", eingabe_beschreibung=beschreibung)
cur = conn.execute(
"INSERT INTO projekt (profil_id, titel, beschreibung_md, status_lebenszyklus, freigabe_status) "
"VALUES (?, ?, ?, 'in_arbeit', 'in_arbeit')",
(session["profil_id"], titel, beschreibung)
)
conn.commit()
neue_id = cur.lastrowid
conn.close()
flash(f"Projekt '{titel}' angelegt — viel Erfolg!", "success")
return redirect(url_for("profil.projekt_detail", projekt_id=neue_id))
conn.close()
return render_template("projekt_neu.html", eingabe_titel=None, eingabe_beschreibung=None)
def _eigenes_projekt_holen(projekt_id, conn=None):
"""Liefert das Projekt, wenn es dem eingeloggten SuS gehoert. Sonst None.
Conn-Recycling optional, damit man in einer einzelnen DB-Connection bleiben kann.
"""
eigene_conn = conn is None
conn = conn or get_db()
try:
from services.projekte import projekt_holen
p = projekt_holen(conn, projekt_id)
if not p or p["profil_id"] != session.get("profil_id"):
return None
return p
finally:
if eigene_conn:
conn.close()
@bp.route("/cockpit/projekt/<int:projekt_id>")
@login_required
def projekt_detail(projekt_id):
from services.projekte import (
dateien_eines_projekts, abschluss_voraussetzungen,
vorlage_info, wiederholungen_von,
)
p = _eigenes_projekt_holen(projekt_id)
if not p:
flash("Projekt nicht gefunden.", "error")
return redirect(url_for("profil.cockpit"))
conn = get_db()
software_liste = conn.execute(
"SELECT slug, titel, kategorie FROM software_seite "
"WHERE aktiv = 1 ORDER BY kategorie, sortierung, titel"
).fetchall()
drucker_liste = conn.execute(
"SELECT id, name, modell FROM drucker WHERE aktiv = 1 ORDER BY modell, name"
).fetchall()
dateien = dateien_eines_projekts(conn, projekt_id)
darf_abschliessen, fehlende = abschluss_voraussetzungen(conn, projekt_id)
vorlage = vorlage_info(conn, p["vorlage_projekt_id"]) if p["vorlage_projekt_id"] else None
wiederholungen = wiederholungen_von(conn, projekt_id)
conn.close()
return render_template(
"projekt_detail.html",
p=p,
software_liste=software_liste,
drucker_liste=drucker_liste,
dateien=dateien,
darf_abschliessen=darf_abschliessen,
abschluss_fehlende=fehlende,
vorlage=vorlage,
wiederholungen=wiederholungen,
)
@bp.route("/cockpit/projekt/<int:projekt_id>/werkstatt", methods=["POST"])
@login_required
def projekt_werkstatt_speichern(projekt_id):
"""Speichert Beschreibung + Software + Drucker + MakerWorld-URL in einem Schritt."""
p = _eigenes_projekt_holen(projekt_id)
if not p:
flash("Projekt nicht gefunden.", "error")
return redirect(url_for("profil.cockpit"))
if p["status_lebenszyklus"] != "in_arbeit":
flash("Abgeschlossene Projekte kannst du nicht mehr bearbeiten.", "error")
return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id))
titel = (request.form.get("titel") or "").strip()
beschreibung = (request.form.get("beschreibung_md") or "").strip() or None
software = (request.form.get("modellier_software_slug") or "").strip() or None
try:
drucker = int(request.form.get("drucker_id_used") or 0) or None
except ValueError:
drucker = None
makerworld = (request.form.get("makerworld_url") or "").strip() or None
if not titel:
flash("Titel darf nicht leer sein.", "error")
return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id))
conn = get_db()
conn.execute(
"UPDATE projekt SET titel = ?, beschreibung_md = ?, "
" modellier_software_slug = ?, drucker_id_used = ?, makerworld_url = ?, "
" geaendert_am = CURRENT_TIMESTAMP WHERE id = ?",
(titel, beschreibung, software, drucker, makerworld, projekt_id)
)
conn.commit()
conn.close()
flash("Aenderungen gespeichert.", "success")
return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id))
# Max-Upload-Groesse (30 MB) — Bambu-3MF-Files sind selten groesser
MAX_UPLOAD_BYTES = 30 * 1024 * 1024
# Mapping Mime -> art (siehe Schema: art TEXT 'cover_foto'|'stl'|'3mf'|'foto'|'sonstiges')
def _datei_art_aus_dateiname(dateiname: str, mime: str) -> str:
name_lower = (dateiname or "").lower()
if name_lower.endswith(".stl"):
return "stl"
if name_lower.endswith(".3mf"):
return "3mf"
if mime and mime.startswith("image/"):
return "foto"
return "sonstiges"
@bp.route("/cockpit/projekt/<int:projekt_id>/datei", methods=["POST"])
@login_required
def projekt_datei_upload(projekt_id):
"""Datei hochladen (JSON base64). Erwartet {dateiname, mime, base64}.
Eine Datei pro Request. Frontend ruft mehrfach auf bei mehreren Dateien.
"""
p = _eigenes_projekt_holen(projekt_id)
if not p:
return jsonify(error="Projekt nicht gefunden"), 404
if p["status_lebenszyklus"] != "in_arbeit":
return jsonify(error="Abgeschlossene Projekte koennen keine Dateien mehr aufnehmen"), 400
data = request.get_json(silent=True) or {}
dateiname = (data.get("dateiname") or "").strip()
mime = (data.get("mime") or "application/octet-stream").strip()
b64 = data.get("base64") or ""
if not dateiname or not b64:
return jsonify(error="dateiname oder base64 fehlt"), 400
# base64 kann mit "data:...;base64," anfangen — abtrennen
if "," in b64:
b64 = b64.split(",", 1)[1]
try:
blob = base64.b64decode(b64)
except Exception:
return jsonify(error="base64 konnte nicht dekodiert werden"), 400
if len(blob) > MAX_UPLOAD_BYTES:
return jsonify(error=f"Datei zu gross ({len(blob)//1024} KB, max {MAX_UPLOAD_BYTES//(1024*1024)} MB)"), 413
art = _datei_art_aus_dateiname(dateiname, mime)
conn = get_db()
cur = conn.execute(
"INSERT INTO projekt_datei (projekt_id, dateiname, mime_type, groesse_bytes, inhalt, art, hochgeladen_von) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(projekt_id, dateiname, mime, len(blob), blob, art, session["profil_id"])
)
conn.commit()
neue_id = cur.lastrowid
conn.close()
return jsonify(ok=True, id=neue_id, art=art, groesse=len(blob))
@bp.route("/cockpit/projekt/<int:projekt_id>/datei/<int:datei_id>/loeschen", methods=["POST"])
@login_required
def projekt_datei_loeschen(projekt_id, datei_id):
p = _eigenes_projekt_holen(projekt_id)
if not p:
flash("Projekt nicht gefunden.", "error")
return redirect(url_for("profil.cockpit"))
if p["status_lebenszyklus"] != "in_arbeit":
flash("Dateien abgeschlossener Projekte koennen nicht geloescht werden.", "error")
return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id))
conn = get_db()
conn.execute(
"DELETE FROM projekt_datei WHERE id = ? AND projekt_id = ?",
(datei_id, projekt_id)
)
conn.commit()
conn.close()
flash("Datei geloescht.", "success")
return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id))
@bp.route("/cockpit/projekt/<int:projekt_id>/bearbeiten", methods=["GET", "POST"])
@login_required
def projekt_bearbeiten(projekt_id):
"""Titel + Beschreibung waehrend status='in_arbeit' anpassen."""
p = _eigenes_projekt_holen(projekt_id)
if not p:
flash("Projekt nicht gefunden.", "error")
return redirect(url_for("profil.cockpit"))
if p["status_lebenszyklus"] != "in_arbeit":
flash("Abgeschlossene Projekte kannst du nicht mehr bearbeiten.", "error")
return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id))
if request.method == "POST":
titel = (request.form.get("titel") or "").strip()
beschreibung = (request.form.get("beschreibung_md") or "").strip() or None
if not titel:
flash("Titel darf nicht leer sein.", "error")
return redirect(url_for("profil.projekt_bearbeiten", projekt_id=projekt_id))
conn = get_db()
conn.execute(
"UPDATE projekt SET titel = ?, beschreibung_md = ?, geaendert_am = CURRENT_TIMESTAMP "
"WHERE id = ?",
(titel, beschreibung, projekt_id)
)
conn.commit()
conn.close()
flash("Aenderungen gespeichert.", "success")
return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id))
return render_template("projekt_bearbeiten.html", p=p)
@bp.route("/cockpit/projekt/<int:projekt_id>/abschluss", methods=["GET", "POST"])
@login_required
def projekt_abschluss(projekt_id):
"""SuS schliesst das Projekt ab — mit differenzierter Selbstbewertung.
- Gesamt: zufrieden/mittel/gelernt (E-015)
- Planung: gut/ok/schwierig
- Ausfuehrung: gut/ok/schwierig
- Was gelernt (Freitext)
- Was klappte nicht (Freitext, hilft Lehrer bei Misserfolg)
Bei 'gelernt' (Misserfolg) → wartet_auf_freigabe (Lehrer muss drueber).
Sonst → freigegeben (SuS kann gleich neu anfangen).
"""
from services.projekte import (
EVAL_GESAMT, EVAL_QUALITAET, freigabe_nach_eval
)
p = _eigenes_projekt_holen(projekt_id)
if not p:
flash("Projekt nicht gefunden.", "error")
return redirect(url_for("profil.cockpit"))
if p["status_lebenszyklus"] != "in_arbeit":
flash("Dieses Projekt ist bereits abgeschlossen.", "error")
return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id))
# Abschluss-Voraussetzungen (Cover + Files) — pruefen bei GET und POST
from services.projekte import abschluss_voraussetzungen
conn = get_db()
darf, fehlende = abschluss_voraussetzungen(conn, projekt_id)
conn.close()
if not darf:
for f in fehlende:
flash(f, "error")
return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id))
if request.method == "POST":
gesamt = (request.form.get("selbsteinschaetzung") or "").strip()
planung = (request.form.get("selbsteinschaetzung_planung") or "").strip()
ausfuehrung = (request.form.get("selbsteinschaetzung_ausfuehrung") or "").strip()
gelernt = (request.form.get("was_gelernt") or "").strip() or None
klappte_nicht = (request.form.get("was_klappte_nicht") or "").strip() or None
if gesamt not in EVAL_GESAMT:
flash("Bitte Gesamt-Einschaetzung waehlen.", "error")
return redirect(url_for("profil.projekt_abschluss", projekt_id=projekt_id))
# Planung/Ausfuehrung optional, aber falls gesetzt: validieren
if planung and planung not in EVAL_QUALITAET:
planung = None
if ausfuehrung and ausfuehrung not in EVAL_QUALITAET:
ausfuehrung = None
neuer_freigabe_status = freigabe_nach_eval(gesamt)
conn = get_db()
conn.execute(
"""UPDATE projekt SET
status_lebenszyklus = 'abgeschlossen',
abgeschlossen_am = CURRENT_TIMESTAMP,
selbsteinschaetzung = ?,
selbsteinschaetzung_planung = ?,
selbsteinschaetzung_ausfuehrung = ?,
was_gelernt = ?,
was_klappte_nicht = ?,
freigabe_status = ?,
geaendert_am = CURRENT_TIMESTAMP
WHERE id = ?""",
(gesamt, planung or None, ausfuehrung or None,
gelernt, klappte_nicht, neuer_freigabe_status, projekt_id)
)
conn.commit()
conn.close()
if neuer_freigabe_status == "wartet_auf_freigabe":
flash(
"Projekt abgeschlossen — und ehrlich bewertet, super! "
"Der Lehrer schaut drueber, dann kannst du was Neues anfangen.",
"success"
)
else:
flash("Projekt abgeschlossen. Du kannst jetzt was Neues anfangen.", "success")
return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id))
return render_template(
"projekt_abschluss.html", p=p,
EVAL_GESAMT=EVAL_GESAMT, EVAL_QUALITAET=EVAL_QUALITAET
)
@bp.route("/cockpit/projekt/<int:projekt_id>/cover", methods=["GET", "POST"])
@login_required
def projekt_cover(projekt_id):
"""Cover-Foto via Crop-Tool (analog admin_software_bild)."""
p = _eigenes_projekt_holen(projekt_id)
if not p:
if request.method == "POST":
return jsonify(error="Projekt nicht gefunden"), 404
flash("Projekt nicht gefunden.", "error")
return redirect(url_for("profil.cockpit"))
if request.method == "POST":
data = request.get_json(silent=True) or {}
bild_str = data.get("bild_base64")
if not bild_str:
return jsonify(error="Kein Bild empfangen"), 400
mime = "image/jpeg"
if "," in bild_str:
header, bild_str = bild_str.split(",", 1)
if ":" in header and ";" in header:
mime = header.split(":")[1].split(";")[0]
try:
blob = base64.b64decode(bild_str)
except Exception:
return jsonify(error="Bild konnte nicht dekodiert werden"), 400
conn = get_db()
conn.execute(
"UPDATE projekt SET cover_blob = ?, cover_mime = ? WHERE id = ?",
(blob, mime, projekt_id)
)
conn.commit()
conn.close()
return jsonify(ok=True)
return render_template("projekt_cover.html", p=p)
@bp.route("/cockpit/projekt/<int:projekt_id>/cover/loeschen", methods=["POST"])
@login_required
def projekt_cover_loeschen(projekt_id):
p = _eigenes_projekt_holen(projekt_id)
if not p:
flash("Projekt nicht gefunden.", "error")
return redirect(url_for("profil.cockpit"))
conn = get_db()
conn.execute(
"UPDATE projekt SET cover_blob = NULL, cover_mime = NULL WHERE id = ?",
(projekt_id,)
)
conn.commit()
conn.close()
flash("Cover-Bild geloescht.", "success")
return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id))
@bp.route("/cockpit/projekt/aus-vorlage/<int:vorlage_id>", methods=["POST"])
@login_required
def projekt_aus_vorlage(vorlage_id):
"""Neues Projekt mit Verknuepfung zu einer Vorlage anlegen."""
from services.projekte import aus_vorlage_anlegen
conn = get_db()
neue_id, fehler = aus_vorlage_anlegen(conn, session["profil_id"], vorlage_id)
conn.close()
if fehler:
flash(fehler, "error")
return redirect(url_for("oeffentlich.projekte"))
flash(
"Projekt angelegt — pass jetzt Titel und Beschreibung an, und mach deinen eigenen Twist.",
"success"
)
return redirect(url_for("profil.projekt_detail", projekt_id=neue_id))
@bp.route("/cockpit/projekt/<int:projekt_id>/rueckfrage/<int:rueckfrage_id>/erledigt", methods=["POST"])
@login_required
def rueckfrage_erledigt(projekt_id, rueckfrage_id):
"""SuS markiert eine Rueckfrage als beantwortet → Projekt geht zurueck zu wartet_auf_freigabe."""
p = _eigenes_projekt_holen(projekt_id)
if not p:
flash("Projekt nicht gefunden.", "error")
return redirect(url_for("profil.cockpit"))
conn = get_db()
conn.execute(
"UPDATE projekt_rueckfrage SET status = 'erledigt', erledigt_am = CURRENT_TIMESTAMP "
"WHERE id = ? AND projekt_id = ?",
(rueckfrage_id, projekt_id)
)
# Wenn keine offenen Rueckfragen mehr da sind → zurueck zu wartet_auf_freigabe
offen = conn.execute(
"SELECT COUNT(*) AS n FROM projekt_rueckfrage WHERE projekt_id = ? AND status = 'offen'",
(projekt_id,)
).fetchone()["n"]
if offen == 0:
conn.execute(
"UPDATE projekt SET freigabe_status = 'wartet_auf_freigabe' WHERE id = ?",
(projekt_id,)
)
conn.commit()
conn.close()
flash("Rueckfrage als erledigt markiert — der Lehrer schaut nochmal drueber.", "success")
return redirect(url_for("profil.projekt_detail", projekt_id=projekt_id))
# ===========================================================================
# Stubs fuer spaetere Etappen
# ===========================================================================
@bp.route("/cockpit/druckauftraege")
@login_required
def druckauftraege():

View File

@@ -100,10 +100,15 @@ def get_ungelesene_notizen(profil_id: int) -> list[dict]:
def get_aktive_projekte(profil_id: int) -> list[dict]:
"""Eigene Projekte mit status_lebenszyklus='in_arbeit' (Etappe 6 fuellt das voll)."""
"""Eigene Projekte mit status_lebenszyklus='in_arbeit'.
Nach Etappe 6: max. 1 (Regel "ein aktives Projekt pro SuS").
Liefert trotzdem Liste, damit das Template unveraendert bleibt.
"""
conn = get_db()
rows = conn.execute(
"SELECT id, titel, beschreibung_md, geaendert_am "
"SELECT id, titel, beschreibung_md, geaendert_am, "
" (cover_blob IS NOT NULL) AS hat_cover "
"FROM projekt "
"WHERE profil_id = ? AND status_lebenszyklus = 'in_arbeit' "
"ORDER BY geaendert_am DESC",
@@ -113,6 +118,26 @@ def get_aktive_projekte(profil_id: int) -> list[dict]:
return [dict(r) for r in rows]
def get_projekt_kontext(profil_id: int) -> dict:
"""Liefert den Projekt-Kontext fuers Cockpit:
- aktives (in_arbeit)
- darf_neues + sperr_grund
- letztes_abgeschlossenes (wenn Wartet auf Freigabe oder Rueckfrage: anzeigen)
"""
from services.projekte import darf_neues_anlegen, aktives_projekt, letztes_projekt
conn = get_db()
aktiv = aktives_projekt(conn, profil_id)
darf, grund = darf_neues_anlegen(conn, profil_id)
letztes = letztes_projekt(conn, profil_id) if not aktiv else None
conn.close()
return {
"aktiv": dict(aktiv) if aktiv else None,
"darf_neues": darf,
"sperr_grund": grund,
"letztes": dict(letztes) if letztes else None,
}
def get_paarung(profil_id: int) -> Optional[dict]:
"""Aktive Paarung des SuS (oder None)."""
conn = get_db()
@@ -185,6 +210,7 @@ def get_cockpit_status(profil_id: int) -> dict:
"""
onb = get_onboarding_fortschritt(profil_id)
aktive_projekte = get_aktive_projekte(profil_id)
projekt_kontext = get_projekt_kontext(profil_id)
paarung = get_paarung(profil_id)
return {
"onboarding": onb,
@@ -192,6 +218,7 @@ def get_cockpit_status(profil_id: int) -> dict:
"rueckfragen": get_offene_rueckfragen(profil_id),
"notizen": get_ungelesene_notizen(profil_id),
"aktive_projekte": aktive_projekte,
"projekt": projekt_kontext, # neu: Selbstlern-Loop-Kontext
"paarung": paarung,
"offene_anfragen": get_offene_anfragen_an_mich(profil_id),
# Progressive-Schalter:

53
services/drucker_links.py Normal file
View File

@@ -0,0 +1,53 @@
"""Default-Manual + Troubleshooting-Links pro Drucker-Modell.
Bambu-Wiki-URLs als Startpunkt — koennen pro konkretem Drucker in der
DB via drucker.manual_url / drucker.troubleshooting_url ueberschrieben
werden (Inline-Edit). Wenn Override gesetzt, gewinnt der.
"""
MODELL_LINKS = {
"P1S": {
"manual": "https://wiki.bambulab.com/en/p1",
"troubleshooting": "https://wiki.bambulab.com/en/p1/troubleshooting",
},
"X1C": {
"manual": "https://wiki.bambulab.com/en/x1",
"troubleshooting": "https://wiki.bambulab.com/en/x1/troubleshooting",
},
"P2S": {
"manual": "https://wiki.bambulab.com/de/p2s/manual/p2s-intro",
"troubleshooting": "https://wiki.bambulab.com/en/p2/troubleshooting",
},
"A1 mini": {
"manual": "https://wiki.bambulab.com/en/a1-mini",
"troubleshooting": "https://wiki.bambulab.com/en/a1-mini/troubleshooting",
},
}
def drucker_links(drucker):
"""Liefert (manual_url, troubleshooting_url) fuer einen Drucker.
Order of resolution: DB-Override > Modell-Default > None.
`drucker` darf ein sqlite3.Row oder dict sein — Hauptsache es hat
die Schluessel modell, manual_url, troubleshooting_url.
"""
modell = drucker["modell"] if drucker else None
default = MODELL_LINKS.get(modell, {})
return {
"manual": _trim(drucker, "manual_url") or default.get("manual"),
"troubleshooting": _trim(drucker, "troubleshooting_url") or default.get("troubleshooting"),
}
def _trim(drucker, schluessel):
"""Liest ein optionales Feld aus dem Row/Dict, gibt None wenn leer."""
try:
wert = drucker[schluessel]
except (KeyError, IndexError):
return None
if not wert:
return None
wert = wert.strip()
return wert or None

341
services/projekte.py Normal file
View File

@@ -0,0 +1,341 @@
"""Projekt-Helper fuer den Selbstlern-Loop (Etappe 6).
Kern-Regeln (mit Herb am 2026-05-25 festgehalten):
1. Pro SuS gibt es maximal EIN aktives Projekt (status_lebenszyklus = 'in_arbeit').
2. Ein neues Projekt kann NUR angelegt werden, wenn:
- kein 'in_arbeit'-Projekt existiert UND
- das letzte abgeschlossene Projekt ist 'freigegeben' (nicht 'wartet_auf_freigabe'
und nicht 'rueckfrage').
3. Bei Selbstbewertung 'gelernt' (= Misserfolg) → automatisch 'wartet_auf_freigabe'
→ Lehrer muss freigeben, bevor neues Projekt erlaubt ist.
4. Bei Selbstbewertung 'zufrieden' / 'mittel' → automatisch 'freigegeben'.
Lehrer kann nachtraeglich 'rueckfrage' setzen, dann blockiert es ebenfalls.
"""
from datetime import datetime
# Status-Werte (zentral, damit nicht ueberall String-Literale)
LEBENSZYKLUS_IN_ARBEIT = "in_arbeit"
LEBENSZYKLUS_ABGESCHLOSSEN = "abgeschlossen"
FREIGABE_IN_ARBEIT = "in_arbeit"
FREIGABE_WARTET = "wartet_auf_freigabe"
FREIGABE_FREIGEGEBEN = "freigegeben"
FREIGABE_RUECKFRAGE = "rueckfrage"
EVAL_GESAMT = ("zufrieden", "mittel", "gelernt")
EVAL_QUALITAET = ("gut", "ok", "schwierig")
def aktives_projekt(conn, profil_id: int):
"""Liefert das aktuelle in_arbeit-Projekt eines SuS (oder None)."""
return conn.execute(
"SELECT id, titel, beschreibung_md, status_lebenszyklus, freigabe_status, "
" geaendert_am, (cover_blob IS NOT NULL) AS hat_cover "
"FROM projekt WHERE profil_id = ? AND status_lebenszyklus = ? "
"ORDER BY id DESC LIMIT 1",
(profil_id, LEBENSZYKLUS_IN_ARBEIT)
).fetchone()
def letztes_projekt(conn, profil_id: int):
"""Liefert das chronologisch letzte Projekt — egal welcher Status."""
return conn.execute(
"SELECT id, titel, status_lebenszyklus, freigabe_status, "
" selbsteinschaetzung, abgeschlossen_am "
"FROM projekt WHERE profil_id = ? "
"ORDER BY COALESCE(abgeschlossen_am, geaendert_am) DESC, id DESC LIMIT 1",
(profil_id,)
).fetchone()
def darf_neues_anlegen(conn, profil_id: int):
"""Prueft, ob der SuS aktuell ein neues Projekt anlegen darf.
Returns:
(darf: bool, grund: str | None)
grund ist der Sperr-Text fuer die UI, wenn darf=False.
"""
if aktives_projekt(conn, profil_id):
return (False, "Du hast schon ein laufendes Projekt — erst abschliessen, dann neu anfangen.")
letztes = letztes_projekt(conn, profil_id)
if letztes and letztes["freigabe_status"] == FREIGABE_WARTET:
return (
False,
f"Dein letztes Projekt ('{letztes['titel']}') wartet auf Freigabe vom Lehrer. "
"Sobald er drueber geschaut hat, kannst du ein neues anfangen."
)
if letztes and letztes["freigabe_status"] == FREIGABE_RUECKFRAGE:
return (
False,
f"Der Lehrer hat eine Rueckfrage zu deinem letzten Projekt ('{letztes['titel']}'). "
"Schau ins Cockpit, beantworte sie — dann kannst du neu anfangen."
)
return (True, None)
def liste_eigener(conn, profil_id: int, kommend_nur: bool = False):
"""Liefert alle Projekte eines SuS. Default: ALLE inkl. abgeschlossene."""
sql = (
"SELECT id, titel, beschreibung_md, status_lebenszyklus, freigabe_status, "
" selbsteinschaetzung, selbsteinschaetzung_planung, selbsteinschaetzung_ausfuehrung, "
" abgeschlossen_am, geaendert_am, (cover_blob IS NOT NULL) AS hat_cover "
"FROM projekt WHERE profil_id = ? "
)
if kommend_nur:
sql += f"AND status_lebenszyklus = '{LEBENSZYKLUS_IN_ARBEIT}' "
sql += "ORDER BY COALESCE(abgeschlossen_am, geaendert_am) DESC, id DESC"
return conn.execute(sql, (profil_id,)).fetchall()
def projekt_holen(conn, projekt_id: int):
"""Volle Projekt-Row inkl. hat_cover-Flag + Werkstatt-Felder."""
return conn.execute(
"SELECT id, profil_id, paarung_id, titel, beschreibung_md, "
" status_lebenszyklus, abgeschlossen_am, freigabe_status, "
" selbsteinschaetzung, selbsteinschaetzung_planung, selbsteinschaetzung_ausfuehrung, "
" was_gelernt, was_klappte_nicht, "
" freigegeben_am, freigegeben_von, sichtbar_in_ag, vorlage_projekt_id, "
" modellier_software_slug, drucker_id_used, makerworld_url, "
" geaendert_am, (cover_blob IS NOT NULL) AS hat_cover "
"FROM projekt WHERE id = ?",
(projekt_id,)
).fetchone()
def dateien_eines_projekts(conn, projekt_id: int):
"""Alle hochgeladenen Dateien eines Projekts (ohne BLOB-Inhalt — nur Metadaten)."""
return conn.execute(
"SELECT id, dateiname, mime_type, groesse_bytes, art, hochgeladen_am "
"FROM projekt_datei WHERE projekt_id = ? "
"ORDER BY hochgeladen_am ASC",
(projekt_id,)
).fetchall()
def abschluss_voraussetzungen(conn, projekt_id: int):
"""Prueft, ob ein Projekt abgeschlossen werden darf.
Bedingung: Cover-Bild gesetzt UND mindestens eine Datei hochgeladen.
Returns:
(darf: bool, fehler: list[str]) — Liste der noch fehlenden Punkte.
"""
row = conn.execute(
"SELECT (cover_blob IS NOT NULL) AS hat_cover, "
" (SELECT COUNT(*) FROM projekt_datei WHERE projekt_id = ?) AS files "
"FROM projekt WHERE id = ?",
(projekt_id, projekt_id)
).fetchone()
if not row:
return (False, ["Projekt nicht gefunden."])
fehler = []
if not row["hat_cover"]:
fehler.append("Cover-Bild fehlt — lad ein Foto vom Druck oder Modell hoch.")
if row["files"] == 0:
fehler.append("Keine Dateien hochgeladen — mindestens eine STL/3MF oder ein Foto wird gebraucht.")
return (len(fehler) == 0, fehler)
def freigabe_nach_eval(selbsteinschaetzung_gesamt: str) -> str:
"""Bestimmt den Freigabe-Status anhand der Selbstbewertung.
- 'zufrieden' / 'mittel''freigegeben' (SuS kann gleich weiter)
- 'gelernt' (Misserfolg) → 'wartet_auf_freigabe' (Lehrer muss drueber)
"""
if selbsteinschaetzung_gesamt == "gelernt":
return FREIGABE_WARTET
return FREIGABE_FREIGEGEBEN
def warten_auf_freigabe_anzahl(conn) -> int:
"""Zahl fuer das Admin-Dashboard-Badge."""
row = conn.execute(
"SELECT COUNT(*) AS n FROM projekt WHERE freigabe_status = ?",
(FREIGABE_WARTET,)
).fetchone()
return row["n"] if row else 0
def alle_projekte(conn, filter_status: str | None = None):
"""Alle Projekte aller SuS — fuer Admin-Uebersicht.
filter_status:
None / 'alle' — alles
'wartet' — nur freigabe_status = wartet_auf_freigabe
'in_arbeit' — nur status_lebenszyklus = in_arbeit
'abgeschlossen' — nur status_lebenszyklus = abgeschlossen
'rueckfrage' — nur freigabe_status = rueckfrage
"""
sql = (
"SELECT p.id, p.titel, p.status_lebenszyklus, p.freigabe_status, "
" p.selbsteinschaetzung, p.selbsteinschaetzung_planung, p.selbsteinschaetzung_ausfuehrung, "
" p.abgeschlossen_am, p.geaendert_am, p.profil_id, "
" pr.vorname, pr.tier, (p.cover_blob IS NOT NULL) AS hat_cover "
"FROM projekt p LEFT JOIN profil pr ON pr.id = p.profil_id "
)
where = []
params: list = []
if filter_status == "wartet":
where.append("p.freigabe_status = ?")
params.append(FREIGABE_WARTET)
elif filter_status == "in_arbeit":
where.append("p.status_lebenszyklus = ?")
params.append(LEBENSZYKLUS_IN_ARBEIT)
elif filter_status == "abgeschlossen":
where.append("p.status_lebenszyklus = ?")
params.append(LEBENSZYKLUS_ABGESCHLOSSEN)
elif filter_status == "rueckfrage":
where.append("p.freigabe_status = ?")
params.append(FREIGABE_RUECKFRAGE)
if where:
sql += "WHERE " + " AND ".join(where) + " "
# Sortierung: wartend zuerst, dann in_arbeit, dann abgeschlossen
sql += (
"ORDER BY "
"CASE p.freigabe_status WHEN 'wartet_auf_freigabe' THEN 0 WHEN 'rueckfrage' THEN 1 ELSE 2 END, "
"CASE p.status_lebenszyklus WHEN 'in_arbeit' THEN 0 ELSE 1 END, "
"COALESCE(p.abgeschlossen_am, p.geaendert_am) DESC"
)
return conn.execute(sql, params).fetchall()
def status_zaehler(conn) -> dict:
"""Liefert dict mit Anzahl pro Filter-Status — fuer Filter-Buttons."""
row = conn.execute(
"SELECT "
" COUNT(*) AS alle, "
" SUM(CASE WHEN freigabe_status = ? THEN 1 ELSE 0 END) AS wartet, "
" SUM(CASE WHEN status_lebenszyklus = ? THEN 1 ELSE 0 END) AS in_arbeit, "
" SUM(CASE WHEN status_lebenszyklus = ? THEN 1 ELSE 0 END) AS abgeschlossen, "
" SUM(CASE WHEN freigabe_status = ? THEN 1 ELSE 0 END) AS rueckfrage "
"FROM projekt",
(FREIGABE_WARTET, LEBENSZYKLUS_IN_ARBEIT, LEBENSZYKLUS_ABGESCHLOSSEN, FREIGABE_RUECKFRAGE)
).fetchone()
return {
"alle": row["alle"] or 0,
"wartet": row["wartet"] or 0,
"in_arbeit": row["in_arbeit"] or 0,
"abgeschlossen": row["abgeschlossen"] or 0,
"rueckfrage": row["rueckfrage"] or 0,
}
def katalog_projekte(conn):
"""Oeffentlicher Katalog (fuer SuS):
- alle in_arbeit-Projekte (man sieht, woran andere gerade arbeiten)
- alle abgeschlossenen mit freigabe_status='freigegeben'
NICHT: wartet_auf_freigabe oder rueckfrage — die sind noch nicht
'oeffentlich' und sollen nicht im Katalog auftauchen.
"""
return conn.execute(
"SELECT p.id, p.titel, p.beschreibung_md, p.status_lebenszyklus, "
" p.selbsteinschaetzung, p.abgeschlossen_am, p.geaendert_am, "
" p.makerworld_url, "
" p.modellier_software_slug, sw.titel AS software_titel, "
" p.drucker_id_used, d.name AS drucker_name, "
" p.profil_id, pr.vorname, pr.tier, "
" (p.cover_blob IS NOT NULL) AS hat_cover, "
" (SELECT COUNT(*) FROM projekt_datei WHERE projekt_id = p.id) AS datei_anzahl "
"FROM projekt p "
"LEFT JOIN profil pr ON pr.id = p.profil_id "
"LEFT JOIN software_seite sw ON sw.slug = p.modellier_software_slug "
"LEFT JOIN drucker d ON d.id = p.drucker_id_used "
"WHERE p.sichtbar_in_ag = 1 "
"AND ( "
" p.status_lebenszyklus = 'in_arbeit' "
" OR (p.status_lebenszyklus = 'abgeschlossen' AND p.freigabe_status = 'freigegeben') "
") "
"ORDER BY COALESCE(p.abgeschlossen_am, p.geaendert_am) DESC"
).fetchall()
def aus_vorlage_anlegen(conn, profil_id: int, vorlage_id: int):
"""Legt ein neues Projekt mit Verknuepfung zu vorlage_id an.
Verknuepfung-Modus (kein Klon, kein Copy): nur vorlage_projekt_id wird
gesetzt. Titel kommt mit "Wiederholt: ..." als Vorschlag — SuS aendert
in der Werkstatt. Beschreibung, Software, Drucker, Files bleiben leer.
Pruefungen:
- SuS darf neues Projekt anlegen (darf_neues_anlegen).
- Vorlage existiert + ist sichtbar (in_arbeit oder freigegeben).
Returns:
(neue_projekt_id, None) bei Erfolg
(None, fehler_text) bei Fehler
"""
darf, grund = darf_neues_anlegen(conn, profil_id)
if not darf:
return (None, grund)
vorlage = conn.execute(
"SELECT id, titel, status_lebenszyklus, freigabe_status, sichtbar_in_ag "
"FROM projekt WHERE id = ?",
(vorlage_id,)
).fetchone()
if not vorlage or not vorlage["sichtbar_in_ag"]:
return (None, "Vorlage nicht gefunden oder nicht sichtbar.")
# Wartet-auf-Freigabe und Rueckfrage-Projekte koennen NICHT als Vorlage dienen
if vorlage["status_lebenszyklus"] == LEBENSZYKLUS_ABGESCHLOSSEN and vorlage["freigabe_status"] != FREIGABE_FREIGEGEBEN:
return (None, "Diese Vorlage ist noch nicht freigegeben.")
titel_vorschlag = f"Wiederholt: {vorlage['titel']}"
cur = conn.execute(
"INSERT INTO projekt (profil_id, titel, vorlage_projekt_id, status_lebenszyklus, freigabe_status) "
"VALUES (?, ?, ?, 'in_arbeit', 'in_arbeit')",
(profil_id, titel_vorschlag, vorlage_id)
)
conn.commit()
return (cur.lastrowid, None)
def wiederholungen_von(conn, projekt_id: int):
"""Liefert alle Projekte, die dieses als vorlage_projekt_id haben.
Mit Person-Info fuer die Anzeige.
"""
return conn.execute(
"SELECT p.id, p.titel, p.status_lebenszyklus, p.freigabe_status, "
" p.profil_id, pr.vorname, pr.tier "
"FROM projekt p LEFT JOIN profil pr ON pr.id = p.profil_id "
"WHERE p.vorlage_projekt_id = ? "
"ORDER BY p.id",
(projekt_id,)
).fetchall()
def vorlage_info(conn, vorlage_id: int):
"""Daten der Vorlage fuer das Banner im Detail."""
return conn.execute(
"SELECT p.id, p.titel, p.profil_id, pr.vorname, pr.tier "
"FROM projekt p LEFT JOIN profil pr ON pr.id = p.profil_id "
"WHERE p.id = ?",
(vorlage_id,)
).fetchone()
def projekt_dateien_fuer_katalog(conn, projekt_id: int):
"""Nur die Metadaten fuer die Datei-Liste im Katalog-Detail (keine BLOBs)."""
return conn.execute(
"SELECT id, dateiname, mime_type, groesse_bytes, art "
"FROM projekt_datei WHERE projekt_id = ? "
"ORDER BY art, dateiname",
(projekt_id,)
).fetchall()
def review_queue(conn):
"""Alle Projekte, die auf Lehrer-Freigabe warten."""
return conn.execute(
"SELECT p.id, p.titel, p.beschreibung_md, p.abgeschlossen_am, "
" p.selbsteinschaetzung, p.selbsteinschaetzung_planung, "
" p.selbsteinschaetzung_ausfuehrung, p.was_gelernt, p.was_klappte_nicht, "
" p.profil_id, pr.vorname, pr.tier, "
" (p.cover_blob IS NOT NULL) AS hat_cover "
"FROM projekt p LEFT JOIN profil pr ON pr.id = p.profil_id "
"WHERE p.freigabe_status = ? "
"ORDER BY p.abgeschlossen_am ASC",
(FREIGABE_WARTET,)
).fetchall()

117
services/slots.py Normal file
View File

@@ -0,0 +1,117 @@
"""Slot-Helper: Konflikt-Pruefung, Belegung, Validierung.
Reine Daten-Operationen ohne Flask-Abhaengigkeit. Wird von SuS- und
Admin-Routen gleichermassen genutzt.
Konvention: Zeitfelder in der DB sind ISO-Strings (datetime.isoformat()),
in Python arbeiten wir mit datetime-Objekten.
"""
from datetime import datetime, timedelta
# Validierungs-Defaults
MIN_DAUER = timedelta(minutes=15)
MAX_DAUER = timedelta(hours=24)
# Slot-Statuswerte, die gegen Konflikte zaehlen (alles ausser storniert/beendet).
BELEGT_STATUS = ("reserviert", "aktiv")
def parse_datetime_local(wert: str) -> datetime | None:
"""Liest den Wert eines <input type='datetime-local'> (YYYY-MM-DDTHH:MM)."""
if not wert:
return None
try:
return datetime.fromisoformat(wert)
except ValueError:
return None
def validiere_zeitraum(start: datetime, ende: datetime, jetzt: datetime | None = None) -> str | None:
"""Prueft den Zeitraum eines neuen Slots. None = OK, sonst Fehlertext (deutsch)."""
if not start or not ende:
return "Start- und Endzeit muessen gesetzt sein."
if ende <= start:
return "Die Endzeit muss nach der Startzeit liegen."
jetzt = jetzt or datetime.now()
# Toleranz: 5 Minuten in der Vergangenheit erlauben, damit "jetzt"-Buchungen klappen
if start < jetzt - timedelta(minutes=5):
return "Der Slot darf nicht in der Vergangenheit beginnen."
dauer = ende - start
if dauer < MIN_DAUER:
return f"Mindestdauer sind {int(MIN_DAUER.total_seconds() // 60)} Minuten."
if dauer > MAX_DAUER:
return f"Maximaldauer sind {int(MAX_DAUER.total_seconds() // 3600)} Stunden."
return None
def konflikt_check(conn, drucker_id: int, start: datetime, ende: datetime, exclude_slot_id: int | None = None):
"""Liefert eine Liste kollidierender Slots (sqlite3.Row) auf dem Drucker.
Standard-Intervall-Ueberschneidung: Konflikt wenn start_a < ende_b UND start_b < ende_a.
Stornierte/beendete Slots blockieren nicht.
"""
sql = (
"SELECT id, profil_id, start_zeit, ende_zeit, status, notiz "
"FROM slot WHERE drucker_id = ? "
f"AND status IN ({','.join('?' for _ in BELEGT_STATUS)}) "
"AND start_zeit < ? AND ende_zeit > ?"
)
params = [drucker_id, *BELEGT_STATUS, ende.isoformat(), start.isoformat()]
if exclude_slot_id is not None:
sql += " AND id != ?"
params.append(exclude_slot_id)
return conn.execute(sql, params).fetchall()
def aktuelle_belegung(conn, drucker_id: int, jetzt: datetime | None = None):
"""Liefert den gerade aktiven Slot auf dem Drucker (oder None)."""
jetzt = jetzt or datetime.now()
return conn.execute(
f"SELECT s.id, s.profil_id, s.start_zeit, s.ende_zeit, s.status, s.notiz, "
f" p.vorname, p.tier "
f"FROM slot s LEFT JOIN profil p ON p.id = s.profil_id "
f"WHERE s.drucker_id = ? AND s.status IN ({','.join('?' for _ in BELEGT_STATUS)}) "
f"AND s.start_zeit <= ? AND s.ende_zeit > ? "
f"ORDER BY s.start_zeit DESC LIMIT 1",
[drucker_id, *BELEGT_STATUS, jetzt.isoformat(), jetzt.isoformat()]
).fetchone()
def naechste_slots(conn, drucker_id: int, n: int = 5, ab: datetime | None = None):
"""Liefert die naechsten n bevorstehenden Slots (start in der Zukunft)."""
ab = ab or datetime.now()
return conn.execute(
f"SELECT s.id, s.profil_id, s.start_zeit, s.ende_zeit, s.status, s.notiz, "
f" p.vorname, p.tier "
f"FROM slot s LEFT JOIN profil p ON p.id = s.profil_id "
f"WHERE s.drucker_id = ? AND s.status IN ({','.join('?' for _ in BELEGT_STATUS)}) "
f"AND s.start_zeit >= ? "
f"ORDER BY s.start_zeit ASC LIMIT ?",
[drucker_id, *BELEGT_STATUS, ab.isoformat(), n]
).fetchall()
def profil_darf_reservieren(conn, profil_id: int) -> bool:
"""Prueft das Lehrer-Freischalt-Flag fuer dieses Profil."""
if not profil_id:
return False
row = conn.execute(
"SELECT reservieren_freigeschaltet FROM profil WHERE id = ?",
(profil_id,)
).fetchone()
return bool(row and row["reservieren_freigeschaltet"])
def slots_eines_profils(conn, profil_id: int, kommend_nur: bool = True):
"""Slots eines SuS — default nur bevorstehende und aktive, nicht beendete."""
sql = (
"SELECT s.id, s.drucker_id, s.start_zeit, s.ende_zeit, s.status, s.notiz, "
" d.name AS drucker_name, d.modell "
"FROM slot s LEFT JOIN drucker d ON d.id = s.drucker_id "
"WHERE s.profil_id = ? "
)
params: list = [profil_id]
if kommend_nur:
sql += "AND s.status IN ('reserviert', 'aktiv') AND s.ende_zeit >= ? "
params.append(datetime.now().isoformat())
sql += "ORDER BY s.start_zeit ASC"
return conn.execute(sql, params).fetchall()

View File

@@ -5,11 +5,70 @@
<div class="section-header">
<h1>Dashboard</h1>
<div>
<a href="{{ url_for('admin.profile_liste') }}" class="btn btn-secondary">Profile</a>
<a href="{{ url_for('admin.admin_logout') }}" class="btn btn-secondary">Admin-Logout</a>
</div>
</div>
{# ====== Schnellnavigation (Verwaltungs-Karten) ====== #}
<div class="grid"
style="grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 0.8rem; margin-bottom: 1.5rem;">
<a href="{{ url_for('admin.profile_liste') }}" class="card"
style="text-decoration: none; color: inherit; text-align: center; padding: 1.2rem 0.8rem;">
<div style="font-size: 2rem;">👥</div>
<div style="font-weight: 600; margin-top: 0.3rem; color: var(--primary-dark);">SuS-Profile</div>
<div style="font-size: 0.8rem; color: var(--text-light); margin-top: 0.3rem;">Anlegen, PIN, Karten drucken</div>
</a>
<a href="{{ url_for('admin.software_liste') }}" class="card"
style="text-decoration: none; color: inherit; text-align: center; padding: 1.2rem 0.8rem;">
<div style="font-size: 2rem;">🖥️</div>
<div style="font-weight: 600; margin-top: 0.3rem; color: var(--primary-dark);">Software &amp; Generatoren</div>
<div style="font-size: 0.8rem; color: var(--text-light); margin-top: 0.3rem;">Karten, Bilder, Tutorials</div>
</a>
<a href="{{ url_for('admin.admin_lektionen') }}" class="card"
style="text-decoration: none; color: inherit; text-align: center; padding: 1.2rem 0.8rem; opacity: 0.6;"
title="Verwaltung kommt mit Etappe 3-Inline-Edit-UI">
<div style="font-size: 2rem;">📚</div>
<div style="font-weight: 600; margin-top: 0.3rem; color: var(--primary-dark);">Lektionen</div>
<div style="font-size: 0.8rem; color: var(--text-light); margin-top: 0.3rem;">Bearbeiten — kommt</div>
</a>
<a href="{{ url_for('admin.admin_drucker') }}" class="card"
style="text-decoration: none; color: inherit; text-align: center; padding: 1.2rem 0.8rem;">
<div style="font-size: 2rem;">🖨️</div>
<div style="font-weight: 600; margin-top: 0.3rem; color: var(--primary-dark);">Drucker</div>
<div style="font-size: 0.8rem; color: var(--text-light); margin-top: 0.3rem;">Status, Sperre, Links</div>
</a>
<a href="{{ url_for('admin.slot_uebersicht') }}" class="card"
style="text-decoration: none; color: inherit; text-align: center; padding: 1.2rem 0.8rem;">
<div style="font-size: 2rem;">🔖</div>
<div style="font-weight: 600; margin-top: 0.3rem; color: var(--primary-dark);">Reservierungen</div>
<div style="font-size: 0.8rem; color: var(--text-light); margin-top: 0.3rem;">Slot-Plan, fuer SuS anlegen</div>
</a>
<a href="{{ url_for('admin.projekte_uebersicht') }}" class="card"
style="text-decoration: none; color: inherit; text-align: center; padding: 1.2rem 0.8rem;
{% if projekte_warten > 0 %}border-top: 4px solid var(--warning);{% endif %}">
<div style="font-size: 2rem; position: relative; display: inline-block;">
🎯
{% if projekte_warten > 0 %}
<span style="position: absolute; top: -4px; right: -16px; background: var(--warning); color: white;
border-radius: 10px; padding: 0.1rem 0.5rem; font-size: 0.7rem; font-weight: 700;">
{{ projekte_warten }}
</span>
{% endif %}
</div>
<div style="font-weight: 600; margin-top: 0.3rem; color: var(--primary-dark);">Projekte</div>
<div style="font-size: 0.8rem; color: var(--text-light); margin-top: 0.3rem;">
{% if projekte_warten > 0 %}<strong style="color: var(--warning);">{{ projekte_warten }} wartet</strong>
{% else %}alle Projekte ansehen{% endif %}
</div>
</a>
<a href="/admin/feedback" class="card"
style="text-decoration: none; color: inherit; text-align: center; padding: 1.2rem 0.8rem;">
<div style="font-size: 2rem;">🐝</div>
<div style="font-weight: 600; margin-top: 0.3rem; color: var(--primary-dark);">Alph-Feedback</div>
<div style="font-size: 0.8rem; color: var(--text-light); margin-top: 0.3rem;">SuS-Rueckmeldungen</div>
</a>
</div>
{# ====== Schnell-Zahlen ====== #}
<div class="grid" style="grid-template-columns: repeat(3, 1fr); gap: 1rem;">
<div class="stat-card">

View File

@@ -0,0 +1,157 @@
{% extends "base.html" %}
{% block title %}Drucker &mdash; Admin{% endblock %}
{% block content %}
<div class="section-header" style="display:flex; justify-content:space-between; align-items:center;">
<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>
</div>
</div>
<div class="card" style="background:#FEF9E7; border-left: 4px solid var(--warning);">
<strong>Hinweis:</strong> Live-Status via Bambu-Cloud-API kommt mit Etappe 5 (siehe
<code>docs/offene-fragen.md</code> &mdash; Bambu-Account muss noch geklaert werden).
Bis dahin pflegst du den Status hier manuell.
</div>
<div class="grid" style="grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));">
{% for d in drucker_liste %}
<div class="card {% if not d.freigegeben %}drucker-failed{% endif %}"
style="display: flex; flex-direction: column; gap: 0.8rem;
{% if not d.freigegeben %}border-left: 4px solid var(--danger);{% endif %}">
<div style="display: flex; justify-content: space-between; align-items: baseline;">
<h2 style="margin: 0; color: var(--primary-dark);">{{ d.name }}</h2>
<span style="font-size: 0.85rem; color: var(--text-light);">
{{ d.modell }}{% if not d.geschlossenes_gehaeuse %} · <span style="color: var(--danger);">offen</span>{% endif %}
</span>
</div>
{# ============== Status setzen ============== #}
<fieldset style="border: 1px solid var(--border); padding: 0.6rem; border-radius: 4px;">
<legend style="font-size: 0.8rem; color: var(--text-light); padding: 0 0.3rem;">Status</legend>
<form method="post" action="{{ url_for('admin.drucker_status_setzen', drucker_id=d.id) }}"
style="display: grid; gap: 0.4rem;">
<select name="status" style="padding: 0.4rem; border: 1px solid var(--border); border-radius: 4px;">
<option value="">— kein Status —</option>
{% for s, label in status_optionen %}
<option value="{{ s }}" {% if d.status == s %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<input type="text" name="job_name" value="{{ d.job_name or '' }}" placeholder="Job-Name (optional)"
style="padding: 0.4rem; border: 1px solid var(--border); border-radius: 4px;">
<input type="text" name="fehlermeldung" value="{{ d.fehlermeldung or '' }}" placeholder="Fehlermeldung (optional)"
style="padding: 0.4rem; border: 1px solid var(--border); border-radius: 4px;">
<button class="btn btn-primary" style="font-size: 0.85rem;">Status speichern</button>
</form>
{% if d.gepollt_am %}
<div style="font-size: 0.75rem; color: var(--text-light); margin-top: 0.4rem;">
zuletzt: {{ d.gepollt_am[:16].replace('T', ' ') }}
{% if d.manuell_gesetzt %}· manuell{% endif %}
</div>
{% endif %}
</fieldset>
{# ============== Freigabe/Sperre ============== #}
<fieldset style="border: 1px solid var(--border); padding: 0.6rem; border-radius: 4px;">
<legend style="font-size: 0.8rem; color: var(--text-light); padding: 0 0.3rem;">Reservierungs-Freigabe</legend>
{% if d.freigegeben %}
<div style="margin-bottom: 0.4rem; color: var(--success); font-weight: 600;">✅ freigegeben</div>
<form method="post" action="{{ url_for('admin.drucker_freigabe_toggle', drucker_id=d.id) }}"
style="display: grid; gap: 0.4rem;">
<input type="hidden" name="freigeben" value="0">
<input type="text" name="gesperrt_grund" placeholder="Grund fuer Sperrung (z.B. 'Filament-Wechsel')" required
style="padding: 0.4rem; border: 1px solid var(--border); border-radius: 4px;">
<button class="btn btn-secondary" style="font-size: 0.85rem; color: var(--danger);">Sperren</button>
</form>
{% else %}
<div style="margin-bottom: 0.4rem; color: var(--danger); font-weight: 600;">
🔒 gesperrt{% if d.gesperrt_grund %}: {{ d.gesperrt_grund }}{% endif %}
</div>
<form method="post" action="{{ url_for('admin.drucker_freigabe_toggle', drucker_id=d.id) }}">
<input type="hidden" name="freigeben" value="1">
<button class="btn btn-primary" style="font-size: 0.85rem;">Wieder freigeben</button>
</form>
{% endif %}
</fieldset>
{# ============== Inline-Edit: URLs + Notiz ============== #}
<fieldset style="border: 1px solid var(--border); padding: 0.6rem; border-radius: 4px;">
<legend style="font-size: 0.8rem; color: var(--text-light); padding: 0 0.3rem;">
Links &amp; Notiz <span style="font-style: italic;">(klick zum Editieren)</span>
</legend>
<div style="font-size: 0.85rem; margin-bottom: 0.5rem;">
<strong>Manual:</strong>
<span class="edit-feld" data-tabelle="drucker" data-id="{{ d.id }}" data-feld="manual_url"
data-default="{{ d.manual or '' }}"
style="cursor: pointer; border-bottom: 1px dashed var(--text-light);">
{% if d.manual_url %}{{ d.manual_url }}
{% elif d.manual %}<em style="color: var(--text-light);">{{ d.manual }}</em>
{% else %}<em style="color: var(--text-light);">[setzen]</em>{% endif %}
</span>
{% if d.manual %}<a href="{{ d.manual }}" target="_blank" rel="noopener noreferrer" style="font-size: 0.85rem;"></a>{% endif %}
</div>
<div style="font-size: 0.85rem; margin-bottom: 0.5rem;">
<strong>Troubleshooting:</strong>
<span class="edit-feld" data-tabelle="drucker" data-id="{{ d.id }}" data-feld="troubleshooting_url"
style="cursor: pointer; border-bottom: 1px dashed var(--text-light);">
{% if d.troubleshooting_url %}{{ d.troubleshooting_url }}
{% elif d.troubleshooting %}<em style="color: var(--text-light);">{{ d.troubleshooting }}</em>
{% else %}<em style="color: var(--text-light);">[setzen]</em>{% endif %}
</span>
{% if d.troubleshooting %}<a href="{{ d.troubleshooting }}" target="_blank" rel="noopener noreferrer" style="font-size: 0.85rem;"></a>{% endif %}
</div>
<div style="font-size: 0.85rem;">
<strong>Notiz:</strong>
<span class="edit-feld" data-tabelle="drucker" data-id="{{ d.id }}" data-feld="notiz"
style="cursor: pointer; border-bottom: 1px dashed var(--text-light);">
{{ d.notiz or '[setzen]' }}
</span>
</div>
<div style="font-size: 0.75rem; color: var(--text-light); margin-top: 0.5rem; font-style: italic;">
Tipp: Wenn Manual/Troubleshooting leer, wird der Modell-Default benutzt (kursiv angezeigt).
</div>
</fieldset>
</div>
{% endfor %}
</div>
{% endblock %}
{% block scripts %}
{# Minimalistischer Inline-Edit-Handler (prompt-basiert, kein contenteditable).
Kompatibel zum bestehenden /admin/inline-edit-Endpunkt mit der Whitelist
in routes/admin.py. #}
<script>
document.querySelectorAll('.edit-feld').forEach(function(el) {
el.addEventListener('click', function() {
var tabelle = el.dataset.tabelle;
var id = el.dataset.id;
var feld = el.dataset.feld;
var alterWert = el.textContent.trim();
if (alterWert === '[setzen]') alterWert = '';
var neu = prompt('Neuer Wert fuer ' + feld + ':', alterWert);
if (neu === null) return; // abgebrochen
var fd = new FormData();
fd.append('tabelle', tabelle);
fd.append('id', id);
fd.append('feld', feld);
fd.append('wert', neu);
fetch('{{ url_for("admin.inline_edit") }}', {method: 'POST', body: fd})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.ok) { location.reload(); }
else { alert('Fehler: ' + (data.error || 'unbekannt')); }
})
.catch(function() { alert('Verbindungsfehler'); });
});
});
</script>
{% endblock %}

View File

@@ -54,6 +54,7 @@
<th>Tier</th>
<th>AG</th>
<th>Status</th>
<th>Reservieren</th>
<th>Angelegt</th>
<th>Aktionen</th>
</tr>
@@ -66,6 +67,17 @@
<td>{{ tier_name(p.tier) }}</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>
<form method="post" action="{{ url_for('admin.profil_reservieren_toggle', profil_id=p.id) }}" style="display:inline;">
{% if p.reservieren_freigeschaltet %}
<button class="btn btn-secondary" style="padding: 0.3rem 0.6rem; font-size: 0.8rem;"
title="Klicken zum Sperren">✅ freigeschaltet</button>
{% else %}
<button class="btn btn-secondary" style="padding: 0.3rem 0.6rem; font-size: 0.8rem; color: var(--text-light);"
title="Klicken zum Freischalten">⚪ noch nicht</button>
{% endif %}
</form>
</td>
<td style="font-size: 0.85rem; color: var(--text-light);">{{ p.erstellt_am[:10] if p.erstellt_am else '' }}</td>
<td>
<form method="post" action="{{ url_for('admin.profil_pin_reset', profil_id=p.id) }}" style="display:inline;"

View File

@@ -0,0 +1,61 @@
{% extends "base.html" %}
{% block title %}Projekt-Freigabe &mdash; Admin{% endblock %}
{% block content %}
<div class="section-header" style="display:flex; justify-content:space-between; align-items:center;">
<h1>Projekte: Wartend auf Freigabe</h1>
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-secondary">&larr; Dashboard</a>
</div>
<p style="color: var(--text-light);">
SuS haben diese Projekte als "Gelernt" (Misserfolg) abgeschlossen — schau drueber, dann freigeben
oder Rueckfrage stellen. Erst danach darf der SuS ein neues Projekt anfangen.
</p>
{% if queue %}
<div class="grid" style="grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));">
{% for p in queue %}
<a href="{{ url_for('admin.projekt_review', projekt_id=p.id) }}" class="card"
style="text-decoration: none; color: inherit; display: flex; flex-direction: column; gap: 0.6rem;
border-left: 4px solid var(--warning);">
{% if p.hat_cover %}
<img src="{{ url_for('api.projekt_cover', projekt_id=p.id) }}" alt=""
style="width: 100%; height: 160px; object-fit: cover; border-radius: 6px;">
{% else %}
<div style="height: 160px; background: linear-gradient(135deg, #ECF0F1, #D5DBDB); border-radius: 6px;
display: flex; align-items: center; justify-content: center; font-size: 2.5rem; color: var(--text-light);">
🎯
</div>
{% endif %}
<h3 style="margin: 0; color: var(--primary-dark);">{{ p.titel }}</h3>
<div style="font-size: 0.9rem;">
{{ tier_emoji(p.tier or '') }} <strong>{{ p.vorname or '—' }}</strong>
</div>
<div style="display: flex; gap: 0.3rem; flex-wrap: wrap; font-size: 0.85rem;">
<span class="badge badge-fortgeschritten">🔴 gelernt</span>
{% if p.selbsteinschaetzung_planung == 'schwierig' %}<span class="badge badge-fortgeschritten">Planung 🔴</span>
{% elif p.selbsteinschaetzung_planung == 'ok' %}<span class="badge badge-mittel">Planung 🟡</span>
{% elif p.selbsteinschaetzung_planung == 'gut' %}<span class="badge badge-anfaenger">Planung 🟢</span>{% endif %}
{% if p.selbsteinschaetzung_ausfuehrung == 'schwierig' %}<span class="badge badge-fortgeschritten">Ausf. 🔴</span>
{% elif p.selbsteinschaetzung_ausfuehrung == 'ok' %}<span class="badge badge-mittel">Ausf. 🟡</span>
{% elif p.selbsteinschaetzung_ausfuehrung == 'gut' %}<span class="badge badge-anfaenger">Ausf. 🟢</span>{% endif %}
</div>
{% if p.was_klappte_nicht %}
<div style="font-size: 0.85rem; color: var(--text-light); font-style: italic;
max-height: 3em; overflow: hidden;">
"{{ p.was_klappte_nicht }}"
</div>
{% endif %}
<div style="font-size: 0.8rem; color: var(--text-light); margin-top: auto;">
abgeschlossen {{ p.abgeschlossen_am[:10] if p.abgeschlossen_am else '' }}
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="card">
<p style="color: var(--text-light);">Keine offenen Reviews. 👍</p>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,152 @@
{% extends "base.html" %}
{% block title %}Review: {{ p.titel }}{% endblock %}
{% block content %}
<div class="section-header" style="display:flex; justify-content:space-between; align-items:center;">
<h1>Review: {{ p.titel }}</h1>
<a href="{{ url_for('admin.projekt_freigabe') }}" class="btn btn-secondary">&larr; Review-Queue</a>
</div>
<div style="display: grid; grid-template-columns: 280px 1fr; gap: 1rem;">
{# ============== Cover ============== #}
<div class="card" style="text-align: center;">
{% if p.hat_cover %}
<img src="{{ url_for('api.projekt_cover', projekt_id=p.id) }}" alt=""
style="width: 100%; max-height: 240px; object-fit: cover; border-radius: 6px;">
{% else %}
<div style="height: 200px; background: linear-gradient(135deg, #ECF0F1, #D5DBDB); border-radius: 6px;
display: flex; align-items: center; justify-content: center; font-size: 3rem; color: var(--text-light);">
🎯
</div>
{% endif %}
{% if profil %}
<div style="margin-top: 0.8rem;">
<div style="font-size: 1.4rem;">{{ tier_emoji(profil.tier) }}</div>
<strong>{{ profil.vorname }}</strong>
</div>
{% endif %}
<div style="font-size: 0.8rem; color: var(--text-light); margin-top: 0.4rem;">
abgeschlossen {{ p.abgeschlossen_am[:16].replace('T', ' ') if p.abgeschlossen_am else '—' }}
</div>
</div>
<div>
{# ============== Beschreibung ============== #}
<div class="card">
<h2>Beschreibung</h2>
{% if p.beschreibung_md %}
<div>{{ p.beschreibung_md|markdown }}</div>
{% else %}
<p style="color: var(--text-light);"><em>Keine Beschreibung.</em></p>
{% endif %}
</div>
{# ============== Selbstbewertung ============== #}
<div class="card">
<h2>Selbstbewertung</h2>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.6rem; margin-bottom: 1rem;">
<div style="text-align: center; padding: 0.6rem; background: #F4F6F7; border-radius: 6px;">
<div style="font-size: 0.75rem; color: var(--text-light);">Gesamt</div>
<div style="font-size: 1.4rem; margin-top: 0.2rem;">
{% if p.selbsteinschaetzung == 'zufrieden' %}🟢
{% elif p.selbsteinschaetzung == 'mittel' %}🟡
{% elif p.selbsteinschaetzung == 'gelernt' %}🔴{% endif %}
</div>
<div style="font-size: 0.85rem; margin-top: 0.2rem;">{{ p.selbsteinschaetzung or '—' }}</div>
</div>
<div style="text-align: center; padding: 0.6rem; background: #F4F6F7; border-radius: 6px;">
<div style="font-size: 0.75rem; color: var(--text-light);">Planung</div>
<div style="font-size: 1.4rem; margin-top: 0.2rem;">
{% if p.selbsteinschaetzung_planung == 'gut' %}🟢
{% elif p.selbsteinschaetzung_planung == 'ok' %}🟡
{% elif p.selbsteinschaetzung_planung == 'schwierig' %}🔴
{% else %}—{% endif %}
</div>
<div style="font-size: 0.85rem; margin-top: 0.2rem;">{{ p.selbsteinschaetzung_planung or '—' }}</div>
</div>
<div style="text-align: center; padding: 0.6rem; background: #F4F6F7; border-radius: 6px;">
<div style="font-size: 0.75rem; color: var(--text-light);">Ausfuehrung</div>
<div style="font-size: 1.4rem; margin-top: 0.2rem;">
{% if p.selbsteinschaetzung_ausfuehrung == 'gut' %}🟢
{% elif p.selbsteinschaetzung_ausfuehrung == 'ok' %}🟡
{% elif p.selbsteinschaetzung_ausfuehrung == 'schwierig' %}🔴
{% else %}—{% endif %}
</div>
<div style="font-size: 0.85rem; margin-top: 0.2rem;">{{ p.selbsteinschaetzung_ausfuehrung or '—' }}</div>
</div>
</div>
{% if p.was_gelernt %}
<div style="margin-bottom: 0.6rem;">
<strong>Was gelernt:</strong>
<div style="margin-top: 0.2rem;">{{ p.was_gelernt }}</div>
</div>
{% endif %}
{% if p.was_klappte_nicht %}
<div>
<strong>Was klappte nicht:</strong>
<div style="margin-top: 0.2rem;">{{ p.was_klappte_nicht }}</div>
</div>
{% endif %}
</div>
{# ============== Rueckfragen-Historie ============== #}
{% if rueckfragen %}
<div class="card">
<h2>Rueckfragen-Historie</h2>
{% for r in rueckfragen %}
<div style="border-left: 3px solid {% if r.status == 'offen' %}var(--warning){% else %}var(--success){% endif %};
padding: 0.5rem 0.8rem; margin-bottom: 0.5rem; background: #F4F6F7; border-radius: 4px;">
<div style="font-size: 0.85rem; color: var(--text-light); margin-bottom: 0.2rem;">
{{ r.erstellt_am[:16].replace('T', ' ') }}{% if r.status == 'erledigt' %} · ✓ erledigt{% endif %}
</div>
<div>{{ r.text }}</div>
</div>
{% endfor %}
</div>
{% endif %}
{# ============== Aktionen ============== #}
<div class="card" style="border-top: 4px solid var(--primary);">
<h2>Deine Entscheidung</h2>
<form method="post" action="{{ url_for('admin.projekt_freigeben', projekt_id=p.id) }}"
style="display: inline-block; margin-right: 0.5rem;">
<button class="btn btn-primary" style="padding: 0.7rem 1.5rem;">
✅ Freigeben — SuS darf neues anfangen
</button>
</form>
<details style="margin-top: 1rem;">
<summary style="cursor: pointer; color: var(--primary); font-weight: 600;">
❓ Rueckfrage stellen
</summary>
<form method="post" action="{{ url_for('admin.projekt_rueckfrage', projekt_id=p.id) }}"
style="margin-top: 0.5rem; display: grid; gap: 0.5rem;">
<textarea name="text" required rows="3" maxlength="500"
placeholder="z.B. 'Schreib mir bitte 2-3 Saetze dazu, was beim Slicen schief lief.'"
style="padding: 0.6rem; border: 1px solid var(--border); border-radius: 4px; font-family: inherit;"></textarea>
<div>
<button class="btn btn-secondary">Rueckfrage abschicken</button>
</div>
</form>
</details>
<details style="margin-top: 1rem;">
<summary style="cursor: pointer; color: var(--danger); font-weight: 600;">
🗑️ Hart loeschen
</summary>
<p style="font-size: 0.85rem; color: var(--text-light); margin-top: 0.5rem;">
Nur fuer Spam, Penismodelle, Copyright-Verletzungen. Wird im aenderung_log dokumentiert.
</p>
<form method="post" action="{{ url_for('admin.projekt_admin_loeschen', projekt_id=p.id) }}"
onsubmit="return confirm('Projekt {{ p.titel }} wirklich UNWIDERRUFLICH loeschen?')"
style="margin-top: 0.5rem;">
<button class="btn btn-secondary" style="color: var(--danger);">Endgueltig loeschen</button>
</form>
</details>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,90 @@
{% extends "base.html" %}
{% block title %}Projekte &mdash; Admin{% endblock %}
{% block content %}
<div class="section-header" style="display:flex; justify-content:space-between; align-items:center;">
<h1>Projekte</h1>
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-secondary">&larr; Dashboard</a>
</div>
{# ============== Filter-Buttons ============== #}
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem;">
{% set filter_links = [
('alle', 'Alle', zaehler.alle),
('wartet', '⏳ wartet auf Freigabe', zaehler.wartet),
('rueckfrage', '❓ Rueckfrage', zaehler.rueckfrage),
('in_arbeit', '🔧 in Arbeit', zaehler.in_arbeit),
('abgeschlossen', '✅ abgeschlossen', zaehler.abgeschlossen),
] %}
{% for key, label, anzahl in filter_links %}
<a href="{{ url_for('admin.projekte_uebersicht', filter=key) }}"
class="btn {% if aktueller_filter == key %}btn-primary{% else %}btn-secondary{% endif %}"
style="font-size: 0.85rem;">
{{ label }} <span style="opacity: 0.7;">({{ anzahl }})</span>
</a>
{% endfor %}
</div>
{% if projekte %}
<div class="grid" style="grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));">
{% for p in projekte %}
<a href="{{ url_for('admin.projekt_review', projekt_id=p.id) }}" class="card"
style="text-decoration: none; color: inherit; display: flex; flex-direction: column; gap: 0.5rem;
{% if p.freigabe_status == 'wartet_auf_freigabe' %}border-left: 4px solid var(--warning);
{% elif p.freigabe_status == 'rueckfrage' %}border-left: 4px solid var(--danger);
{% elif p.status_lebenszyklus == 'in_arbeit' %}border-left: 4px solid var(--primary);
{% endif %}">
{% if p.hat_cover %}
<img src="{{ url_for('api.projekt_cover', projekt_id=p.id) }}" alt=""
style="width: 100%; height: 140px; object-fit: cover; border-radius: 6px;">
{% else %}
<div style="height: 140px; background: linear-gradient(135deg, #ECF0F1, #D5DBDB); border-radius: 6px;
display: flex; align-items: center; justify-content: center; font-size: 2rem; color: var(--text-light);">
🎯
</div>
{% endif %}
<h3 style="margin: 0; color: var(--primary-dark);">{{ p.titel }}</h3>
<div style="font-size: 0.85rem;">
{{ tier_emoji(p.tier or '') }} <strong>{{ p.vorname or '—' }}</strong>
</div>
<div style="display: flex; gap: 0.3rem; flex-wrap: wrap; font-size: 0.8rem;">
{% if p.status_lebenszyklus == 'in_arbeit' %}
<span class="badge badge-mittel">🔧 in Arbeit</span>
{% else %}
<span class="badge badge-anfaenger">✅ abgeschlossen</span>
{% endif %}
{% if p.freigabe_status == 'wartet_auf_freigabe' %}
<span class="badge badge-mittel">⏳ wartet</span>
{% elif p.freigabe_status == 'rueckfrage' %}
<span class="badge badge-fortgeschritten">❓ Rueckfrage</span>
{% elif p.freigabe_status == 'freigegeben' %}
<span class="badge badge-anfaenger">✓ frei</span>
{% endif %}
{% if p.selbsteinschaetzung == 'zufrieden' %}<span class="badge badge-anfaenger">🟢</span>
{% elif p.selbsteinschaetzung == 'mittel' %}<span class="badge badge-mittel">🟡</span>
{% elif p.selbsteinschaetzung == 'gelernt' %}<span class="badge badge-fortgeschritten">🔴</span>{% endif %}
</div>
<div style="font-size: 0.75rem; color: var(--text-light); margin-top: auto;">
{% if p.abgeschlossen_am %}abgeschlossen {{ p.abgeschlossen_am[:10] }}
{% else %}geaendert {{ p.geaendert_am[:10] }}{% endif %}
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="card">
<p style="color: var(--text-light);">
Keine Projekte
{% if aktueller_filter != 'alle' %} mit Filter "{{ aktueller_filter }}"
<a href="{{ url_for('admin.projekte_uebersicht', filter='alle') }}">alle anzeigen</a>{% endif %}.
</p>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,98 @@
{% extends "base.html" %}
{% block title %}Slot fuer SuS anlegen &mdash; Admin{% endblock %}
{% block content %}
<div class="section-header" style="display:flex; justify-content:space-between; align-items:center;">
<h1>Slot fuer einen SuS anlegen</h1>
<a href="{{ url_for('admin.slot_uebersicht') }}" class="btn btn-secondary">&larr; Slot-Uebersicht</a>
</div>
{% if not profile %}
<div class="card" style="background:#FADBD8; border-left: 4px solid var(--danger);">
<strong>Keine aktiven SuS-Profile.</strong>
<a href="{{ url_for('admin.profile_liste') }}">Erst ein Profil anlegen.</a>
</div>
{% elif not drucker_liste %}
<div class="card" style="background:#FADBD8; border-left: 4px solid var(--danger);">
<strong>Keine Drucker eingerichtet.</strong>
</div>
{% else %}
<div class="card">
<form method="post" style="display: grid; gap: 1rem;">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
<div>
<label style="display:block; font-size: 0.9rem; color: var(--text-light); margin-bottom: 0.3rem;">
SuS-Profil *
</label>
<select name="profil_id" required
style="padding: 0.6rem; width: 100%; border: 1px solid var(--border); border-radius: 4px; font-size: 1rem;">
<option value="">— waehlen —</option>
{% for p in profile %}
<option value="{{ p.id }}" {% if vor_profil == p.id %}selected{% endif %}>
{{ tier_emoji(p.tier) }} {{ p.vorname }}
</option>
{% endfor %}
</select>
</div>
<div>
<label style="display:block; font-size: 0.9rem; color: var(--text-light); margin-bottom: 0.3rem;">
Drucker *
</label>
<select name="drucker_id" required
style="padding: 0.6rem; width: 100%; border: 1px solid var(--border); border-radius: 4px; font-size: 1rem;">
<option value="">— waehlen —</option>
{% for d in drucker_liste %}
<option value="{{ d.id }}" {% if vor_drucker == d.id %}selected{% endif %}>
{{ d.name }} ({{ d.modell }}){% if not d.freigegeben %} 🔒 gesperrt{% endif %}
</option>
{% endfor %}
</select>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
<div>
<label style="display:block; font-size: 0.9rem; color: var(--text-light); margin-bottom: 0.3rem;">
Start *
</label>
<input type="datetime-local" name="start_zeit" required step="900"
value="{{ default_start.strftime('%Y-%m-%dT%H:%M') }}"
style="padding: 0.6rem; width: 100%; border: 1px solid var(--border); border-radius: 4px; font-size: 1rem;">
</div>
<div>
<label style="display:block; font-size: 0.9rem; color: var(--text-light); margin-bottom: 0.3rem;">
Ende *
</label>
<input type="datetime-local" name="ende_zeit" required step="900"
value="{{ default_ende.strftime('%Y-%m-%dT%H:%M') }}"
style="padding: 0.6rem; width: 100%; border: 1px solid var(--border); border-radius: 4px; font-size: 1rem;">
</div>
</div>
<div>
<label style="display:block; font-size: 0.9rem; color: var(--text-light); margin-bottom: 0.3rem;">
Notiz (optional)
</label>
<input type="text" name="notiz" maxlength="200"
placeholder="z.B. 'Druck ueber Nacht', 'Lina ist heute krank, ich starte fuer sie'"
style="padding: 0.6rem; width: 100%; border: 1px solid var(--border); border-radius: 4px; font-size: 0.95rem;">
</div>
<div style="background: #EBF5FB; padding: 0.6rem 0.8rem; border-radius: 4px; font-size: 0.85rem; color: var(--text);">
<strong>Hinweis:</strong> Du kannst auch fuer SuS reservieren, die noch nicht freigeschaltet
sind, und auch fuer gesperrte Drucker (du bekommst dann eine Warnung).
</div>
<div style="display: flex; justify-content: space-between;">
<a href="{{ url_for('admin.slot_uebersicht') }}" class="btn btn-secondary">Abbrechen</a>
<button class="btn btn-primary">Slot anlegen</button>
</div>
</form>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,78 @@
{% extends "base.html" %}
{% block title %}Slots &mdash; Admin{% endblock %}
{% block content %}
<div class="section-header" style="display:flex; justify-content:space-between; align-items:center;">
<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>
</div>
</div>
<p style="color: var(--text-light);">
Alle Reservierungen, deren Ende noch nicht laenger als 2 Stunden her ist. Sortiert nach Start-Zeit.
</p>
{% if slots %}
<div class="card">
<table>
<thead>
<tr>
<th>Start</th>
<th>Ende</th>
<th>Dauer</th>
<th>Drucker</th>
<th>SuS</th>
<th>Status</th>
<th>Notiz</th>
<th></th>
</tr>
</thead>
<tbody>
{% for s in slots %}
<tr {% if s.status == 'storniert' %}style="opacity: 0.5;"{% endif %}>
<td><strong>{{ s.start_zeit|iso_format('%d.%m. %H:%M') }}</strong></td>
<td>{{ s.ende_zeit|iso_format('%H:%M') }}</td>
<td>{{ s.start_zeit|dauer_min(s.ende_zeit) }} min</td>
<td>
{{ s.drucker_name or '—' }}
{% if s.drucker_name and not s.freigegeben %}
<div style="font-size: 0.75rem; color: var(--danger);">🔒 Drucker gesperrt</div>
{% endif %}
</td>
<td>
{% if s.profil_id %}
{{ tier_emoji(s.tier or '') }} <strong>{{ s.vorname or '—' }}</strong>
{% else %}
<em style="color: var(--text-light);">unbekannt</em>
{% endif %}
</td>
<td>
{% if s.status == 'reserviert' %}<span class="badge badge-mittel">reserviert</span>
{% elif s.status == 'aktiv' %}<span class="badge badge-anfaenger">aktiv</span>
{% elif s.status == 'beendet' %}<span class="badge">beendet</span>
{% elif s.status == 'storniert' %}<span class="badge">storniert</span>
{% else %}<span class="badge">{{ s.status }}</span>{% endif %}
</td>
<td style="font-size: 0.85rem; color: var(--text-light);">{{ s.notiz or '' }}</td>
<td>
<form method="post" action="{{ url_for('admin.slot_loeschen', slot_id=s.id) }}" style="display:inline;"
onsubmit="return confirm('Slot von {{ s.vorname or s.id }} wirklich loeschen?')">
<button class="btn btn-secondary" style="padding: 0.3rem 0.6rem; font-size: 0.8rem; color: var(--danger);">loeschen</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card">
<p style="color: var(--text-light);">Keine bevorstehenden Reservierungen.
<a href="{{ url_for('admin.slot_neu_lehrer') }}">Eine fuer einen SuS anlegen.</a>
</p>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,178 @@
{% extends "base.html" %}
{% block title %}Software &mdash; Admin{% endblock %}
{% block content %}
<div class="section-header" style="display:flex; justify-content:space-between; align-items:center;">
<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>
</div>
</div>
<!-- Liste -->
<div class="card">
<h2>Alle Karten</h2>
{% if karten %}
<table>
<thead>
<tr>
<th></th>
<th>Titel</th>
<th>Slug</th>
<th>Kategorie</th>
<th>Sort.</th>
<th>Plattform</th>
<th>Hauptlink</th>
<th>Status</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for k in karten %}
<tr style="{% if not k.aktiv %}opacity: 0.5;{% endif %}">
<td style="font-size: 1.4rem;">
{%- if k.kategorie == 'app' -%}📱
{%- elif k.kategorie == 'generator' -%}🧩
{%- else -%}🖥️
{%- endif -%}
</td>
<td><strong>{{ k.titel }}</strong>
<div style="font-size: 0.8rem; color: var(--text-light); margin-top: 0.2rem;">{{ k.kurzbeschreibung }}</div>
</td>
<td><code style="font-size: 0.8rem;">{{ k.slug }}</code></td>
<td>{{ k.kategorie }}</td>
<td>{{ k.sortierung }}</td>
<td style="font-size: 0.8rem; color: var(--text-light);">{{ k.plattform or '—' }}</td>
<td style="font-size: 0.8rem;">
{% if k.url %}<a href="{{ k.url }}" target="_blank" rel="noopener noreferrer"></a>{% endif %}
{% if k.tutorial_url %} <a href="{{ k.tutorial_url }}" target="_blank" rel="noopener noreferrer" title="Tutorial">T↗</a>{% endif %}
{% if k.app_store_url %} <a href="{{ k.app_store_url }}" target="_blank" rel="noopener noreferrer" title="App Store"></a>{% endif %}
{% if k.play_store_url %} <a href="{{ k.play_store_url }}" target="_blank" rel="noopener noreferrer" title="Google Play"></a>{% endif %}
</td>
<td>
{% if k.aktiv %}<span class="badge badge-anfaenger">aktiv</span>
{% else %}<span class="badge">inaktiv</span>{% endif %}
</td>
<td>
<a href="{{ url_for('admin.software_bearbeiten', karte_id=k.id) }}"
class="btn btn-primary" style="padding: 0.3rem 0.6rem; font-size: 0.8rem;">bearbeiten</a>
<form method="post" action="{{ url_for('admin.software_aktiv_toggle', karte_id=k.id) }}" style="display:inline;">
<button class="btn btn-secondary" style="padding: 0.3rem 0.6rem; font-size: 0.8rem;">
{% if k.aktiv %}aus{% else %}an{% endif %}
</button>
</form>
<form method="post" action="{{ url_for('admin.software_loeschen', karte_id=k.id) }}" style="display:inline;"
onsubmit="return confirm('Karte {{ k.titel }} wirklich loeschen? Das kann nicht rueckgaengig gemacht werden.')">
<button class="btn btn-secondary" style="padding: 0.3rem 0.6rem; font-size: 0.8rem; color: var(--danger);">loeschen</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color: var(--text-light);">Noch keine Karten. Leg unten die erste an, oder fuehre die Initial-Migration aus
(<code>migrations/2026-05-25_software_sektion.py</code>).</p>
{% endif %}
</div>
<!-- Neue Karte -->
<div class="card">
<h2>Neue Karte anlegen</h2>
<form method="post" action="{{ url_for('admin.software_neu') }}" style="display: grid; gap: 0.8rem;">
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.8rem;">
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">Slug (URL-Identifier, klein, mit Bindestrichen) *</label>
<input type="text" name="slug" required pattern="[a-z0-9-]+"
placeholder="z.B. fusion360"
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);">Titel *</label>
<input type="text" name="titel" required
placeholder="z.B. Fusion 360"
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);">Kategorie *</label>
<select name="kategorie" required
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
{% for cat in kategorien %}
<option value="{{ cat }}">{{ cat }}</option>
{% endfor %}
</select>
</div>
</div>
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">Kurzbeschreibung (1-2 Saetze, erscheint auf der Karte) *</label>
<input type="text" name="kurzbeschreibung" required maxlength="200"
placeholder="z.B. Profi-CAD-Software, fuer Schueler kostenlos."
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.8rem;">
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">Hauptlink (Startseite / Download) *</label>
<input type="url" name="url" required
placeholder="https://..."
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);">Tutorial-Link (optional)</label>
<input type="url" name="tutorial_url"
placeholder="https://..."
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
</div>
</div>
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 0.8rem;">
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">Plattform (Klartext)</label>
<input type="text" name="plattform"
placeholder="z.B. Browser oder Windows / macOS / Linux"
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);">Sortierung (kleiner = weiter oben)</label>
<input type="number" name="sortierung" value="100" min="0" max="9999"
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
</div>
</div>
<fieldset style="border: 1px dashed var(--border); padding: 0.8rem; border-radius: 4px;">
<legend style="font-size: 0.85rem; color: var(--text-light); padding: 0 0.4rem;">Nur bei Apps</legend>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.8rem;">
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">App Store URL</label>
<input type="url" name="app_store_url"
placeholder="https://apps.apple.com/..."
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);">Google Play URL</label>
<input type="url" name="play_store_url"
placeholder="https://play.google.com/..."
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
</div>
</div>
</fieldset>
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">Bild-Pfad (relativ zu <code>static/img/software/</code>, optional)</label>
<input type="text" name="bild_pfad"
placeholder="z.B. fusion360.png"
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
<p style="font-size: 0.8rem; color: var(--text-light); margin-top: 0.3rem;">
Erst das Bild ueber FTP / SCP nach <code>static/img/software/</code> kopieren, dann den Dateinamen hier eintragen.
</p>
</div>
<div style="display: flex; justify-content: flex-end;">
<button class="btn btn-primary">Karte anlegen</button>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,354 @@
{% extends "base.html" %}
{% block title %}Bild: {{ karte.titel }} &mdash; Admin{% endblock %}
{% block content %}
<p><a href="{{ url_for('admin.software_bearbeiten', karte_id=karte.id) }}">&larr; Zurueck zur Karte</a></p>
<div class="card">
<h1>Bild fuer: {{ karte.titel }}</h1>
<p style="color: var(--text-light); font-size: 0.9rem;">
Slug: <code>{{ karte.slug }}</code> &middot; Kategorie: {{ karte.kategorie }}
</p>
</div>
<!-- Paste-Bereich -->
<div class="card" id="paste-bereich">
<h2>1. Screenshot einfuegen</h2>
<p style="color: var(--text-light); margin-bottom: 1rem;">
Kopiere ein Bild in die Zwischenablage (z.B. Screenshot mit Snipping Tool)
und druecke <kbd style="background:#eee;padding:0.2rem 0.5rem;border-radius:4px;border:1px solid #ccc;">Strg+V</kbd>
hier auf der Seite. Oder waehle eine Datei aus.
</p>
<div id="drop-zone" style="border: 3px dashed var(--border); border-radius: 12px; padding: 3rem;
text-align: center; cursor: pointer; transition: border-color 0.2s; background: #fafafa;">
<p style="font-size: 1.2rem; color: var(--text-light);">Strg+V zum Einfuegen</p>
<p style="font-size: 0.85rem; color: var(--text-light);">oder klicken um Datei auszuwaehlen</p>
<input type="file" id="datei-input" accept="image/*" style="display: none;">
</div>
</div>
<!-- Crop-Bereich (anfangs versteckt) -->
<div class="card" id="crop-bereich" style="display: none;">
<h2>2. Ausschnitt waehlen</h2>
<p style="color: var(--text-light); margin-bottom: 1rem;">
Ziehe das Quadrat auf den gewuenschten Bildausschnitt. Ecken zum Vergroessern/Verkleinern ziehen.
</p>
<div style="display: flex; gap: 2rem; flex-wrap: wrap;">
<!-- Quellbild mit Crop-Overlay -->
<div style="flex: 1; min-width: 300px;">
<div id="crop-container" style="position: relative; display: inline-block; max-width: 100%; cursor: crosshair;">
<canvas id="quell-canvas" style="max-width: 100%; display: block;"></canvas>
<div id="crop-box" style="position: absolute; border: 3px solid var(--primary);
background: rgba(0,151,157,0.1); cursor: move; display: none;">
<div class="resize-handle" data-pos="tl" style="position:absolute;top:-6px;left:-6px;width:12px;height:12px;background:var(--primary);border-radius:50%;cursor:nw-resize;"></div>
<div class="resize-handle" data-pos="tr" style="position:absolute;top:-6px;right:-6px;width:12px;height:12px;background:var(--primary);border-radius:50%;cursor:ne-resize;"></div>
<div class="resize-handle" data-pos="bl" style="position:absolute;bottom:-6px;left:-6px;width:12px;height:12px;background:var(--primary);border-radius:50%;cursor:sw-resize;"></div>
<div class="resize-handle" data-pos="br" style="position:absolute;bottom:-6px;right:-6px;width:12px;height:12px;background:var(--primary);border-radius:50%;cursor:se-resize;"></div>
</div>
</div>
</div>
<!-- Vorschau -->
<div style="min-width: 200px; text-align: center;">
<h3>Vorschau</h3>
<canvas id="vorschau-canvas" width="200" height="200"
style="border: 1px solid var(--border); border-radius: 8px; background: #f0f0f0;"></canvas>
</div>
</div>
<div style="margin-top: 1.5rem; display: flex; gap: 1rem;">
<button onclick="speichern()" class="btn btn-primary" id="speichern-btn"
style="padding: 0.8rem 2rem; font-size: 1rem;">Bild speichern</button>
<button onclick="zuruecksetzen()" class="btn btn-secondary">Neues Bild</button>
</div>
</div>
<!-- Erfolg -->
<div class="card" id="erfolg-bereich" style="display: none; border-left: 4px solid var(--success);">
<h2 style="color: var(--success);">Bild gespeichert!</h2>
<p>Das Bild fuer <strong>{{ karte.titel }}</strong> wurde erfolgreich gespeichert.</p>
<div style="margin-top: 1rem;">
<a href="{{ url_for('admin.software_bearbeiten', karte_id=karte.id) }}" class="btn btn-primary">Zurueck zur Karte</a>
<a href="{{ url_for('admin.software_liste') }}" class="btn btn-secondary">Alle Karten</a>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
var quellCanvas = document.getElementById('quell-canvas');
var quellCtx = quellCanvas.getContext('2d');
var vorschauCanvas = document.getElementById('vorschau-canvas');
var vorschauCtx = vorschauCanvas.getContext('2d');
var cropBox = document.getElementById('crop-box');
var cropContainer = document.getElementById('crop-container');
var originalBild = null;
var skalierung = 1;
var crop = { x: 0, y: 0, size: 100 };
var dragging = false;
var resizing = false;
var resizePos = '';
var startX, startY, startCropX, startCropY, startCropSize;
function bildLaden(quelle) {
var img = new Image();
img.onload = function() {
originalBild = img;
var maxBreite = 600;
skalierung = Math.min(1, maxBreite / img.width);
quellCanvas.width = img.width * skalierung;
quellCanvas.height = img.height * skalierung;
quellCtx.drawImage(img, 0, 0, quellCanvas.width, quellCanvas.height);
var minSeite = Math.min(quellCanvas.width, quellCanvas.height);
crop.size = Math.round(minSeite * 0.6);
crop.x = Math.round((quellCanvas.width - crop.size) / 2);
crop.y = Math.round((quellCanvas.height - crop.size) / 2);
cropBoxAktualisieren();
cropBox.style.display = 'block';
vorschauAktualisieren();
document.getElementById('paste-bereich').style.display = 'none';
document.getElementById('crop-bereich').style.display = 'block';
};
img.src = quelle;
}
document.addEventListener('paste', function(e) {
if (originalBild) return;
var items = e.clipboardData.items;
for (var i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') >= 0) {
var blob = items[i].getAsFile();
var reader = new FileReader();
reader.onload = function(ev) { bildLaden(ev.target.result); };
reader.readAsDataURL(blob);
e.preventDefault();
return;
}
}
});
var dropZone = document.getElementById('drop-zone');
var dateiInput = document.getElementById('datei-input');
dropZone.addEventListener('click', function() { dateiInput.click(); });
dateiInput.addEventListener('change', function() {
if (this.files[0]) {
var reader = new FileReader();
reader.onload = function(ev) { bildLaden(ev.target.result); };
reader.readAsDataURL(this.files[0]);
}
});
dropZone.addEventListener('dragover', function(e) {
e.preventDefault();
this.style.borderColor = 'var(--primary)';
this.style.background = '#EBF5FB';
});
dropZone.addEventListener('dragleave', function() {
this.style.borderColor = 'var(--border)';
this.style.background = '#fafafa';
});
dropZone.addEventListener('drop', function(e) {
e.preventDefault();
this.style.borderColor = 'var(--border)';
this.style.background = '#fafafa';
if (e.dataTransfer.files[0] && e.dataTransfer.files[0].type.indexOf('image') >= 0) {
var reader = new FileReader();
reader.onload = function(ev) { bildLaden(ev.target.result); };
reader.readAsDataURL(e.dataTransfer.files[0]);
}
});
function cropBoxAktualisieren() {
cropBox.style.left = crop.x + 'px';
cropBox.style.top = crop.y + 'px';
cropBox.style.width = crop.size + 'px';
cropBox.style.height = crop.size + 'px';
}
function vorschauAktualisieren() {
if (!originalBild) return;
var sx = crop.x / skalierung;
var sy = crop.y / skalierung;
var ss = crop.size / skalierung;
vorschauCtx.clearRect(0, 0, 200, 200);
vorschauCtx.drawImage(originalBild, sx, sy, ss, ss, 0, 0, 200, 200);
}
function begrenzen() {
var maxX = quellCanvas.width - crop.size;
var maxY = quellCanvas.height - crop.size;
crop.x = Math.max(0, Math.min(crop.x, maxX));
crop.y = Math.max(0, Math.min(crop.y, maxY));
crop.size = Math.max(30, Math.min(crop.size,
Math.min(quellCanvas.width - crop.x, quellCanvas.height - crop.y)));
}
cropContainer.addEventListener('mousedown', function(e) {
if (!originalBild) return;
var rect = quellCanvas.getBoundingClientRect();
var mx = e.clientX - rect.left;
var my = e.clientY - rect.top;
var handle = e.target.closest('.resize-handle');
if (handle) {
resizing = true;
resizePos = handle.dataset.pos;
startX = e.clientX;
startY = e.clientY;
startCropX = crop.x;
startCropY = crop.y;
startCropSize = crop.size;
e.preventDefault();
return;
}
if (mx >= crop.x && mx <= crop.x + crop.size &&
my >= crop.y && my <= crop.y + crop.size) {
dragging = true;
startX = e.clientX;
startY = e.clientY;
startCropX = crop.x;
startCropY = crop.y;
e.preventDefault();
}
});
document.addEventListener('mousemove', function(e) {
if (dragging) {
var dx = e.clientX - startX;
var dy = e.clientY - startY;
crop.x = startCropX + dx;
crop.y = startCropY + dy;
begrenzen();
cropBoxAktualisieren();
vorschauAktualisieren();
}
if (resizing) {
var dx = e.clientX - startX;
var dy = e.clientY - startY;
if (resizePos === 'br') {
var delta = Math.max(dx, dy);
crop.size = Math.max(30, startCropSize + delta);
} else if (resizePos === 'tl') {
var delta = Math.min(dx, dy);
crop.x = startCropX + delta;
crop.y = startCropY + delta;
crop.size = startCropSize - delta;
} else if (resizePos === 'tr') {
var delta = Math.max(dx, -dy);
crop.y = startCropY - delta;
crop.size = startCropSize + delta;
} else if (resizePos === 'bl') {
var delta = Math.max(-dx, dy);
crop.x = startCropX - delta;
crop.size = startCropSize + delta;
}
crop.size = Math.max(30, crop.size);
begrenzen();
cropBoxAktualisieren();
vorschauAktualisieren();
}
});
document.addEventListener('mouseup', function() {
dragging = false;
resizing = false;
});
// Touch-Events fuer mobile/Tablet
cropContainer.addEventListener('touchstart', function(e) {
if (!originalBild) return;
var touch = e.touches[0];
var rect = quellCanvas.getBoundingClientRect();
var mx = touch.clientX - rect.left;
var my = touch.clientY - rect.top;
if (mx >= crop.x && mx <= crop.x + crop.size &&
my >= crop.y && my <= crop.y + crop.size) {
dragging = true;
startX = touch.clientX;
startY = touch.clientY;
startCropX = crop.x;
startCropY = crop.y;
e.preventDefault();
}
}, {passive: false});
document.addEventListener('touchmove', function(e) {
if (dragging) {
var touch = e.touches[0];
var dx = touch.clientX - startX;
var dy = touch.clientY - startY;
crop.x = startCropX + dx;
crop.y = startCropY + dy;
begrenzen();
cropBoxAktualisieren();
vorschauAktualisieren();
e.preventDefault();
}
}, {passive: false});
document.addEventListener('touchend', function() {
dragging = false;
});
function speichern() {
if (!originalBild) return;
var btn = document.getElementById('speichern-btn');
btn.disabled = true;
btn.textContent = 'Speichern...';
// 400x400 quadratisch, JPEG 85% Qualitaet
var exportCanvas = document.createElement('canvas');
exportCanvas.width = 400;
exportCanvas.height = 400;
var ctx = exportCanvas.getContext('2d');
var sx = crop.x / skalierung;
var sy = crop.y / skalierung;
var ss = crop.size / skalierung;
ctx.drawImage(originalBild, sx, sy, ss, ss, 0, 0, 400, 400);
var dataUrl = exportCanvas.toDataURL('image/jpeg', 0.85);
fetch('{{ url_for("admin.software_bild", karte_id=karte.id) }}', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({bild_base64: dataUrl})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) {
alert('Fehler: ' + data.error);
btn.disabled = false;
btn.textContent = 'Bild speichern';
} else {
document.getElementById('crop-bereich').style.display = 'none';
document.getElementById('erfolg-bereich').style.display = 'block';
}
})
.catch(function() {
alert('Verbindungsfehler');
btn.disabled = false;
btn.textContent = 'Bild speichern';
});
}
function zuruecksetzen() {
originalBild = null;
cropBox.style.display = 'none';
vorschauCtx.clearRect(0, 0, 200, 200);
document.getElementById('crop-bereich').style.display = 'none';
document.getElementById('paste-bereich').style.display = 'block';
}
</script>
{% endblock %}

View File

@@ -0,0 +1,138 @@
{% extends "base.html" %}
{% block title %}{{ karte.titel }} bearbeiten &mdash; Admin{% endblock %}
{% block content %}
<div class="section-header" style="display:flex; justify-content:space-between; align-items:center;">
<h1>Karte bearbeiten: {{ karte.titel }}</h1>
<div>
<a href="{{ url_for('admin.software_liste') }}" class="btn btn-secondary">&larr; Alle Karten</a>
</div>
</div>
{# ---- Bild-Sektion ---- #}
<div class="card">
<h2>Bild</h2>
<div style="display: flex; gap: 1.5rem; align-items: flex-start; flex-wrap: wrap;">
<div style="flex: 0 0 200px; text-align: center;">
{% if karte.hat_bild_blob %}
<img src="{{ url_for('api.software_bild', karte_id=karte.id) }}?v={{ karte.id }}"
style="max-width: 200px; max-height: 200px; border-radius: 8px; border: 1px solid var(--border); background: #fff;">
{% elif karte.bild_pfad %}
<img src="{{ url_for('static', filename='img/software/' ~ karte.bild_pfad) }}"
style="max-width: 200px; max-height: 200px; border-radius: 8px; border: 1px solid var(--border); background: #fff;">
{% else %}
<div style="width:200px; height:200px; background:#ECF0F1; border-radius: 8px; display:flex; align-items:center; justify-content:center; font-size:3rem; color: var(--text-light);">
{%- if karte.kategorie == 'app' -%}📱
{%- elif karte.kategorie == 'generator' -%}🧩
{%- else -%}🖥️
{%- endif -%}
</div>
{% endif %}
</div>
<div style="flex: 1; min-width: 250px;">
<p style="margin-bottom: 0.8rem; color: var(--text-light);">
Logo / Screenshot fuer die Karte. Wird auf der oeffentlichen Software-Seite angezeigt.
</p>
<a href="{{ url_for('admin.software_bild', karte_id=karte.id) }}" class="btn btn-primary">
Bild einfuegen / aendern
</a>
{% if karte.hat_bild_blob %}
<form method="post" action="{{ url_for('admin.software_bild_loeschen', karte_id=karte.id) }}" style="display:inline;"
onsubmit="return confirm('Bild wirklich loeschen?')">
<button class="btn btn-secondary" style="color: var(--danger);">Bild loeschen</button>
</form>
{% endif %}
</div>
</div>
</div>
{# ---- Daten-Sektion ---- #}
<div class="card">
<h2>Inhalt</h2>
<form method="post" style="display: grid; gap: 0.8rem;">
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.8rem;">
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">Slug (unveraenderbar)</label>
<input type="text" value="{{ karte.slug }}" disabled
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px; background: #f5f5f5;">
</div>
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">Titel *</label>
<input type="text" name="titel" required value="{{ karte.titel }}"
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);">Kategorie *</label>
<select name="kategorie" required
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
{% for cat in kategorien %}
<option value="{{ cat }}" {% if cat == karte.kategorie %}selected{% endif %}>{{ cat }}</option>
{% endfor %}
</select>
</div>
</div>
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">Kurzbeschreibung (1-2 Saetze, erscheint auf der Karte) *</label>
<input type="text" name="kurzbeschreibung" required maxlength="200" value="{{ karte.kurzbeschreibung }}"
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.8rem;">
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">Hauptlink (Startseite / Download) *</label>
<input type="url" name="url" required value="{{ karte.url }}"
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);">Tutorial-Link (optional)</label>
<input type="url" name="tutorial_url" value="{{ karte.tutorial_url or '' }}"
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
</div>
</div>
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 0.8rem;">
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">Plattform (Klartext)</label>
<input type="text" name="plattform" value="{{ karte.plattform or '' }}"
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);">Sortierung (kleiner = weiter oben)</label>
<input type="number" name="sortierung" value="{{ karte.sortierung }}" min="0" max="9999"
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
</div>
</div>
<fieldset style="border: 1px dashed var(--border); padding: 0.8rem; border-radius: 4px;">
<legend style="font-size: 0.85rem; color: var(--text-light); padding: 0 0.4rem;">Nur bei Apps</legend>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.8rem;">
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">App Store URL</label>
<input type="url" name="app_store_url" value="{{ karte.app_store_url or '' }}"
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);">Google Play URL</label>
<input type="url" name="play_store_url" value="{{ karte.play_store_url or '' }}"
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
</div>
</div>
</fieldset>
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">
Bild-Pfad (Fallback, relativ zu <code>static/img/software/</code>) — meist leer lassen, weil das Crop-Bild oben Vorrang hat
</label>
<input type="text" name="bild_pfad" value="{{ karte.bild_pfad or '' }}"
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 0.5rem;">
<a href="{{ url_for('admin.software_liste') }}" class="btn btn-secondary">Abbrechen</a>
<button class="btn btn-primary">Aenderungen speichern</button>
</div>
</form>
</div>
{% endblock %}

View File

@@ -99,7 +99,7 @@
</form>
</div>
{% else %}
<div class="card" style="background: #EAFBF1; border-left: 4px solid var(--success);">
<div class="card" style="background: #EAFBF1; border-left: 4px solid var(--success); display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap;">
<p style="margin: 0; font-size: 0.9rem;">
<strong>Heute eingecheckt:</strong>
{% if status.check_in_heute.modus == 'weiter_wie_bisher' %}Arbeite weiter
@@ -110,6 +110,85 @@
{% else %}{{ status.check_in_heute.modus }}{% endif %}
{% if status.check_in_heute.freitext %} &mdash; „{{ status.check_in_heute.freitext }}"{% endif %}
</p>
<form method="post" action="{{ url_for('profil.check_in_zuruecksetzen') }}" style="margin: 0;">
<button class="btn btn-secondary" style="padding: 0.3rem 0.7rem; font-size: 0.8rem;">
Modus aendern
</button>
</form>
</div>
{% endif %}
{# ====== PROJEKT-KARTE — ganz oben, damit "Hier weitermachen" sofort sichtbar ist ====== #}
{% if status.hat_projekte or not status.ist_neu or status.projekt.letztes %}
<div class="card" style="margin-top: 1rem;
{% if status.projekt.aktiv %}border-left: 4px solid var(--success);{% endif %}">
<h2>{% if status.paarung %}Euer Projekt{% else %}Dein Projekt{% endif %}</h2>
{% if status.projekt.aktiv %}
{# === Aktives Projekt: Weitermachen-Karte === #}
<div style="display: flex; gap: 1rem; align-items: center; margin-top: 0.5rem; flex-wrap: wrap;">
<div style="flex: 0 0 120px;">
{% if status.projekt.aktiv.hat_cover %}
<img src="{{ url_for('api.projekt_cover', projekt_id=status.projekt.aktiv.id) }}" alt=""
style="width: 120px; height: 120px; object-fit: cover; border-radius: 8px;">
{% else %}
<div style="width: 120px; height: 120px; background: linear-gradient(135deg, #ECF0F1, #D5DBDB);
border-radius: 8px; display: flex; align-items: center; justify-content: center;
font-size: 2.5rem; color: var(--text-light);">🎯</div>
{% endif %}
</div>
<div style="flex: 1; min-width: 250px;">
<h3 style="margin: 0 0 0.4rem 0;">{{ status.projekt.aktiv.titel }}</h3>
{% if status.projekt.aktiv.beschreibung_md %}
<div style="font-size: 0.9rem; color: var(--text); max-height: 4.5em; overflow: hidden;">
{{ status.projekt.aktiv.beschreibung_md|markdown }}
</div>
{% endif %}
<div style="margin-top: 0.8rem; display: flex; gap: 0.5rem; flex-wrap: wrap;">
<a href="{{ url_for('profil.projekt_detail', projekt_id=status.projekt.aktiv.id) }}"
class="btn btn-primary">Hier weitermachen →</a>
<a href="{{ url_for('profil.projekt_abschluss', projekt_id=status.projekt.aktiv.id) }}"
class="btn btn-secondary" style="font-size: 0.85rem;">Abschliessen</a>
</div>
</div>
</div>
{% elif status.projekt.letztes and status.projekt.letztes.freigabe_status == 'wartet_auf_freigabe' %}
<div style="background: #FEF9E7; padding: 0.8rem; border-radius: 6px; border-left: 4px solid var(--warning);">
<strong>⏳ Wartet auf Freigabe:</strong>
<a href="{{ url_for('profil.projekt_detail', projekt_id=status.projekt.letztes.id) }}">{{ status.projekt.letztes.titel }}</a>
<div style="font-size: 0.85rem; margin-top: 0.3rem; color: var(--text-light);">
Der Lehrer schaut drueber. Sobald freigegeben, kannst du ein neues Projekt anfangen.
</div>
</div>
{% elif status.projekt.letztes and status.projekt.letztes.freigabe_status == 'rueckfrage' %}
<div style="background: #FADBD8; padding: 0.8rem; border-radius: 6px; border-left: 4px solid var(--danger);">
<strong>❓ Rueckfrage offen zu:</strong>
<a href="{{ url_for('profil.projekt_detail', projekt_id=status.projekt.letztes.id) }}">{{ status.projekt.letztes.titel }}</a>
<div style="font-size: 0.85rem; margin-top: 0.3rem;">
Siehe Rueckfrage-Banner oben, beantworte sie — dann kann der Lehrer freigeben.
</div>
</div>
{% else %}
<p style="color: var(--text-light); margin-bottom: 0.8rem;">
Du hast gerade kein laufendes Projekt. Bereit fuer was Neues?
</p>
{% if status.projekt.darf_neues %}
<a href="{{ url_for('profil.projekt_neu') }}" class="btn btn-primary">+ Neues Projekt anfangen</a>
{% else %}
<div style="background: #FEF9E7; padding: 0.6rem 0.8rem; border-radius: 6px; font-size: 0.9rem;">
{{ status.projekt.sperr_grund }}
</div>
{% endif %}
{% endif %}
{% if status.projekt.aktiv or status.projekt.letztes %}
<div style="margin-top: 0.8rem; font-size: 0.85rem;">
<a href="{{ url_for('profil.projekte_liste') }}" style="color: var(--text-light);">Alle meine Projekte ansehen →</a>
</div>
{% endif %}
</div>
{% endif %}
@@ -163,30 +242,6 @@
</div>
{% endif %}
{# ====== PROJEKT-KARTE (V1: nur wenn Projekte oder Onboarding fertig) ====== #}
{% if status.hat_projekte or not status.ist_neu %}
<div class="card">
<h2>{% if status.paarung %}Euer Projekt{% else %}Dein Projekt{% endif %}</h2>
{% if status.aktive_projekte %}
{% for p in status.aktive_projekte %}
<div style="margin-bottom: 0.8rem;">
<strong>{{ p.titel }}</strong>
{% if p.beschreibung_md %}
<div style="margin-top: 0.3rem; font-size: 0.9rem;">{{ p.beschreibung_md|markdown }}</div>
{% endif %}
</div>
{% endfor %}
{% else %}
<p style="color: var(--text-light);">
Du hast noch kein laufendes Projekt.
</p>
<p style="margin-top: 0.5rem; font-size: 0.85rem; color: var(--text-light);">
<em>Projekt-Anlage kommt mit Etappe 6.</em>
</p>
{% endif %}
</div>
{% endif %}
{# ====== PAARUNG / TEAM-KARTE — immer wenn Paarung aktiv, sonst nur fuer Erfahrene ====== #}
{% if status.paarung or not status.ist_neu %}
<div class="card">

163
templates/drucker.html Normal file
View File

@@ -0,0 +1,163 @@
{% extends "base.html" %}
{% block title %}Drucker{% endblock %}
{% block content %}
<style>
.druckerkachel-gross {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1.2rem;
}
.druckerkachel-gross h2 { margin: 0; color: var(--primary-dark); }
.druckerkachel-gross .modell { font-size: 0.85rem; color: var(--text-light); }
.druckerkachel-gross .status-zeile { font-size: 1rem; font-weight: 600; margin-top: 0.3rem; }
.druckerkachel-gross .progress {
font-size: 0.85rem; color: var(--text-light); margin-top: 0.2rem;
}
.druckerkachel-gross .sperre-banner {
background: #FADBD8; color: #943126; border: 1px solid #F5B7B1;
border-radius: 6px; padding: 0.6rem 0.8rem; font-size: 0.9rem;
}
.druckerkachel-gross .api-hinweis {
font-size: 0.8rem; color: var(--text-light); font-style: italic;
}
.druckerkachel-gross .links {
display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: auto; padding-top: 0.5rem;
}
.druckerkachel-gross .links .btn { font-size: 0.8rem; padding: 0.35rem 0.7rem; }
.druckerkachel-gross.gesperrt { border-left: 4px solid var(--danger); }
</style>
<div class="section-header" style="display:flex; justify-content:space-between; align-items:center;">
<h1>Drucker</h1>
<a href="{{ url_for('profil.slots') }}" class="btn btn-secondary">Meine Reservierungen</a>
</div>
<p style="color: var(--text-light);">
Aktueller Stand unserer 4 Bambu-Lab-Drucker. <strong>Kein Drucken ohne Reservierung</strong> — es
muss immer klar sein, wer gerade an welchem Drucker druckt.
</p>
{% if not darf_reservieren %}
<div class="card" style="background:#FEF9E7; border-left: 4px solid var(--warning);">
<strong>Reservierung noch nicht freigeschaltet.</strong>
Du kannst die Drucker bisher nur ansehen. Lass dich beim Lehrer freischalten,
dann kannst du selbst Slots reservieren.
</div>
{% endif %}
{% set gesperrte = drucker_liste | selectattr('freigegeben', 'equalto', 0) | list %}
{% if gesperrte %}
<div class="card" style="background:#FADBD8; border-left: 4px solid var(--danger);">
<strong>⚠ {{ gesperrte|length }} Drucker derzeit gesperrt:</strong>
{{ gesperrte | map(attribute='name') | join(', ') }}
— Details auf den jeweiligen Kacheln.
</div>
{% endif %}
<div class="grid" style="grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));">
{% for d in drucker_liste %}
<div class="card druckerkachel-gross drucker-{{ d.status or 'idle' }} {% if not d.freigegeben %}gesperrt{% endif %}">
<div>
<h2>{{ d.name }}</h2>
<div class="modell">
{{ d.modell }}{% if not d.geschlossenes_gehaeuse %} · <span style="color: var(--danger);">offen</span>{% endif %}
</div>
</div>
{# Sperre-Banner hat Vorrang vor allem anderen #}
{% if not d.freigegeben %}
<div class="sperre-banner">
<strong>Gesperrt</strong>{% if d.gesperrt_grund %} — {{ d.gesperrt_grund }}{% endif %}
<div style="font-size: 0.8rem; margin-top: 0.3rem;">Reservierung derzeit nicht moeglich.</div>
</div>
{% endif %}
{# Status-Zeile #}
<div class="status-zeile">
{% if d.status == 'printing' %}🟢 druckt
{% elif d.status == 'idle' %}⚪ frei
{% elif d.status == 'paused' %}🟡 pausiert
{% elif d.status == 'finished' %}✅ fertig — Druck abholen
{% elif d.status == 'failed' %}🔴 Fehler
{% elif d.status == 'offline' %}⚫ offline
{% else %}⚪ kein Status
{% endif %}
</div>
{# Job-Details wenn aktiv #}
{% if d.job_name %}<div class="progress">{{ d.job_name }}</div>{% endif %}
{% if d.progress_pct %}
<div class="progress">
{{ d.progress_pct|round|int }} %{% if d.restzeit_min %} · noch {{ d.restzeit_min }} min{% endif %}
</div>
{% endif %}
{% if d.fehlermeldung %}
<div style="color: var(--danger); font-size: 0.9rem;">{{ d.fehlermeldung }}</div>
{% endif %}
{# Notiz vom Lehrer (auch wenn nicht gesperrt) #}
{% if d.notiz and d.freigegeben %}
<div style="font-size: 0.85rem; color: var(--text-light);"> {{ d.notiz }}</div>
{% endif %}
{# Hinweis wenn noch keine API-Daten #}
{% if not d.status %}
<div class="api-hinweis">
(Live-Status via Bambu-API kommt mit Etappe 5. Bis dahin pflegt der Lehrer den Status manuell.)
</div>
{% elif d.manuell_gesetzt %}
<div class="api-hinweis">vom Lehrer gesetzt</div>
{% endif %}
{# Belegung: aktuell + naechste 3 Slots #}
<div style="background:#F4F6F7; border-radius: 6px; padding: 0.6rem 0.8rem; font-size: 0.85rem;">
{% if d.belegung %}
<div><strong>🔖 Jetzt belegt:</strong>
{{ tier_emoji(d.belegung.tier or '') }} {{ d.belegung.vorname or '—' }}
bis {{ d.belegung.ende_zeit|iso_format('%H:%M') }} Uhr
</div>
{% else %}
<div><strong>🔖 Aktuell frei.</strong></div>
{% endif %}
{% if d.kommend %}
<div style="margin-top: 0.3rem; color: var(--text-light);">
Naechste:
{% for s in d.kommend %}
{{ s.start_zeit|iso_format('%d.%m. %H:%M') }}{{ s.ende_zeit|iso_format('%H:%M') }}
({{ s.vorname or '—' }}){% if not loop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
</div>
{# Reservieren-Button #}
{% if d.freigegeben and darf_reservieren %}
<a href="{{ url_for('profil.slot_neu', drucker_id=d.id) }}" class="btn btn-primary"
style="text-align: center;">+ Diesen Drucker reservieren</a>
{% elif d.freigegeben and not darf_reservieren %}
<div style="font-size: 0.8rem; color: var(--text-light); font-style: italic;">
Reservierung ist fuer dich noch nicht freigeschaltet.
</div>
{% endif %}
{# Manual + Troubleshooting #}
<div class="links">
{% if d.manual %}
<a href="{{ d.manual }}" target="_blank" rel="noopener noreferrer" class="btn btn-secondary">📖 Manual ↗</a>
{% endif %}
{% if d.troubleshooting %}
<a href="{{ d.troubleshooting }}" target="_blank" rel="noopener noreferrer" class="btn btn-secondary">🔧 Hilfe ↗</a>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% if not drucker_liste %}
<div class="card">
<p style="color: var(--text-light);">Noch keine Drucker eingerichtet.</p>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block title %}Drucker reservieren{% endblock %}
{% block content %}
<div class="section-header" style="display:flex; justify-content:space-between; align-items:center;">
<h1>Drucker reservieren</h1>
<a href="{{ url_for('profil.slots') }}" class="btn btn-secondary">Meine Slots</a>
</div>
{% if not drucker_liste %}
<div class="card" style="background:#FADBD8; border-left: 4px solid var(--danger);">
<strong>Kein Drucker frei.</strong> Alle Drucker sind derzeit gesperrt (Wartung / Reparatur).
Frag den Lehrer, wann wieder reserviert werden kann.
</div>
{% else %}
<div class="card">
<form method="post" style="display: grid; gap: 1rem;">
<div>
<label style="display:block; font-size: 0.9rem; color: var(--text-light); margin-bottom: 0.3rem;">
Drucker *
</label>
<select name="drucker_id" required
style="padding: 0.6rem; width: 100%; border: 1px solid var(--border); border-radius: 4px; font-size: 1rem;">
<option value="">— bitte waehlen —</option>
{% for d in drucker_liste %}
<option value="{{ d.id }}" {% if vorgewaehlt_drucker == d.id %}selected{% endif %}>
{{ d.name }} ({{ d.modell }})
</option>
{% endfor %}
</select>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
<div>
<label style="display:block; font-size: 0.9rem; color: var(--text-light); margin-bottom: 0.3rem;">
Start *
</label>
<input type="datetime-local" name="start_zeit" required step="900"
value="{{ eingabe_start or default_start.strftime('%Y-%m-%dT%H:%M') }}"
min="{{ default_start.strftime('%Y-%m-%dT%H:%M') }}"
style="padding: 0.6rem; width: 100%; border: 1px solid var(--border); border-radius: 4px; font-size: 1rem;">
</div>
<div>
<label style="display:block; font-size: 0.9rem; color: var(--text-light); margin-bottom: 0.3rem;">
Ende *
</label>
<input type="datetime-local" name="ende_zeit" required step="900"
value="{{ eingabe_ende or default_ende.strftime('%Y-%m-%dT%H:%M') }}"
style="padding: 0.6rem; width: 100%; border: 1px solid var(--border); border-radius: 4px; font-size: 1rem;">
</div>
</div>
<div>
<label style="display:block; font-size: 0.9rem; color: var(--text-light); margin-bottom: 0.3rem;">
Notiz (optional)
</label>
<input type="text" name="notiz" maxlength="200"
value="{{ eingabe_notiz or '' }}"
placeholder="z.B. 'Schluesselanhaenger fuer Mama' oder '6h-Druck, lasse ueber Nacht'"
style="padding: 0.6rem; width: 100%; border: 1px solid var(--border); border-radius: 4px; font-size: 0.95rem;">
</div>
<div style="background: #EBF5FB; padding: 0.6rem 0.8rem; border-radius: 4px; font-size: 0.85rem; color: var(--text);">
<strong>Regeln:</strong> Mindest-Dauer 15 Minuten, Maximum 24 Stunden.
Achte darauf, dass dein Druck in deinem Slot fertig wird — sonst kann der naechste SuS nicht starten.
</div>
<div style="display: flex; justify-content: space-between;">
<a href="{{ url_for('oeffentlich.drucker') }}" class="btn btn-secondary">Abbrechen</a>
<button class="btn btn-primary">Reservieren</button>
</div>
</form>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,75 @@
{% extends "base.html" %}
{% block title %}Meine Slots{% endblock %}
{% block content %}
<div class="section-header" style="display:flex; justify-content:space-between; align-items:center;">
<h1>Meine Drucker-Reservierungen</h1>
<div>
{% if darf_reservieren %}
<a href="{{ url_for('profil.slot_neu') }}" class="btn btn-primary">+ Neue Reservierung</a>
{% endif %}
<a href="{{ url_for('oeffentlich.drucker') }}" class="btn btn-secondary">Drucker-Uebersicht</a>
</div>
</div>
{% if not darf_reservieren %}
<div class="card" style="background:#FEF9E7; border-left: 4px solid var(--warning);">
<strong>Reservierung noch nicht freigeschaltet.</strong>
Bitte den Lehrer kurz darum — er muss dich einmalig freischalten, danach kannst du selbst reservieren.
</div>
{% endif %}
{% if slots %}
<div class="card">
<table>
<thead>
<tr>
<th>Drucker</th>
<th>Start</th>
<th>Ende</th>
<th>Dauer</th>
<th>Status</th>
<th>Notiz</th>
<th></th>
</tr>
</thead>
<tbody>
{% for s in slots %}
<tr>
<td><strong>{{ s.drucker_name }}</strong>
<div style="font-size: 0.8rem; color: var(--text-light);">{{ s.modell }}</div>
</td>
<td>{{ s.start_zeit|iso_format('%d.%m. %H:%M') }}</td>
<td>{{ s.ende_zeit|iso_format('%d.%m. %H:%M') }}</td>
<td>{{ s.start_zeit|dauer_min(s.ende_zeit) }} min</td>
<td>
{% if s.status == 'reserviert' %}<span class="badge badge-mittel">reserviert</span>
{% elif s.status == 'aktiv' %}<span class="badge badge-anfaenger">aktiv</span>
{% else %}<span class="badge">{{ s.status }}</span>{% endif %}
</td>
<td style="font-size: 0.85rem; color: var(--text-light);">{{ s.notiz or '' }}</td>
<td>
<form method="post" action="{{ url_for('profil.slot_stornieren', slot_id=s.id) }}"
onsubmit="return confirm('Slot wirklich stornieren?')" style="display: inline;">
<button class="btn btn-secondary" style="padding: 0.3rem 0.7rem; font-size: 0.85rem; color: var(--danger);">
stornieren
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card">
<p style="color: var(--text-light);">
Du hast aktuell keine bevorstehenden Reservierungen.
{% if darf_reservieren %}
<a href="{{ url_for('profil.slot_neu') }}">Jetzt eine Reservierung anlegen.</a>
{% endif %}
</p>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,129 @@
{% extends "base.html" %}
{% block title %}{{ p.titel }} abschliessen{% endblock %}
{% block content %}
<style>
.eval-gruppe { display: grid; gap: 0.5rem; }
.eval-titel { font-size: 0.95rem; font-weight: 600; color: var(--text); margin-bottom: 0.3rem; }
.eval-untertitel { font-size: 0.8rem; color: var(--text-light); margin-bottom: 0.6rem; font-weight: normal; }
.eval-optionen { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.5rem; }
.eval-option {
cursor: pointer; padding: 0.7rem 0.5rem; border-radius: 6px; text-align: center;
background: #F4F6F7; border: 2px solid transparent; transition: all 0.15s;
}
.eval-option:hover { background: #ECF0F1; }
.eval-option input[type=radio] { display: none; }
.eval-option input[type=radio]:checked + span { font-weight: 600; }
.eval-option:has(input[type=radio]:checked) {
background: #EBF5FB; border-color: var(--primary);
}
.eval-emoji { font-size: 1.4rem; display: block; margin-bottom: 0.2rem; }
</style>
<div class="section-header" style="display:flex; justify-content:space-between; align-items:center;">
<h1>"{{ p.titel }}" abschliessen</h1>
<a href="{{ url_for('profil.projekt_detail', projekt_id=p.id) }}" class="btn btn-secondary">Abbrechen</a>
</div>
<div class="card" style="background:#EBF5FB; border-left: 4px solid var(--primary);">
<strong>Ehrlich bleiben.</strong> Niemand wird benotet — die Bewertung ist fuer dich selbst,
damit du beim naechsten Projekt besser einschaetzen kannst, was du planen musst.
</div>
<div class="card">
<form method="post" style="display: grid; gap: 1.5rem;">
{# ============== Gesamt ============== #}
<div class="eval-gruppe">
<div class="eval-titel">Wie ist es insgesamt gelaufen? *</div>
<div class="eval-untertitel">
"Gelernt" heisst: hat nicht so geklappt, aber ich hab dabei was verstanden.
Bei "Gelernt" schaut der Lehrer kurz drueber, bevor du was Neues anfaengst.
</div>
<div class="eval-optionen">
<label class="eval-option">
<input type="radio" name="selbsteinschaetzung" value="zufrieden" required>
<span><span class="eval-emoji">🟢</span>Zufrieden</span>
</label>
<label class="eval-option">
<input type="radio" name="selbsteinschaetzung" value="mittel">
<span><span class="eval-emoji">🟡</span>Mittel</span>
</label>
<label class="eval-option">
<input type="radio" name="selbsteinschaetzung" value="gelernt">
<span><span class="eval-emoji">🔴</span>Gelernt</span>
</label>
</div>
</div>
{# ============== Planung ============== #}
<div class="eval-gruppe">
<div class="eval-titel">Wie gut war deine Planung im Vorhinein?</div>
<div class="eval-untertitel">Hattest du vorher klar, was du bauen willst und wie?</div>
<div class="eval-optionen">
<label class="eval-option">
<input type="radio" name="selbsteinschaetzung_planung" value="gut">
<span><span class="eval-emoji">🟢</span>Gut</span>
</label>
<label class="eval-option">
<input type="radio" name="selbsteinschaetzung_planung" value="ok">
<span><span class="eval-emoji">🟡</span>OK</span>
</label>
<label class="eval-option">
<input type="radio" name="selbsteinschaetzung_planung" value="schwierig">
<span><span class="eval-emoji">🔴</span>Schwierig</span>
</label>
</div>
</div>
{# ============== Ausfuehrung ============== #}
<div class="eval-gruppe">
<div class="eval-titel">Wie gut hat die Ausfuehrung geklappt?</div>
<div class="eval-untertitel">Modellieren, Slicen, Drucken — wie ist das gelaufen?</div>
<div class="eval-optionen">
<label class="eval-option">
<input type="radio" name="selbsteinschaetzung_ausfuehrung" value="gut">
<span><span class="eval-emoji">🟢</span>Gut</span>
</label>
<label class="eval-option">
<input type="radio" name="selbsteinschaetzung_ausfuehrung" value="ok">
<span><span class="eval-emoji">🟡</span>OK</span>
</label>
<label class="eval-option">
<input type="radio" name="selbsteinschaetzung_ausfuehrung" value="schwierig">
<span><span class="eval-emoji">🔴</span>Schwierig</span>
</label>
</div>
</div>
{# ============== Freitexte ============== #}
<div>
<label style="display:block; font-size: 0.95rem; font-weight: 600; margin-bottom: 0.3rem;">
Was hast du gelernt?
</label>
<div style="font-size: 0.8rem; color: var(--text-light); margin-bottom: 0.4rem;">
1-2 Saetze reichen. Was nimmst du mit fuer's naechste Mal?
</div>
<textarea name="was_gelernt" rows="3" maxlength="500"
style="padding: 0.6rem; width: 100%; border: 1px solid var(--border); border-radius: 4px; font-size: 0.95rem; font-family: inherit;"></textarea>
</div>
<div>
<label style="display:block; font-size: 0.95rem; font-weight: 600; margin-bottom: 0.3rem;">
Was hat nicht so geklappt? <span style="font-weight: normal; color: var(--text-light);">(optional)</span>
</label>
<div style="font-size: 0.8rem; color: var(--text-light); margin-bottom: 0.4rem;">
Nur wenn was schief lief. Hilft dem Lehrer beim Drueberschauen.
</div>
<textarea name="was_klappte_nicht" rows="3" maxlength="500"
style="padding: 0.6rem; width: 100%; border: 1px solid var(--border); border-radius: 4px; font-size: 0.95rem; font-family: inherit;"></textarea>
</div>
<div style="display: flex; justify-content: space-between;">
<a href="{{ url_for('profil.projekt_detail', projekt_id=p.id) }}" class="btn btn-secondary">Abbrechen</a>
<button class="btn btn-primary">Projekt abschliessen</button>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block title %}{{ p.titel }} bearbeiten{% endblock %}
{% block content %}
<div class="section-header" style="display:flex; justify-content:space-between; align-items:center;">
<h1>Projekt bearbeiten</h1>
<a href="{{ url_for('profil.projekt_detail', projekt_id=p.id) }}" class="btn btn-secondary">&larr; Zurueck</a>
</div>
<div class="card">
<form method="post" style="display: grid; gap: 1rem;">
<div>
<label style="display:block; font-size: 0.9rem; color: var(--text-light); margin-bottom: 0.3rem;">Titel *</label>
<input type="text" name="titel" required maxlength="100" value="{{ p.titel }}"
style="padding: 0.6rem; width: 100%; border: 1px solid var(--border); border-radius: 4px; font-size: 1rem;">
</div>
<div>
<label style="display:block; font-size: 0.9rem; color: var(--text-light); margin-bottom: 0.3rem;">Beschreibung</label>
<textarea name="beschreibung_md" rows="10" maxlength="2000"
style="padding: 0.6rem; width: 100%; border: 1px solid var(--border); border-radius: 4px; font-size: 0.95rem; font-family: inherit;">{{ p.beschreibung_md or '' }}</textarea>
<div style="font-size: 0.8rem; color: var(--text-light); margin-top: 0.3rem;">
Markdown: **fett**, *kursiv*, Listen mit -, Ueberschriften mit #.
</div>
</div>
<div style="display: flex; justify-content: space-between;">
<a href="{{ url_for('profil.projekt_detail', projekt_id=p.id) }}" class="btn btn-secondary">Abbrechen</a>
<button class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,290 @@
{% extends "base.html" %}
{% block title %}Cover-Bild: {{ p.titel }}{% endblock %}
{% block content %}
<p><a href="{{ url_for('profil.projekt_detail', projekt_id=p.id) }}">&larr; Zurueck zum Projekt</a></p>
<div class="card">
<h1>Cover-Bild fuer: {{ p.titel }}</h1>
<p style="color: var(--text-light); font-size: 0.9rem;">
Ein Foto vom Druck oder ein Screenshot vom Modell — wird im Cockpit und spaeter im Katalog gezeigt.
</p>
</div>
<!-- Paste-Bereich -->
<div class="card" id="paste-bereich">
<h2>1. Bild einfuegen</h2>
<p style="color: var(--text-light); margin-bottom: 1rem;">
Kopiere ein Bild in die Zwischenablage (Snipping Tool, Handy-Foto) und druecke
<kbd style="background:#eee;padding:0.2rem 0.5rem;border-radius:4px;border:1px solid #ccc;">Strg+V</kbd>
hier auf der Seite. Oder waehle eine Datei aus.
</p>
<div id="drop-zone" style="border: 3px dashed var(--border); border-radius: 12px; padding: 3rem;
text-align: center; cursor: pointer; transition: border-color 0.2s; background: #fafafa;">
<p style="font-size: 1.2rem; color: var(--text-light);">Strg+V zum Einfuegen</p>
<p style="font-size: 0.85rem; color: var(--text-light);">oder klicken um Datei auszuwaehlen</p>
<input type="file" id="datei-input" accept="image/*" style="display: none;">
</div>
</div>
<!-- Crop-Bereich (anfangs versteckt) -->
<div class="card" id="crop-bereich" style="display: none;">
<h2>2. Ausschnitt waehlen</h2>
<p style="color: var(--text-light); margin-bottom: 1rem;">
Ziehe das Quadrat auf den gewuenschten Bildausschnitt. Ecken zum Vergroessern/Verkleinern ziehen.
</p>
<div style="display: flex; gap: 2rem; flex-wrap: wrap;">
<div style="flex: 1; min-width: 300px;">
<div id="crop-container" style="position: relative; display: inline-block; max-width: 100%; cursor: crosshair;">
<canvas id="quell-canvas" style="max-width: 100%; display: block;"></canvas>
<div id="crop-box" style="position: absolute; border: 3px solid var(--primary);
background: rgba(0,151,157,0.1); cursor: move; display: none;">
<div class="resize-handle" data-pos="tl" style="position:absolute;top:-6px;left:-6px;width:12px;height:12px;background:var(--primary);border-radius:50%;cursor:nw-resize;"></div>
<div class="resize-handle" data-pos="tr" style="position:absolute;top:-6px;right:-6px;width:12px;height:12px;background:var(--primary);border-radius:50%;cursor:ne-resize;"></div>
<div class="resize-handle" data-pos="bl" style="position:absolute;bottom:-6px;left:-6px;width:12px;height:12px;background:var(--primary);border-radius:50%;cursor:sw-resize;"></div>
<div class="resize-handle" data-pos="br" style="position:absolute;bottom:-6px;right:-6px;width:12px;height:12px;background:var(--primary);border-radius:50%;cursor:se-resize;"></div>
</div>
</div>
</div>
<div style="min-width: 200px; text-align: center;">
<h3>Vorschau</h3>
<canvas id="vorschau-canvas" width="200" height="200"
style="border: 1px solid var(--border); border-radius: 8px; background: #f0f0f0;"></canvas>
</div>
</div>
<div style="margin-top: 1.5rem; display: flex; gap: 1rem;">
<button onclick="speichern()" class="btn btn-primary" id="speichern-btn"
style="padding: 0.8rem 2rem; font-size: 1rem;">Bild speichern</button>
<button onclick="zuruecksetzen()" class="btn btn-secondary">Neues Bild</button>
</div>
</div>
<!-- Erfolg -->
<div class="card" id="erfolg-bereich" style="display: none; border-left: 4px solid var(--success);">
<h2 style="color: var(--success);">Bild gespeichert!</h2>
<p>Das Cover fuer <strong>{{ p.titel }}</strong> wurde erfolgreich gespeichert.</p>
<div style="margin-top: 1rem;">
<a href="{{ url_for('profil.projekt_detail', projekt_id=p.id) }}" class="btn btn-primary">Zurueck zum Projekt</a>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
var quellCanvas = document.getElementById('quell-canvas');
var quellCtx = quellCanvas.getContext('2d');
var vorschauCanvas = document.getElementById('vorschau-canvas');
var vorschauCtx = vorschauCanvas.getContext('2d');
var cropBox = document.getElementById('crop-box');
var cropContainer = document.getElementById('crop-container');
var originalBild = null;
var skalierung = 1;
var crop = { x: 0, y: 0, size: 100 };
var dragging = false, resizing = false, resizePos = '';
var startX, startY, startCropX, startCropY, startCropSize;
function bildLaden(quelle) {
var img = new Image();
img.onload = function() {
originalBild = img;
var maxBreite = 600;
skalierung = Math.min(1, maxBreite / img.width);
quellCanvas.width = img.width * skalierung;
quellCanvas.height = img.height * skalierung;
quellCtx.drawImage(img, 0, 0, quellCanvas.width, quellCanvas.height);
var minSeite = Math.min(quellCanvas.width, quellCanvas.height);
crop.size = Math.round(minSeite * 0.6);
crop.x = Math.round((quellCanvas.width - crop.size) / 2);
crop.y = Math.round((quellCanvas.height - crop.size) / 2);
cropBoxAktualisieren();
cropBox.style.display = 'block';
vorschauAktualisieren();
document.getElementById('paste-bereich').style.display = 'none';
document.getElementById('crop-bereich').style.display = 'block';
};
img.src = quelle;
}
document.addEventListener('paste', function(e) {
if (originalBild) return;
var items = e.clipboardData.items;
for (var i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') >= 0) {
var blob = items[i].getAsFile();
var reader = new FileReader();
reader.onload = function(ev) { bildLaden(ev.target.result); };
reader.readAsDataURL(blob);
e.preventDefault();
return;
}
}
});
var dropZone = document.getElementById('drop-zone');
var dateiInput = document.getElementById('datei-input');
dropZone.addEventListener('click', function() { dateiInput.click(); });
dateiInput.addEventListener('change', function() {
if (this.files[0]) {
var reader = new FileReader();
reader.onload = function(ev) { bildLaden(ev.target.result); };
reader.readAsDataURL(this.files[0]);
}
});
dropZone.addEventListener('dragover', function(e) {
e.preventDefault(); this.style.borderColor = 'var(--primary)'; this.style.background = '#EBF5FB';
});
dropZone.addEventListener('dragleave', function() {
this.style.borderColor = 'var(--border)'; this.style.background = '#fafafa';
});
dropZone.addEventListener('drop', function(e) {
e.preventDefault();
this.style.borderColor = 'var(--border)'; this.style.background = '#fafafa';
if (e.dataTransfer.files[0] && e.dataTransfer.files[0].type.indexOf('image') >= 0) {
var reader = new FileReader();
reader.onload = function(ev) { bildLaden(ev.target.result); };
reader.readAsDataURL(e.dataTransfer.files[0]);
}
});
function cropBoxAktualisieren() {
cropBox.style.left = crop.x + 'px';
cropBox.style.top = crop.y + 'px';
cropBox.style.width = crop.size + 'px';
cropBox.style.height = crop.size + 'px';
}
function vorschauAktualisieren() {
if (!originalBild) return;
var sx = crop.x / skalierung, sy = crop.y / skalierung, ss = crop.size / skalierung;
vorschauCtx.clearRect(0, 0, 200, 200);
vorschauCtx.drawImage(originalBild, sx, sy, ss, ss, 0, 0, 200, 200);
}
function begrenzen() {
var maxX = quellCanvas.width - crop.size, maxY = quellCanvas.height - crop.size;
crop.x = Math.max(0, Math.min(crop.x, maxX));
crop.y = Math.max(0, Math.min(crop.y, maxY));
crop.size = Math.max(30, Math.min(crop.size,
Math.min(quellCanvas.width - crop.x, quellCanvas.height - crop.y)));
}
cropContainer.addEventListener('mousedown', function(e) {
if (!originalBild) return;
var rect = quellCanvas.getBoundingClientRect();
var mx = e.clientX - rect.left, my = e.clientY - rect.top;
var handle = e.target.closest('.resize-handle');
if (handle) {
resizing = true; resizePos = handle.dataset.pos;
startX = e.clientX; startY = e.clientY;
startCropX = crop.x; startCropY = crop.y; startCropSize = crop.size;
e.preventDefault(); return;
}
if (mx >= crop.x && mx <= crop.x + crop.size && my >= crop.y && my <= crop.y + crop.size) {
dragging = true;
startX = e.clientX; startY = e.clientY;
startCropX = crop.x; startCropY = crop.y;
e.preventDefault();
}
});
document.addEventListener('mousemove', function(e) {
if (dragging) {
crop.x = startCropX + (e.clientX - startX);
crop.y = startCropY + (e.clientY - startY);
begrenzen(); cropBoxAktualisieren(); vorschauAktualisieren();
}
if (resizing) {
var dx = e.clientX - startX, dy = e.clientY - startY;
if (resizePos === 'br') crop.size = Math.max(30, startCropSize + Math.max(dx, dy));
else if (resizePos === 'tl') {
var delta = Math.min(dx, dy);
crop.x = startCropX + delta; crop.y = startCropY + delta;
crop.size = startCropSize - delta;
} else if (resizePos === 'tr') {
var delta = Math.max(dx, -dy);
crop.y = startCropY - delta; crop.size = startCropSize + delta;
} else if (resizePos === 'bl') {
var delta = Math.max(-dx, dy);
crop.x = startCropX - delta; crop.size = startCropSize + delta;
}
crop.size = Math.max(30, crop.size);
begrenzen(); cropBoxAktualisieren(); vorschauAktualisieren();
}
});
document.addEventListener('mouseup', function() { dragging = false; resizing = false; });
cropContainer.addEventListener('touchstart', function(e) {
if (!originalBild) return;
var t = e.touches[0], rect = quellCanvas.getBoundingClientRect();
var mx = t.clientX - rect.left, my = t.clientY - rect.top;
if (mx >= crop.x && mx <= crop.x + crop.size && my >= crop.y && my <= crop.y + crop.size) {
dragging = true;
startX = t.clientX; startY = t.clientY;
startCropX = crop.x; startCropY = crop.y;
e.preventDefault();
}
}, {passive: false});
document.addEventListener('touchmove', function(e) {
if (dragging) {
var t = e.touches[0];
crop.x = startCropX + (t.clientX - startX);
crop.y = startCropY + (t.clientY - startY);
begrenzen(); cropBoxAktualisieren(); vorschauAktualisieren();
e.preventDefault();
}
}, {passive: false});
document.addEventListener('touchend', function() { dragging = false; });
function speichern() {
if (!originalBild) return;
var btn = document.getElementById('speichern-btn');
btn.disabled = true; btn.textContent = 'Speichern...';
var exportCanvas = document.createElement('canvas');
exportCanvas.width = 400; exportCanvas.height = 400;
var ctx = exportCanvas.getContext('2d');
var sx = crop.x / skalierung, sy = crop.y / skalierung, ss = crop.size / skalierung;
ctx.drawImage(originalBild, sx, sy, ss, ss, 0, 0, 400, 400);
var dataUrl = exportCanvas.toDataURL('image/jpeg', 0.85);
fetch('{{ url_for("profil.projekt_cover", projekt_id=p.id) }}', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({bild_base64: dataUrl})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) {
alert('Fehler: ' + data.error);
btn.disabled = false; btn.textContent = 'Bild speichern';
} else {
document.getElementById('crop-bereich').style.display = 'none';
document.getElementById('erfolg-bereich').style.display = 'block';
}
})
.catch(function() {
alert('Verbindungsfehler');
btn.disabled = false; btn.textContent = 'Bild speichern';
});
}
function zuruecksetzen() {
originalBild = null;
cropBox.style.display = 'none';
vorschauCtx.clearRect(0, 0, 200, 200);
document.getElementById('crop-bereich').style.display = 'none';
document.getElementById('paste-bereich').style.display = 'block';
}
</script>
{% endblock %}

View File

@@ -0,0 +1,405 @@
{% extends "base.html" %}
{% block title %}{{ p.titel }}{% endblock %}
{% block content %}
<div class="section-header" style="display:flex; justify-content:space-between; align-items:center;">
<h1>{{ p.titel }}</h1>
<div>
<a href="{{ url_for('profil.projekte_liste') }}" class="btn btn-secondary">Meine Projekte</a>
<a href="{{ url_for('profil.cockpit') }}" class="btn btn-secondary">Cockpit</a>
</div>
</div>
{# ============== Lineage-Banner: Vorlage + Wiederholungen ============== #}
{% if vorlage %}
<div class="card" style="background: #EBF5FB; border-left: 4px solid var(--primary); margin-bottom: 0.8rem;">
<strong>🔗 Vorlage:</strong>
<a href="{{ url_for('profil.projekt_detail', projekt_id=vorlage.id) }}">{{ vorlage.titel }}</a>
von {{ tier_emoji(vorlage.tier or '') }} {{ vorlage.vorname or '—' }}
<div style="font-size: 0.85rem; margin-top: 0.3rem; color: var(--text-light);">
Du machst dein eigenes Projekt — die Vorlage ist nur Inspiration. Eigener Twist erwuenscht!
</div>
</div>
{% endif %}
{% if wiederholungen %}
<div class="card" style="background: #EAFBF1; border-left: 4px solid var(--success); margin-bottom: 0.8rem;">
<strong>🔁 Wurde wiederholt von:</strong>
{% for w in wiederholungen %}
{{ tier_emoji(w.tier or '') }}
<a href="{{ url_for('profil.projekt_detail', projekt_id=w.id) }}">{{ w.vorname or '—' }}</a>{% if not loop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
{# ============== Status-Zeile ============== #}
<div style="margin-bottom: 1rem;">
{% if p.status_lebenszyklus == 'in_arbeit' %}
<span class="badge badge-mittel">🔧 in Arbeit</span>
{% else %}
<span class="badge badge-anfaenger">✅ abgeschlossen</span>
{% if p.selbsteinschaetzung == 'zufrieden' %}<span class="badge badge-anfaenger">🟢 zufrieden</span>
{% elif p.selbsteinschaetzung == 'mittel' %}<span class="badge badge-mittel">🟡 mittel</span>
{% elif p.selbsteinschaetzung == 'gelernt' %}<span class="badge badge-fortgeschritten">🔴 gelernt</span>
{% endif %}
{% if p.freigabe_status == 'wartet_auf_freigabe' %}<span class="badge badge-mittel">⏳ wartet auf Freigabe</span>
{% elif p.freigabe_status == 'rueckfrage' %}<span class="badge badge-fortgeschritten">❓ Rueckfrage offen</span>
{% elif p.freigabe_status == 'freigegeben' %}<span class="badge badge-anfaenger">✓ freigegeben</span>
{% endif %}
{% endif %}
</div>
<div style="display: grid; grid-template-columns: 280px 1fr; gap: 1rem;">
{# ============== Cover-Bild ============== #}
<div class="card" style="text-align: center;">
{% if p.hat_cover %}
<img src="{{ url_for('api.projekt_cover', projekt_id=p.id) }}" alt=""
style="width: 100%; max-height: 240px; object-fit: cover; border-radius: 6px;">
{% else %}
<div style="height: 200px; background: linear-gradient(135deg, #ECF0F1, #D5DBDB); border-radius: 6px;
display: flex; align-items: center; justify-content: center; font-size: 3rem; color: var(--text-light);">
🎯
</div>
{% endif %}
<div style="margin-top: 0.6rem; display: flex; gap: 0.4rem; justify-content: center; flex-wrap: wrap;">
<a href="{{ url_for('profil.projekt_cover', projekt_id=p.id) }}" class="btn btn-secondary" style="font-size: 0.85rem;">
{% if p.hat_cover %}Bild aendern{% else %}Bild hinzufuegen{% endif %}
</a>
{% if p.hat_cover %}
<form method="post" action="{{ url_for('profil.projekt_cover_loeschen', projekt_id=p.id) }}" style="display:inline;"
onsubmit="return confirm('Cover-Bild loeschen?')">
<button class="btn btn-secondary" style="font-size: 0.85rem; color: var(--danger);">loeschen</button>
</form>
{% endif %}
</div>
</div>
{# ============== Beschreibung + Eval ============== #}
<div>
<div class="card">
<h2>Beschreibung</h2>
{% if p.beschreibung_md %}
<div>{{ p.beschreibung_md|markdown }}</div>
{% else %}
<p style="color: var(--text-light);"><em>Noch keine Beschreibung.</em></p>
{% endif %}
{% if p.status_lebenszyklus == 'in_arbeit' %}
<div style="margin-top: 1rem;">
<a href="{{ url_for('profil.projekt_bearbeiten', projekt_id=p.id) }}" class="btn btn-secondary" style="font-size: 0.85rem;">
bearbeiten
</a>
</div>
{% endif %}
</div>
{% if p.status_lebenszyklus == 'abgeschlossen' %}
<div class="card">
<h2>Deine Bewertung</h2>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.6rem; margin-bottom: 1rem;">
<div style="text-align: center; padding: 0.6rem; background: #F4F6F7; border-radius: 6px;">
<div style="font-size: 0.75rem; color: var(--text-light);">Gesamt</div>
<div style="font-size: 1.4rem; margin-top: 0.2rem;">
{% if p.selbsteinschaetzung == 'zufrieden' %}🟢
{% elif p.selbsteinschaetzung == 'mittel' %}🟡
{% elif p.selbsteinschaetzung == 'gelernt' %}🔴
{% endif %}
</div>
<div style="font-size: 0.85rem; margin-top: 0.2rem;">{{ p.selbsteinschaetzung or '—' }}</div>
</div>
<div style="text-align: center; padding: 0.6rem; background: #F4F6F7; border-radius: 6px;">
<div style="font-size: 0.75rem; color: var(--text-light);">Planung</div>
<div style="font-size: 1.4rem; margin-top: 0.2rem;">
{% if p.selbsteinschaetzung_planung == 'gut' %}🟢
{% elif p.selbsteinschaetzung_planung == 'ok' %}🟡
{% elif p.selbsteinschaetzung_planung == 'schwierig' %}🔴
{% else %}—{% endif %}
</div>
<div style="font-size: 0.85rem; margin-top: 0.2rem;">{{ p.selbsteinschaetzung_planung or '—' }}</div>
</div>
<div style="text-align: center; padding: 0.6rem; background: #F4F6F7; border-radius: 6px;">
<div style="font-size: 0.75rem; color: var(--text-light);">Ausfuehrung</div>
<div style="font-size: 1.4rem; margin-top: 0.2rem;">
{% if p.selbsteinschaetzung_ausfuehrung == 'gut' %}🟢
{% elif p.selbsteinschaetzung_ausfuehrung == 'ok' %}🟡
{% elif p.selbsteinschaetzung_ausfuehrung == 'schwierig' %}🔴
{% else %}—{% endif %}
</div>
<div style="font-size: 0.85rem; margin-top: 0.2rem;">{{ p.selbsteinschaetzung_ausfuehrung or '—' }}</div>
</div>
</div>
{% if p.was_gelernt %}
<div style="margin-bottom: 0.6rem;">
<strong>Was gelernt:</strong>
<div style="margin-top: 0.2rem;">{{ p.was_gelernt }}</div>
</div>
{% endif %}
{% if p.was_klappte_nicht %}
<div>
<strong>Was nicht klappte:</strong>
<div style="margin-top: 0.2rem;">{{ p.was_klappte_nicht }}</div>
</div>
{% endif %}
</div>
{% endif %}
{# ============== Werkstatt (nur waehrend in_arbeit) ============== #}
{% if p.status_lebenszyklus == 'in_arbeit' %}
<div class="card" style="border-top: 4px solid var(--primary);">
<h2>🔧 Werkstatt</h2>
<p style="color: var(--text-light); font-size: 0.9rem;">
Hier alles zum Projekt — Beschreibung, Software, Drucker, MakerWorld-Link und Dateien.
</p>
<form method="post" action="{{ url_for('profil.projekt_werkstatt_speichern', projekt_id=p.id) }}"
style="display: grid; gap: 0.8rem;">
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light); margin-bottom: 0.2rem;">Titel *</label>
<input type="text" name="titel" required maxlength="100" value="{{ p.titel }}"
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); margin-bottom: 0.2rem;">Beschreibung</label>
<textarea name="beschreibung_md" rows="5" maxlength="2000"
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px; font-family: inherit;">{{ p.beschreibung_md or '' }}</textarea>
<div style="font-size: 0.75rem; color: var(--text-light); margin-top: 0.2rem;">
Markdown: **fett**, *kursiv*, Listen mit -.
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.8rem;">
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light); margin-bottom: 0.2rem;">
Hauptsoftware
</label>
<select name="modellier_software_slug"
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
<option value="">— noch nicht festgelegt —</option>
{% for s in software_liste %}
<option value="{{ s.slug }}" {% if p.modellier_software_slug == s.slug %}selected{% endif %}>
{{ s.titel }}
</option>
{% endfor %}
</select>
</div>
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light); margin-bottom: 0.2rem;">
Drucker
</label>
<select name="drucker_id_used"
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
<option value="">— noch nicht festgelegt —</option>
{% for d in drucker_liste %}
<option value="{{ d.id }}" {% if p.drucker_id_used == d.id %}selected{% endif %}>
{{ d.name }} ({{ d.modell }})
</option>
{% endfor %}
</select>
</div>
</div>
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light); margin-bottom: 0.2rem;">
MakerWorld-Link <span style="font-weight: normal;">(optional, wenn du dein Projekt dort veroeffentlicht hast)</span>
</label>
<input type="url" name="makerworld_url" value="{{ p.makerworld_url or '' }}"
placeholder="https://makerworld.com/en/models/..."
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
</div>
<div style="display: flex; justify-content: flex-end;">
<button class="btn btn-primary">Werkstatt speichern</button>
</div>
</form>
{# ============== Dateien-Bereich ============== #}
<h3 style="margin-top: 1.5rem;">Dateien</h3>
<p style="color: var(--text-light); font-size: 0.85rem;">
STL, 3MF, Fotos. Mindestens eine Datei + ein Cover-Bild sind noetig, um das Projekt abzuschliessen.
</p>
{% if dateien %}
<table style="margin-top: 0.5rem;">
<thead>
<tr><th>Datei</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>
</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
</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>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color: var(--text-light); margin-top: 0.5rem;">
<em>Noch keine Dateien.</em>
</p>
{% endif %}
{# Upload-Zone #}
<div id="upload-zone" style="margin-top: 1rem; border: 3px dashed var(--border); border-radius: 8px;
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
</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>
</div>
{% endif %}
{# ============== Aktionen ============== #}
<div class="card">
{% if p.status_lebenszyklus == 'in_arbeit' %}
<h2>Naechster Schritt</h2>
<div style="display: flex; gap: 0.6rem; flex-wrap: wrap;">
<a href="{{ url_for('profil.projekt_cover', projekt_id=p.id) }}" class="btn btn-secondary">
📷 {% if p.hat_cover %}Cover aendern{% else %}Cover-Bild hinzufuegen{% endif %}
</a>
{% if darf_abschliessen %}
<a href="{{ url_for('profil.projekt_abschluss', projekt_id=p.id) }}" class="btn btn-primary">
✅ Projekt abschliessen
</a>
{% else %}
<button class="btn btn-secondary" disabled title="{{ abschluss_fehlende|join(' · ') }}"
style="opacity: 0.5; cursor: not-allowed;">
✅ Projekt abschliessen
</button>
{% endif %}
</div>
{% if not darf_abschliessen %}
<div style="margin-top: 0.6rem; background: #FEF9E7; padding: 0.6rem 0.8rem; border-radius: 6px; font-size: 0.85rem;">
<strong>Noch nicht abschliessbar:</strong>
<ul style="margin: 0.3rem 0 0 1.2rem; padding: 0;">
{% for f in abschluss_fehlende %}<li>{{ f }}</li>{% endfor %}
</ul>
</div>
{% endif %}
{% elif p.freigabe_status == 'wartet_auf_freigabe' %}
<div style="background: #FEF9E7; padding: 0.8rem; border-radius: 6px; border-left: 4px solid var(--warning);">
<strong>Wartet auf Freigabe vom Lehrer.</strong>
Sobald er drueber geschaut hat, kannst du ein neues Projekt anfangen.
</div>
{% elif p.freigabe_status == 'rueckfrage' %}
<div style="background: #FADBD8; padding: 0.8rem; border-radius: 6px; border-left: 4px solid var(--danger);">
<strong>Lehrer hat eine Rueckfrage.</strong>
Schau ins Cockpit fuer die Details und beantworte sie.
</div>
{% else %}
<div style="background: #EAFBF1; padding: 0.8rem; border-radius: 6px; border-left: 4px solid var(--success);">
<strong>Freigegeben.</strong>
{% if p.freigegeben_am %}Am {{ p.freigegeben_am[:10] }}.{% endif %}
Du kannst jetzt ein neues Projekt anfangen.
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
{# Upload-JS nur wenn Werkstatt sichtbar ist (also in_arbeit). #}
{% if p.status_lebenszyklus == 'in_arbeit' %}
<script>
(function() {
var zone = document.getElementById('upload-zone');
var input = document.getElementById('datei-input');
var status = document.getElementById('upload-status');
if (!zone || !input) return;
var MAX_BYTES = 30 * 1024 * 1024;
zone.addEventListener('click', function(e) {
if (e.target === input) return;
input.click();
});
zone.addEventListener('dragover', function(e) {
e.preventDefault();
zone.style.borderColor = 'var(--primary)';
zone.style.background = '#EBF5FB';
});
zone.addEventListener('dragleave', function() {
zone.style.borderColor = 'var(--border)';
zone.style.background = '#fafafa';
});
zone.addEventListener('drop', function(e) {
e.preventDefault();
zone.style.borderColor = 'var(--border)';
zone.style.background = '#fafafa';
if (e.dataTransfer.files && e.dataTransfer.files.length) {
dateienHochladen(e.dataTransfer.files);
}
});
input.addEventListener('change', function() {
if (this.files && this.files.length) dateienHochladen(this.files);
this.value = '';
});
async function dateienHochladen(fileList) {
var files = Array.from(fileList);
var fehler = 0;
for (var i = 0; i < files.length; i++) {
var f = files[i];
status.textContent = 'Lade ' + (i+1) + '/' + files.length + ': ' + f.name + ' ...';
status.style.color = 'var(--text-light)';
if (f.size > MAX_BYTES) {
status.textContent = 'Zu gross: ' + f.name + ' (' + Math.round(f.size/1024/1024) + ' MB)';
status.style.color = 'var(--danger)';
fehler++;
continue;
}
try {
var dataUrl = await readAsDataUrl(f);
var resp = await fetch('{{ url_for("profil.projekt_datei_upload", projekt_id=p.id) }}', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
dateiname: f.name,
mime: f.type || 'application/octet-stream',
base64: dataUrl
})
});
var json = await resp.json();
if (!json.ok) {
status.textContent = 'Fehler bei ' + f.name + ': ' + (json.error || 'unbekannt');
status.style.color = 'var(--danger)';
fehler++;
}
} catch (err) {
status.textContent = 'Fehler bei ' + f.name + ': ' + err;
status.style.color = 'var(--danger)';
fehler++;
}
}
if (fehler === 0) {
status.textContent = files.length + ' Datei(en) hochgeladen — Seite wird neu geladen ...';
status.style.color = 'var(--success)';
setTimeout(function() { location.reload(); }, 600);
}
}
function readAsDataUrl(file) {
return new Promise(function(resolve, reject) {
var reader = new FileReader();
reader.onload = function() { resolve(reader.result); };
reader.onerror = function() { reject(reader.error); };
reader.readAsDataURL(file);
});
}
})();
</script>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,66 @@
{% extends "base.html" %}
{% block title %}Meine Projekte{% endblock %}
{% block content %}
<div class="section-header" style="display:flex; justify-content:space-between; align-items:center;">
<h1>Meine Projekte</h1>
<div>
{% if darf_neues %}
<a href="{{ url_for('profil.projekt_neu') }}" class="btn btn-primary">+ Neues Projekt</a>
{% endif %}
<a href="{{ url_for('profil.cockpit') }}" class="btn btn-secondary">Cockpit</a>
</div>
</div>
{% if not darf_neues and sperr_grund %}
<div class="card" style="background:#FEF9E7; border-left: 4px solid var(--warning);">
{{ sperr_grund }}
</div>
{% endif %}
{% if projekte %}
<div class="grid" style="grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));">
{% for p in projekte %}
<a href="{{ url_for('profil.projekt_detail', projekt_id=p.id) }}" class="card"
style="text-decoration: none; color: inherit; display: flex; flex-direction: column; gap: 0.6rem;">
{% if p.hat_cover %}
<img src="{{ url_for('api.projekt_cover', projekt_id=p.id) }}" alt=""
style="width: 100%; height: 160px; object-fit: cover; border-radius: 6px; background: #fff;">
{% else %}
<div style="height: 160px; background: linear-gradient(135deg, #ECF0F1, #D5DBDB); border-radius: 6px;
display: flex; align-items: center; justify-content: center; font-size: 2.5rem; color: var(--text-light);">
🎯
</div>
{% endif %}
<h3 style="margin: 0; color: var(--primary-dark);">{{ p.titel }}</h3>
<div style="font-size: 0.85rem;">
{% if p.status_lebenszyklus == 'in_arbeit' %}
<span class="badge badge-mittel">in Arbeit</span>
{% else %}
{% if p.selbsteinschaetzung == 'zufrieden' %}<span class="badge badge-anfaenger">🟢 zufrieden</span>
{% elif p.selbsteinschaetzung == 'mittel' %}<span class="badge badge-mittel">🟡 mittel</span>
{% elif p.selbsteinschaetzung == 'gelernt' %}<span class="badge badge-fortgeschritten">🔴 gelernt</span>
{% endif %}
{% if p.freigabe_status == 'wartet_auf_freigabe' %}<span class="badge badge-mittel">⏳ wartet auf Freigabe</span>
{% elif p.freigabe_status == 'rueckfrage' %}<span class="badge badge-fortgeschritten">❓ Rueckfrage</span>
{% endif %}
{% endif %}
</div>
{% if p.abgeschlossen_am %}
<div style="font-size: 0.8rem; color: var(--text-light);">
abgeschlossen {{ p.abgeschlossen_am[:10] }}
</div>
{% endif %}
</a>
{% endfor %}
</div>
{% else %}
<div class="card">
<p style="color: var(--text-light);">
Du hast noch keine Projekte.
{% if darf_neues %}<a href="{{ url_for('profil.projekt_neu') }}">Leg dein erstes an.</a>{% endif %}
</p>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block title %}Neues Projekt{% endblock %}
{% block content %}
<div class="section-header" style="display:flex; justify-content:space-between; align-items:center;">
<h1>Neues Projekt anlegen</h1>
<a href="{{ url_for('profil.cockpit') }}" class="btn btn-secondary">Abbrechen</a>
</div>
<div class="card" style="background:#EBF5FB; border-left: 4px solid var(--primary);">
<strong>Commitment.</strong> Mit dem Anlegen entscheidest du dich, dieses Projekt durchzuziehen.
Du kannst es spaeter abschliessen und selbst bewerten — und erst dann wieder ein neues anfangen.
</div>
<div class="card">
<form method="post" style="display: grid; gap: 1rem;">
<div>
<label style="display:block; font-size: 0.9rem; color: var(--text-light); margin-bottom: 0.3rem;">
Titel * <span style="font-weight: normal;">(was willst du bauen?)</span>
</label>
<input type="text" name="titel" required maxlength="100" autofocus
value="{{ eingabe_titel or '' }}"
placeholder="z.B. 'Stiftehalter mit meinem Lieblings-Tier'"
style="padding: 0.6rem; width: 100%; border: 1px solid var(--border); border-radius: 4px; font-size: 1rem;">
</div>
<div>
<label style="display:block; font-size: 0.9rem; color: var(--text-light); margin-bottom: 0.3rem;">
Beschreibung <span style="font-weight: normal;">(optional, kannst du spaeter ergaenzen)</span>
</label>
<textarea name="beschreibung_md" rows="6" maxlength="2000"
placeholder="Was willst du genau bauen? Hast du schon eine Idee, mit welcher Software (TinkerCAD, MakerWorld, ...)? Welches Material?"
style="padding: 0.6rem; width: 100%; border: 1px solid var(--border); border-radius: 4px; font-size: 0.95rem; font-family: inherit;">{{ eingabe_beschreibung or '' }}</textarea>
<div style="font-size: 0.8rem; color: var(--text-light); margin-top: 0.3rem;">
Markdown geht: **fett**, *kursiv*, Listen mit -.
</div>
</div>
<div style="display: flex; justify-content: space-between;">
<a href="{{ url_for('profil.cockpit') }}" class="btn btn-secondary">Abbrechen</a>
<button class="btn btn-primary">Projekt anlegen</button>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,279 @@
{% extends "base.html" %}
{% block title %}Projekte{% endblock %}
{% block content %}
<style>
.kat-tabelle { width: 100%; border-collapse: collapse; }
.kat-tabelle th {
background: #F2F3F4; padding: 0.6rem 0.5rem;
text-align: left; font-size: 0.85rem; cursor: pointer;
user-select: none; white-space: nowrap;
}
.kat-tabelle th:hover { background: #E5E7E8; }
.kat-tabelle th .sort-pfeil {
display: inline-block; margin-left: 0.3rem; opacity: 0.3; font-size: 0.7rem;
}
.kat-tabelle th.sort-aktiv .sort-pfeil { opacity: 1; color: var(--primary); }
.kat-tabelle td {
padding: 0.5rem; border-bottom: 1px solid var(--border);
vertical-align: middle; font-size: 0.9rem;
}
.kat-zeile { cursor: pointer; }
.kat-zeile:hover { background: #F8F9FA; }
.kat-zeile.offen { background: #EBF5FB; }
.kat-thumb {
width: 40px; height: 40px; border-radius: 6px; object-fit: cover;
background: linear-gradient(135deg, #ECF0F1, #D5DBDB);
display: flex; align-items: center; justify-content: center;
font-size: 1.2rem; color: var(--text-light);
}
.kat-thumb img { width: 100%; height: 100%; object-fit: cover; border-radius: 6px; }
.kat-detail { display: none; background: #F8F9FA; }
.kat-detail.offen { display: table-row; }
.kat-detail-inner {
display: flex; gap: 1.5rem; padding: 1rem 0.5rem; flex-wrap: wrap;
}
.kat-detail-bild img {
max-width: 260px; max-height: 260px; border-radius: 8px;
border: 1px solid var(--border); background: #fff;
}
.kat-detail-bild-platzhalter {
width: 260px; height: 200px; border-radius: 8px;
background: linear-gradient(135deg, #ECF0F1, #D5DBDB);
display: flex; align-items: center; justify-content: center;
font-size: 3rem; color: var(--text-light);
}
.kat-detail-info { flex: 1; min-width: 260px; }
.kat-detail-info h3 { margin-top: 0; }
.kat-detail-info .meta {
font-size: 0.85rem; color: var(--text-light); margin-bottom: 0.6rem;
}
.kat-dateien { margin-top: 0.5rem; }
.kat-dateien a {
display: inline-block; margin: 0.2rem 0.4rem 0.2rem 0;
padding: 0.2rem 0.6rem; background: #fff; border: 1px solid var(--border);
border-radius: 4px; text-decoration: none; font-size: 0.85rem; color: var(--text);
}
.kat-dateien a:hover { background: #ECF0F1; }
</style>
<h1>Projekte</h1>
<p style="color: var(--text-light);">
Was die AG gerade macht — laufende und fertige Projekte. Klick auf eine Zeile fuer Details.
Spalten-Header klicken zum Sortieren.
</p>
{% if projekte %}
<div class="card" style="padding: 0;">
<table class="kat-tabelle" id="kat-tabelle">
<thead>
<tr>
<th></th>
<th data-sort="titel">Titel<span class="sort-pfeil">▲▼</span></th>
<th data-sort="person">Wer<span class="sort-pfeil">▲▼</span></th>
<th data-sort="status">Status<span class="sort-pfeil">▲▼</span></th>
<th data-sort="erfolg">Erfolg<span class="sort-pfeil">▲▼</span></th>
<th data-sort="datum">Datum<span class="sort-pfeil">▲▼</span></th>
<th title="MakerWorld">MW</th>
</tr>
</thead>
<tbody>
{% for p in projekte %}
{# ====== Kompakte Zeile ====== #}
<tr class="kat-zeile" data-projekt="{{ p.id }}"
data-titel="{{ p.titel|lower }}"
data-person="{{ (p.vorname or '')|lower }}"
data-status="{{ p.status_lebenszyklus }}"
data-erfolg="{% if p.selbsteinschaetzung == 'zufrieden' %}1{% elif p.selbsteinschaetzung == 'mittel' %}2{% elif p.selbsteinschaetzung == 'gelernt' %}3{% else %}9{% endif %}"
data-datum="{{ p.abgeschlossen_am or p.geaendert_am or '' }}">
<td>
<div class="kat-thumb">
{% if p.hat_cover %}
<img src="{{ url_for('api.projekt_cover', projekt_id=p.id) }}" alt="">
{% else %}🎯{% endif %}
</div>
</td>
<td><strong>{{ p.titel }}</strong></td>
<td>{{ tier_emoji(p.tier or '') }} {{ p.vorname or '—' }}</td>
<td>
{% if p.status_lebenszyklus == 'in_arbeit' %}<span class="badge badge-mittel">🔧 laeuft</span>
{% else %}<span class="badge badge-anfaenger">✅ fertig</span>{% endif %}
</td>
<td>
{% if p.selbsteinschaetzung == 'zufrieden' %}🟢
{% elif p.selbsteinschaetzung == 'mittel' %}🟡
{% elif p.selbsteinschaetzung == 'gelernt' %}🔴
{% else %}—{% endif %}
</td>
<td style="font-size: 0.8rem; color: var(--text-light); white-space: nowrap;">
{{ (p.abgeschlossen_am or p.geaendert_am or '')|iso_format('%d.%m.%y') }}
</td>
<td style="text-align: center;">
{% if p.makerworld_url %}
<a href="{{ p.makerworld_url }}" target="_blank" rel="noopener noreferrer"
onclick="event.stopPropagation();" title="Auf MakerWorld" style="text-decoration: none;">🌐</a>
{% endif %}
</td>
</tr>
{# ====== Detail-Zeile (versteckt bis Klick) ====== #}
<tr class="kat-detail" data-fuer="{{ p.id }}">
<td colspan="7">
<div class="kat-detail-inner">
<div class="kat-detail-bild">
{% if p.hat_cover %}
<img src="{{ url_for('api.projekt_cover', projekt_id=p.id) }}" alt="">
{% else %}
<div class="kat-detail-bild-platzhalter">🎯</div>
{% endif %}
</div>
<div class="kat-detail-info">
<h3>{{ p.titel }}</h3>
<div class="meta">
{{ tier_emoji(p.tier or '') }} <strong>{{ p.vorname or '—' }}</strong>
{% if p.software_titel %} · 🖥️ {{ p.software_titel }}{% endif %}
{% if p.drucker_name %} · 🖨️ {{ p.drucker_name }}{% endif %}
{% if p.abgeschlossen_am %} · abgeschlossen {{ p.abgeschlossen_am|iso_format('%d.%m.%Y') }}{% endif %}
</div>
{% if p.beschreibung_md %}
<div style="margin-bottom: 0.8rem;">{{ p.beschreibung_md|markdown }}</div>
{% else %}
<p style="color: var(--text-light);"><em>Keine Beschreibung.</em></p>
{% endif %}
{% if p.dateien %}
<div class="kat-dateien">
<strong style="font-size: 0.85rem;">Dateien:</strong>
{% for d in p.dateien %}
<a href="{{ url_for('api.projekt_datei_download', projekt_id=p.id, datei_id=d.id) }}">
📎 {{ 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>
{% endfor %}
</div>
{% endif %}
{% if p.makerworld_url %}
<div style="margin-top: 0.8rem;">
<a href="{{ p.makerworld_url }}" target="_blank" rel="noopener noreferrer"
class="btn btn-secondary" style="font-size: 0.85rem;">
🌐 Auf MakerWorld ansehen ↗
</a>
</div>
{% endif %}
{# ====== "Sowas mache ich auch"-Button ====== #}
<div style="margin-top: 1rem; padding-top: 0.8rem; border-top: 1px solid var(--border);">
{% if p.profil_id == eigene_profil_id %}
<span style="font-size: 0.85rem; color: var(--text-light); font-style: italic;">
Dein eigenes Projekt.
</span>
{% elif darf_wiederholen %}
<form method="post"
action="{{ url_for('profil.projekt_aus_vorlage', vorlage_id=p.id) }}"
onclick="event.stopPropagation();"
onsubmit="return confirm('Neues Projekt mit Vorlage \'{{ p.titel }}\' anlegen?')">
<button class="btn btn-primary" style="font-size: 0.9rem;">
+ Sowas mache ich auch
</button>
</form>
{% else %}
<span style="font-size: 0.85rem; color: var(--text-light);">
{{ wiederhol_sperr_grund }}
</span>
{% endif %}
</div>
</div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card">
<p style="color: var(--text-light);">Noch keine Projekte im Katalog.</p>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
(function() {
var tabelle = document.getElementById('kat-tabelle');
if (!tabelle) return;
var tbody = tabelle.querySelector('tbody');
// ===== Aufklappen =====
tabelle.querySelectorAll('.kat-zeile').forEach(function(zeile) {
zeile.addEventListener('click', function(e) {
if (e.target.tagName === 'A') return; // MakerWorld-Link nicht abfangen
var pid = zeile.dataset.projekt;
var detail = tabelle.querySelector('.kat-detail[data-fuer="' + pid + '"]');
if (!detail) return;
var offen = detail.classList.toggle('offen');
zeile.classList.toggle('offen', offen);
});
});
// ===== Sortieren =====
var sortRichtung = {}; // key -> 'asc' | 'desc'
function sortiereNach(key) {
var richtung = sortRichtung[key] === 'asc' ? 'desc' : 'asc';
sortRichtung = {}; // nur eine Sortierung aktiv
sortRichtung[key] = richtung;
// Zeilen-Paare einsammeln: [kat-zeile, kat-detail]
var paare = [];
tabelle.querySelectorAll('.kat-zeile').forEach(function(zeile) {
var pid = zeile.dataset.projekt;
var detail = tabelle.querySelector('.kat-detail[data-fuer="' + pid + '"]');
paare.push({ zeile: zeile, detail: detail, key: zeile.dataset[key] || '' });
});
// Sortier-Helper: erfolg + status sind numerisch/string-codiert, titel/person/datum string-lexikografisch
paare.sort(function(a, b) {
var va = a.key, vb = b.key;
if (key === 'erfolg') { va = parseInt(va) || 9; vb = parseInt(vb) || 9; }
if (va < vb) return richtung === 'asc' ? -1 : 1;
if (va > vb) return richtung === 'asc' ? 1 : -1;
return 0;
});
// Neu in tbody einhaengen (paar fuer paar — Detail folgt direkt)
paare.forEach(function(p) {
tbody.appendChild(p.zeile);
if (p.detail) tbody.appendChild(p.detail);
});
// Sortier-Indikator
tabelle.querySelectorAll('th[data-sort]').forEach(function(th) {
th.classList.remove('sort-aktiv');
var pfeil = th.querySelector('.sort-pfeil');
if (pfeil) pfeil.textContent = '▲▼';
});
var aktivTh = tabelle.querySelector('th[data-sort="' + key + '"]');
if (aktivTh) {
aktivTh.classList.add('sort-aktiv');
aktivTh.querySelector('.sort-pfeil').textContent = richtung === 'asc' ? '▲' : '▼';
}
}
tabelle.querySelectorAll('th[data-sort]').forEach(function(th) {
th.addEventListener('click', function() {
sortiereNach(th.dataset.sort);
});
});
// Initial: nach Datum absteigend (neueste oben)
sortiereNach('datum');
sortiereNach('datum'); // zweimal -> desc
})();
</script>
{% endblock %}

167
templates/software.html Normal file
View File

@@ -0,0 +1,167 @@
{% extends "base.html" %}
{% block title %}Software{% endblock %}
{% block content %}
{# Kleine, lokale CSS-Erganzungen fuer die Software-Karten.
Bewusst inline, damit alle Stil-Anpassungen auf einen Blick sichtbar sind.
Wenn die Site mehr eigene Stile bekommt, ziehen wir sie nach static/. #}
<style>
.sw-karte {
display: flex;
flex-direction: column;
gap: 0.6rem;
text-decoration: none;
color: inherit;
}
.sw-karte .bild-platzhalter {
height: 110px;
background: linear-gradient(135deg, #ECF0F1, #D5DBDB);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 2.5rem;
color: var(--text-light);
}
.sw-karte img.logo {
height: 110px;
width: 100%;
object-fit: contain;
background: #fff;
border-radius: 6px;
padding: 0.5rem;
border: 1px solid var(--border);
}
.sw-karte h3 { margin: 0; color: var(--primary-dark); }
.sw-karte .plattform { font-size: 0.8rem; color: var(--text-light); margin-top: -0.2rem; }
.sw-karte .kurz { font-size: 0.95rem; line-height: 1.45; }
.sw-karte .btn-reihe {
margin-top: auto;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
padding-top: 0.4rem;
}
.sw-karte .btn-reihe .btn { font-size: 0.8rem; padding: 0.35rem 0.7rem; }
.sw-store {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.8rem;
padding: 0.35rem 0.7rem;
border-radius: 6px;
background: #2C3E50;
color: #fff;
text-decoration: none;
}
.sw-store:hover { background: #1B2631; }
.sektion-kopf { margin: 2rem 0 0.8rem; }
.sektion-kopf:first-of-type { margin-top: 0.5rem; }
.sektion-kopf h2 { color: var(--text); }
.sektion-kopf p { color: var(--text-light); font-size: 0.9rem; }
</style>
{# ===========================================================================
Karten-Makro: wird unten in jeder Sektion verwendet.
Muss VOR den Aufrufen stehen — Jinja2 mit extends ist da streng.
=========================================================================== #}
{% macro karte(k) %}
<div class="card sw-karte">
{# Bild: Crop-Upload (BLOB) > static-Fallback > Emoji-Platzhalter #}
{% if k.hat_bild_blob %}
<img src="{{ url_for('api.software_bild', karte_id=k.id) }}" alt="{{ k.titel }}" class="logo">
{% elif k.bild_pfad %}
<img src="{{ url_for('static', filename='img/software/' ~ k.bild_pfad) }}" alt="{{ k.titel }}" class="logo">
{% else %}
<div class="bild-platzhalter">
{%- if k.kategorie == 'app' -%}📱
{%- elif k.kategorie == 'generator' -%}🧩
{%- else -%}🖥️
{%- endif -%}
</div>
{% endif %}
<h3>{{ k.titel }}</h3>
{% if k.plattform %}<div class="plattform">{{ k.plattform }}</div>{% endif %}
<div class="kurz">{{ k.kurzbeschreibung }}</div>
<div class="btn-reihe">
{% if k.url %}
<a href="{{ k.url }}" target="_blank" rel="noopener noreferrer" class="btn btn-primary">
{% if k.kategorie == 'generator' %}Zum Generator ↗{% else %}Zur Software ↗{% endif %}
</a>
{% endif %}
{% if k.tutorial_url %}
<a href="{{ k.tutorial_url }}" target="_blank" rel="noopener noreferrer" class="btn btn-secondary">
Tutorial ↗
</a>
{% endif %}
{% if k.app_store_url %}
<a href="{{ k.app_store_url }}" target="_blank" rel="noopener noreferrer" class="sw-store">
App Store
</a>
{% endif %}
{% if k.play_store_url %}
<a href="{{ k.play_store_url }}" target="_blank" rel="noopener noreferrer" class="sw-store">
▶ Google Play
</a>
{% endif %}
</div>
</div>
{% endmacro %}
<h1>Software</h1>
<p style="color: var(--text-light);">
Diese Programme nutzen wir in der AG. Den Umgang lernst du in den
<a href="{{ url_for('oeffentlich.lektionen') }}">Lektionen</a> — hier ist nur die Uebersicht.
</p>
{# ---- Sektion: Software ---- #}
{% if gruppen.software %}
<div class="sektion-kopf">
<h2>Programme</h2>
<p>Auf Schul-PCs installiert oder im Browser nutzbar.</p>
</div>
<div class="grid">
{% for k in gruppen.software %}
{{ karte(k) }}
{% endfor %}
</div>
{% endif %}
{# ---- Sektion: Apps ---- #}
{% if gruppen.app %}
<div class="sektion-kopf">
<h2>Handy-Apps</h2>
<p>Aufs eigene Smartphone laden — QR-Codes zum Scannen folgen.</p>
</div>
<div class="grid">
{% for k in gruppen.app %}
{{ karte(k) }}
{% endfor %}
</div>
{% endif %}
{# ---- Sektion: Generatoren ---- #}
{% if gruppen.generator %}
<div class="sektion-kopf">
<h2>Online-Generatoren</h2>
<p>Webseiten, auf denen du fertige 3D-Modelle nach deinen Wuenschen erzeugen kannst — kein CAD noetig.</p>
</div>
<div class="grid">
{% for k in gruppen.generator %}
{{ karte(k) }}
{% endfor %}
</div>
{% endif %}
{# Wenn gar nichts da ist (Migration noch nicht gelaufen?) #}
{% if not gruppen.software and not gruppen.app and not gruppen.generator %}
<div class="card">
<p>Noch keine Software eingetragen.
{% if session.admin %}<a href="{{ url_for('admin.software_liste') }}">Im Admin anlegen.</a>{% endif %}
</p>
</div>
{% endif %}
{% endblock %}