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:
@@ -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()
|
||||
Reference in New Issue
Block a user