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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -15,6 +15,7 @@ backups/
|
||||
# Editor
|
||||
.vscode/
|
||||
.idea/
|
||||
.claude/
|
||||
*.swp
|
||||
.DS_Store
|
||||
|
||||
|
||||
23
app.py
23
app.py
@@ -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',
|
||||
|
||||
51
database.py
51
database.py
@@ -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
|
||||
-- ===========================================================================
|
||||
|
||||
44
migrations/2026-05-25_drucker_freigabe_links.py
Normal file
44
migrations/2026-05-25_drucker_freigabe_links.py
Normal 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()
|
||||
31
migrations/2026-05-25_profil_reservieren_flag.py
Normal file
31
migrations/2026-05-25_profil_reservieren_flag.py
Normal 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()
|
||||
121
migrations/2026-05-25_projekt_eval_cover.py
Normal file
121
migrations/2026-05-25_projekt_eval_cover.py
Normal 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()
|
||||
44
migrations/2026-05-25_projekt_werkstatt_felder.py
Normal file
44
migrations/2026-05-25_projekt_werkstatt_felder.py
Normal 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()
|
||||
35
migrations/2026-05-25_software_bild_blob.py
Normal file
35
migrations/2026-05-25_software_bild_blob.py
Normal 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()
|
||||
164
migrations/2026-05-25_software_sektion.py
Normal file
164
migrations/2026-05-25_software_sektion.py
Normal 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()
|
||||
684
routes/admin.py
684
routes/admin.py
@@ -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"))
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
653
routes/profil.py
653
routes/profil.py
@@ -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():
|
||||
|
||||
@@ -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
53
services/drucker_links.py
Normal 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
341
services/projekte.py
Normal 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
117
services/slots.py
Normal 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()
|
||||
@@ -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 & 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">
|
||||
|
||||
157
templates/admin_drucker.html
Normal file
157
templates/admin_drucker.html
Normal file
@@ -0,0 +1,157 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Drucker — 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">← 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> — 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 & 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 %}
|
||||
@@ -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;"
|
||||
|
||||
61
templates/admin_projekt_queue.html
Normal file
61
templates/admin_projekt_queue.html
Normal file
@@ -0,0 +1,61 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Projekt-Freigabe — 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">← 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 %}
|
||||
152
templates/admin_projekt_review.html
Normal file
152
templates/admin_projekt_review.html
Normal 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">← 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 %}
|
||||
90
templates/admin_projekte.html
Normal file
90
templates/admin_projekte.html
Normal file
@@ -0,0 +1,90 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Projekte — 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">← 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 %}
|
||||
98
templates/admin_slot_neu.html
Normal file
98
templates/admin_slot_neu.html
Normal file
@@ -0,0 +1,98 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Slot fuer SuS anlegen — 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">← 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 %}
|
||||
78
templates/admin_slots.html
Normal file
78
templates/admin_slots.html
Normal file
@@ -0,0 +1,78 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Slots — 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">← 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 %}
|
||||
178
templates/admin_software.html
Normal file
178
templates/admin_software.html
Normal file
@@ -0,0 +1,178 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Software — 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 %}
|
||||
354
templates/admin_software_bild.html
Normal file
354
templates/admin_software_bild.html
Normal file
@@ -0,0 +1,354 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Bild: {{ karte.titel }} — Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p><a href="{{ url_for('admin.software_bearbeiten', karte_id=karte.id) }}">← 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> · 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 %}
|
||||
138
templates/admin_software_edit.html
Normal file
138
templates/admin_software_edit.html
Normal file
@@ -0,0 +1,138 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ karte.titel }} bearbeiten — 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">← 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 %}
|
||||
@@ -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 %} — „{{ 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
163
templates/drucker.html
Normal 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 %}
|
||||
79
templates/profil_slot_neu.html
Normal file
79
templates/profil_slot_neu.html
Normal 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 %}
|
||||
75
templates/profil_slots.html
Normal file
75
templates/profil_slots.html
Normal 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 %}
|
||||
129
templates/projekt_abschluss.html
Normal file
129
templates/projekt_abschluss.html
Normal 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 %}
|
||||
32
templates/projekt_bearbeiten.html
Normal file
32
templates/projekt_bearbeiten.html
Normal 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">← 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 %}
|
||||
290
templates/projekt_cover.html
Normal file
290
templates/projekt_cover.html
Normal 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) }}">← 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 %}
|
||||
405
templates/projekt_detail.html
Normal file
405
templates/projekt_detail.html
Normal 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 %}
|
||||
66
templates/projekt_liste.html
Normal file
66
templates/projekt_liste.html
Normal 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 %}
|
||||
44
templates/projekt_neu.html
Normal file
44
templates/projekt_neu.html
Normal 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 %}
|
||||
279
templates/projekte_katalog.html
Normal file
279
templates/projekte_katalog.html
Normal 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
167
templates/software.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user