roosterwijs/backend/modules/autorooster/views.py
bes-r c845588e5e
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Add roles modules audit logging and CI
2026-07-04 23:01:06 +02:00

155 lines
7.1 KiB
Python

"""Automatisch rooster: stelt een conflictvrij voorstel voor (greedy).
Werkt voor meerdere klassen tegelijk. Een wens (vak) kan voor meerdere klassen
gelden; begeleiders en lokalen worden over álle klassen heen niet dubbel geboekt.
Voorkeuren: vak liever ochtend/middag, dagen vrijhouden, max. blokken per dag.
"""
from rest_framework.decorators import api_view, permission_classes
from rest_framework.response import Response
from core.audit import audit_log
from core.models import Activiteit, Groep, Lokaal, Persoon, Roosterblok, Schooljaar, Tijdslot
from core.permissions import CoreDataPermission, module_permission_class
AutoroosterModuleEnabled = module_permission_class("autorooster")
def _is_ochtend(slot):
return slot.start_tijd.hour < 12
@api_view(["POST"])
@permission_classes([CoreDataPermission, AutoroosterModuleEnabled])
def voorstel(request):
sjid = request.data.get("schooljaar")
# Meerdere klassen; val terug op het oude enkele 'groep'-veld.
groepen_ids = request.data.get("groepen")
if not groepen_ids and request.data.get("groep"):
groepen_ids = [request.data.get("groep")]
try:
groepen_ids = [int(g) for g in (groepen_ids or [])]
except (TypeError, ValueError):
groepen_ids = []
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 groepen_ids:
return Response({"detail": "Geef een schooljaar en minstens één klas op."}, status=400)
try:
sj = Schooljaar.objects.get(pk=sjid)
except Schooljaar.DoesNotExist:
return Response({"detail": "Schooljaar niet gevonden."}, status=404)
groepen = {g.id: g for g in Groep.objects.filter(id__in=groepen_ids)}
ordered_gids = [gid for gid in groepen_ids if gid in groepen]
if not ordered_gids:
return Response({"detail": "Geen geldige klassen."}, status=404)
alle_sloten = list(Tijdslot.objects.filter(actief=True).order_by("dag", "volgorde", "start_tijd"))
slot_dag = {s.id: s.dag for s in alle_sloten}
def sloten_voor(gid):
return [s for s in alle_sloten if s.groep_id in (None, gid) and s.dag not in vrije_dagen]
# Gedeelde bezetting over alle klassen heen.
groep_bezet = {gid: set() for gid in ordered_gids}
per_dag = {} # (gid, weekdag) -> aantal
beg_bezet = {} # tijdslot -> set(begeleider-id) (globaal)
lok_bezet = {} # tijdslot -> set(lokaal-id) (globaal)
if respecteer:
for b in Roosterblok.objects.filter(schooljaar=sj, sjabloon=False).prefetch_related("begeleiders"):
for p in b.begeleiders.all():
beg_bezet.setdefault(b.tijdslot_id, set()).add(p.id)
if b.lokaal_id:
lok_bezet.setdefault(b.tijdslot_id, set()).add(b.lokaal_id)
if b.groep_id in groep_bezet:
groep_bezet[b.groep_id].add(b.tijdslot_id)
wd = slot_dag.get(b.tijdslot_id)
if wd is not None:
per_dag[(b.groep_id, wd)] = per_dag.get((b.groep_id, wd), 0) + 1
act_naam = {a.id: a.naam for a in Activiteit.objects.all()}
pers_naam = {p.id: p.volledige_naam for p in Persoon.objects.all()}
lok_naam = {l.id: l.naam for l in Lokaal.objects.all()}
slot_info = {s.id: {"label": f"{s.get_dag_display()} {s.start_tijd:%H:%M} {s.naam}", "dag": s.dag, "dag_label": s.get_dag_display()} for s in alle_sloten}
voorstel_lijst = []
niet = []
for w in wensen:
act_id = w.get("activiteit")
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
# Doelklassen van deze wens (leeg = alle gekozen klassen).
try:
doel = [int(g) for g in (w.get("groepen") or [])]
except (TypeError, ValueError):
doel = []
doel = [gid for gid in doel if gid in groepen] or list(ordered_gids)
for gid in doel:
sloten = sloten_voor(gid)
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()
for pas in (1, 2):
for s in kandidaten:
if geplaatst >= aantal:
break
if s.id in groep_bezet[gid]:
continue
if pas == 1 and s.dag in vak_dagen:
continue
if max_per_dag is not None and per_dag.get((gid, 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()):
continue
voorstel_lijst.append({
"tijdslot": s.id,
"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": gid,
"groep_naam": groepen[gid].naam,
"begeleider": beg,
"begeleider_naam": pers_naam.get(beg) if beg else None,
"lokaal": lok,
"lokaal_naam": lok_naam.get(lok) if lok else None,
})
groep_bezet[gid].add(s.id)
vak_dagen.add(s.dag)
per_dag[(gid, s.dag)] = per_dag.get((gid, s.dag), 0) + 1
if beg:
beg_bezet.setdefault(s.id, set()).add(beg)
if lok:
lok_bezet.setdefault(s.id, set()).add(lok)
geplaatst += 1
if geplaatst >= aantal:
break
if geplaatst < aantal:
niet.append({"activiteit_naam": act_naam.get(act_id, "?"), "groep_naam": groepen[gid].naam, "tekort": aantal - geplaatst})
voorstel_lijst.sort(key=lambda x: (x["groep_naam"], x["dag"], x["tijdslot_label"]))
audit_log(request, "generate", "AutoroosterVoorstel", {"schooljaar": sj.pk, "groepen": ordered_gids})
return Response({"voorstel": voorstel_lijst, "niet_geplaatst": niet})