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