From 54a9dd5dc2639bc8833688b4478e0f518e34c345 Mon Sep 17 00:00:00 2001 From: bes-r <27369668+bes-r@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:32:50 +0200 Subject: [PATCH] auto-rooster met voorkeuren (dagdeel, vrije dagen, max per dag) --- ROADMAP.md | 5 +-- backend/modules/autorooster/views.py | 52 +++++++++++++++++++++++----- frontend/src/pages.jsx | 30 +++++++++++++--- 3 files changed, 72 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3fdeaf8..add6d86 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,8 +132,9 @@ volgt. ## Later -- [ ] **Auto-rooster slimmer**: voorkeuren toevoegen (vak liever in de ochtend/ - middag, bepaalde dagen vrijhouden, max. blokken per dag per groep). +- [x] **Auto-rooster slimmer**: voorkeuren — vak liever ochtend/middag (zachte + voorkeur met fallback), bepaalde dagen vrijhouden, en max. blokken per dag + per groep. - [ ] **Algemene visuele opfris** (UX/uiterlijk). - [ ] **Module: automatisch rooster genereren.** De gebruiker ge \ No newline at end of file diff --git a/backend/modules/autorooster/views.py b/backend/modules/autorooster/views.py index 896b35e..3a7122c 100644 --- a/backend/modules/autorooster/views.py +++ b/backend/modules/autorooster/views.py @@ -1,4 +1,8 @@ -"""Automatisch rooster: stelt een conflictvrij voorstel voor (greedy).""" +"""Automatisch rooster: stelt een conflictvrij voorstel voor (greedy). + +Houdt rekening met voorkeuren: een vak liever in de ochtend of middag, bepaalde +weekdagen vrijhouden, en een maximum aantal blokken per dag per groep. +""" from django.db.models import Q from rest_framework.decorators import api_view from rest_framework.response import Response @@ -6,12 +10,22 @@ from rest_framework.response import Response from core.models import Activiteit, Groep, Lokaal, Persoon, Roosterblok, Schooljaar, Tijdslot +def _is_ochtend(slot): + return slot.start_tijd.hour < 12 + + @api_view(["POST"]) def voorstel(request): sjid = request.data.get("schooljaar") groep_id = request.data.get("groep") wensen = request.data.get("wensen") or [] respecteer = request.data.get("respecteer_bestaand", True) + vrije_dagen = {int(d) for d in (request.data.get("vrije_dagen") or [])} + mpd = request.data.get("max_per_dag") + try: + max_per_dag = int(mpd) if mpd not in (None, "", 0, "0") else None + except (TypeError, ValueError): + max_per_dag = None if not sjid or not groep_id: return Response({"detail": "Geef een schooljaar en een groep op."}, status=400) try: @@ -20,23 +34,30 @@ def voorstel(request): except (Schooljaar.DoesNotExist, Groep.DoesNotExist): return Response({"detail": "Schooljaar of groep niet gevonden."}, status=404) - # Kandidaat-tijdsloten: groep-specifiek of algemeen (geen groep). - sloten = list( - Tijdslot.objects.filter(actief=True) + # Kandidaat-tijdsloten: groep-specifiek of algemeen, vrije dagen uitgesloten. + sloten = [ + s for s in Tijdslot.objects.filter(actief=True) .filter(Q(groep=groep) | Q(groep__isnull=True)) .order_by("dag", "volgorde", "start_tijd") - ) + if s.dag not in vrije_dagen + ] if not sloten: - return Response({"voorstel": [], "niet_geplaatst": [], "detail": "Geen tijdsloten gevonden voor deze groep."}) + return Response({"voorstel": [], "niet_geplaatst": [], "detail": "Geen bruikbare tijdsloten (let op vrije dagen)."}) + + alle_slot_dag = {t.id: t.dag for t in Tijdslot.objects.all()} # Bezetting bijhouden (eventueel inclusief bestaande blokken). groep_bezet = set() beg_bezet = {} lok_bezet = {} + per_dag = {} # weekdag -> aantal blokken voor deze groep if respecteer: for b in Roosterblok.objects.filter(schooljaar=sj).prefetch_related("begeleiders"): if b.groep_id == groep.id: groep_bezet.add(b.tijdslot_id) + wd = alle_slot_dag.get(b.tijdslot_id) + if wd is not None: + per_dag[wd] = per_dag.get(wd, 0) + 1 for p in b.begeleiders.all(): beg_bezet.setdefault(b.tijdslot_id, set()).add(p.id) if b.lokaal_id: @@ -54,21 +75,32 @@ def voorstel(request): aantal = int(w.get("aantal") or 0) beg = w.get("begeleider") or None lok = w.get("lokaal") or None + dagdeel = (w.get("dagdeel") or "").strip() if not act_id or aantal <= 0: continue beg = int(beg) if beg else None lok = int(lok) if lok else None + + # Voorkeur dagdeel: zet de voorkeurssloten vooraan (zachte voorkeur). + if dagdeel == "ochtend": + kandidaten = [s for s in sloten if _is_ochtend(s)] + [s for s in sloten if not _is_ochtend(s)] + elif dagdeel == "middag": + kandidaten = [s for s in sloten if not _is_ochtend(s)] + [s for s in sloten if _is_ochtend(s)] + else: + kandidaten = sloten + geplaatst = 0 vak_dagen = set() - # Twee passes: eerst spreiden (max 1 per dag), daarna opvullen. - for pas in (1, 2): - for s in sloten: + for pas in (1, 2): # pas 1 = spreiden (max 1 per dag), pas 2 = opvullen + for s in kandidaten: if geplaatst >= aantal: break if s.id in groep_bezet: continue if pas == 1 and s.dag in vak_dagen: continue + if max_per_dag is not None and per_dag.get(s.dag, 0) >= max_per_dag: + continue if beg and beg in beg_bezet.get(s.id, set()): continue if lok and lok in lok_bezet.get(s.id, set()): @@ -78,6 +110,7 @@ def voorstel(request): "tijdslot_label": slot_info[s.id]["label"], "dag": slot_info[s.id]["dag"], "dag_label": slot_info[s.id]["dag_label"], + "dagdeel": "ochtend" if _is_ochtend(s) else "middag", "activiteit": act_id, "activiteit_naam": act_naam.get(act_id, "?"), "groep": groep.id, @@ -88,6 +121,7 @@ def voorstel(request): }) groep_bezet.add(s.id) vak_dagen.add(s.dag) + per_dag[s.dag] = per_dag.get(s.dag, 0) + 1 if beg: beg_bezet.setdefault(s.id, set()).add(beg) if lok: diff --git a/frontend/src/pages.jsx b/frontend/src/pages.jsx index 65a0211..ac77f78 100644 --- a/frontend/src/pages.jsx +++ b/frontend/src/pages.jsx @@ -3226,7 +3226,9 @@ export function AutoRoosterPage() { const [activiteiten, setActiviteiten] = useState([]); const [medewerkers, setMedewerkers] = useState([]); const [lokalen, setLokalen] = useState([]); - const [wensen, setWensen] = useState([{ activiteit: "", aantal: 1, begeleider: "", lokaal: "" }]); + const [wensen, setWensen] = useState([{ activiteit: "", aantal: 1, begeleider: "", lokaal: "", dagdeel: "" }]); + const [vrijeDagen, setVrijeDagen] = useState([]); + const [maxPerDag, setMaxPerDag] = useState(""); const [voorstel, setVoorstel] = useState(null); const [niet, setNiet] = useState([]); const [error, setError] = useState(null); @@ -3242,7 +3244,7 @@ export function AutoRoosterPage() { }, []); const setWens = (i, veld, val) => setWensen((ws) => ws.map((w, idx) => idx === i ? { ...w, [veld]: val } : w)); - const addWens = () => setWensen((ws) => [...ws, { activiteit: "", aantal: 1, begeleider: "", lokaal: "" }]); + const addWens = () => setWensen((ws) => [...ws, { activiteit: "", aantal: 1, begeleider: "", lokaal: "", dagdeel: "" }]); const delWens = (i) => setWensen((ws) => ws.filter((_, idx) => idx !== i)); async function genereer() { @@ -3254,7 +3256,9 @@ export function AutoRoosterPage() { try { const r = await createResource("autorooster/voorstel", { schooljaar: Number(schooljaar), groep: Number(groep), - wensen: geldig.map((w) => ({ activiteit: Number(w.activiteit), aantal: Number(w.aantal), begeleider: w.begeleider || null, lokaal: w.lokaal || null })), + wensen: geldig.map((w) => ({ activiteit: Number(w.activiteit), aantal: Number(w.aantal), begeleider: w.begeleider || null, lokaal: w.lokaal || null, dagdeel: w.dagdeel || "" })), + vrije_dagen: vrijeDagen, + max_per_dag: maxPerDag === "" ? null : Number(maxPerDag), }); setVoorstel(r.voorstel || []); setNiet(r.niet_geplaatst || []); } catch (e) { setError(e.message); } @@ -3300,9 +3304,20 @@ export function AutoRoosterPage() { +

Voorkeuren

+
+ Dagen vrijhouden: + {WEEKDAGEN.map((d) => ( + + ))} + +
+

Wensen

- + {wensen.map((w, i) => ( @@ -3313,6 +3328,13 @@ export function AutoRoosterPage() { +
VakAantal/weekBegeleider (optioneel)Lokaal (optioneel)
VakAantal/weekVoorkeur dagdeelBegeleider (optioneel)Lokaal (optioneel)
setWens(i, "aantal", e.target.value)} style={{ width: 70 }} /> + +