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>
118 lines
4.8 KiB
Python
118 lines
4.8 KiB
Python
"""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()
|