From 2b6a9ac47a5c899565a90b786fb45f8f2bd72eba Mon Sep 17 00:00:00 2001 From: bes-r <27369668+bes-r@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:48:04 +0200 Subject: [PATCH] module: automatisch rooster genereren --- backend/config/settings.py | 1 + backend/config/urls.py | 1 + backend/modules/autorooster/__init__.py | 0 backend/modules/autorooster/apps.py | 30 +++++ backend/modules/autorooster/urls.py | 7 + backend/modules/autorooster/views.py | 102 +++++++++++++++ frontend/src/App.jsx | 3 +- frontend/src/pages.jsx | 162 ++++++++++++++++++++++++ 8 files changed, 305 insertions(+), 1 deletion(-) create mode 100644 backend/modules/autorooster/__init__.py create mode 100644 backend/modules/autorooster/apps.py create mode 100644 backend/modules/autorooster/urls.py create mode 100644 backend/modules/autorooster/views.py diff --git a/backend/config/settings.py b/backend/config/settings.py index b1ae71c..810975e 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -77,6 +77,7 @@ MODULE_APPS = [ "modules.uren", # Urenoverzicht "modules.blokwizard", # Vak-wizard "modules.personenwizard", # Personen-wizard & import + "modules.autorooster", # Automatisch rooster ] INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + CORE_APPS + MODULE_APPS diff --git a/backend/config/urls.py b/backend/config/urls.py index 540ddff..8afb46d 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -15,6 +15,7 @@ urlpatterns = [ path("api/", include("modules.stage.urls")), path("api/", include("modules.uren.urls")), path("api/", include("modules.personenwizard.urls")), + path("api/", include("modules.autorooster.urls")), path("api/", include("modules.accounts.urls")), ] diff --git a/backend/modules/autorooster/__init__.py b/backend/modules/autorooster/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/autorooster/apps.py b/backend/modules/autorooster/apps.py new file mode 100644 index 0000000..59716b8 --- /dev/null +++ b/backend/modules/autorooster/apps.py @@ -0,0 +1,30 @@ +from django.apps import AppConfig + + +class AutoroosterConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "modules.autorooster" + label = "module_autorooster" + verbose_name = "Module: Automatisch rooster" + + def ready(self): + from plugins.registry import MenuItem, ModuleSpec, registry + + registry.register( + ModuleSpec( + key="autorooster", + name="Automatisch rooster", + description=( + "Genereer op basis van wensen (welk vak, hoeveel keer per week, " + "evt. vaste begeleider/lokaal) een conflictvrij voorstel per groep. " + "Je past het daarna gewoon aan in de Roostermaker." + ), + version="0.1.0", + category="Hulpmiddelen", + core=False, + default_enabled=False, + menu_items=( + MenuItem("Auto-rooster", "/auto-rooster", icon="puzzle", order=44), + ), + ) + ) diff --git a/backend/modules/autorooster/urls.py b/backend/modules/autorooster/urls.py new file mode 100644 index 0000000..4561f22 --- /dev/null +++ b/backend/modules/autorooster/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("autorooster/voorstel/", views.voorstel, name="autorooster-voorstel"), +] diff --git a/backend/modules/autorooster/views.py b/backend/modules/autorooster/views.py new file mode 100644 index 0000000..896b35e --- /dev/null +++ b/backend/modules/autorooster/views.py @@ -0,0 +1,102 @@ +"""Automatisch rooster: stelt een conflictvrij voorstel voor (greedy).""" +from django.db.models import Q +from rest_framework.decorators import api_view +from rest_framework.response import Response + +from core.models import Activiteit, Groep, Lokaal, Persoon, Roosterblok, Schooljaar, Tijdslot + + +@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) + if not sjid or not groep_id: + return Response({"detail": "Geef een schooljaar en een groep op."}, status=400) + try: + sj = Schooljaar.objects.get(pk=sjid) + groep = Groep.objects.get(pk=groep_id) + 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) + .filter(Q(groep=groep) | Q(groep__isnull=True)) + .order_by("dag", "volgorde", "start_tijd") + ) + if not sloten: + return Response({"voorstel": [], "niet_geplaatst": [], "detail": "Geen tijdsloten gevonden voor deze groep."}) + + # Bezetting bijhouden (eventueel inclusief bestaande blokken). + groep_bezet = set() + beg_bezet = {} + lok_bezet = {} + 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) + 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) + + 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 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 + if not act_id or aantal <= 0: + continue + beg = int(beg) if beg else None + lok = int(lok) if lok else None + geplaatst = 0 + vak_dagen = set() + # Twee passes: eerst spreiden (max 1 per dag), daarna opvullen. + for pas in (1, 2): + for s in sloten: + if geplaatst >= aantal: + break + if s.id in groep_bezet: + continue + if pas == 1 and s.dag in vak_dagen: + 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"], + "activiteit": act_id, + "activiteit_naam": act_naam.get(act_id, "?"), + "groep": groep.id, + "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.add(s.id) + vak_dagen.add(s.dag) + 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, "?"), "tekort": aantal - geplaatst}) + + voorstel_lijst.sort(key=lambda x: (x["dag"], x["tijdslot_label"])) + return Response({"voorstel": voorstel_lijst, "niet_geplaatst": niet}) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 9fcba9c..3e4207e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -7,7 +7,7 @@ import { RoosterWeergavePage, RoostermakerPage, AfwezigheidPage, GroepsindelingPage, StagePage, GebruikersPage, UrenPage, LeerlingRoosterPage, OuderRoosterPage, - VakWizardPage, PersonenWizardPage, + VakWizardPage, PersonenWizardPage, AutoRoosterPage, } from "./pages.jsx"; const ICONS = { @@ -41,6 +41,7 @@ const PAGES = { "/ouderrooster": OuderRoosterPage, "/vak-wizard": VakWizardPage, "/personen-wizard": PersonenWizardPage, + "/auto-rooster": AutoRoosterPage, }; export default function App() { diff --git a/frontend/src/pages.jsx b/frontend/src/pages.jsx index 37005cb..3169031 100644 --- a/frontend/src/pages.jsx +++ b/frontend/src/pages.jsx @@ -3168,3 +3168,165 @@ export function PersonenWizardPage() { ); } + +/* ------------------------------- Auto-rooster ----------------------------- */ +export function AutoRoosterPage() { + const [schooljaren, setSchooljaren] = useState([]); + const [schooljaar, setSchooljaar] = useState(""); + const [groepen, setGroepen] = useState([]); + const [groep, setGroep] = useState(""); + const [activiteiten, setActiviteiten] = useState([]); + const [medewerkers, setMedewerkers] = useState([]); + const [lokalen, setLokalen] = useState([]); + const [wensen, setWensen] = useState([{ activiteit: "", aantal: 1, begeleider: "", lokaal: "" }]); + const [voorstel, setVoorstel] = useState(null); + const [niet, setNiet] = useState([]); + const [error, setError] = useState(null); + const [bezig, setBezig] = useState(false); + const [resultaat, setResultaat] = useState(null); + + useEffect(() => { + listResource("schooljaren").then((j) => { setSchooljaren(j); if (j.length) setSchooljaar(String(j[0].id)); }).catch((e) => setError(e.message)); + listResource("groepen").then(setGroepen).catch(() => {}); + listResource("activiteiten").then(setActiviteiten).catch(() => {}); + listResource("personen").then((ps) => setMedewerkers(ps.filter((p) => p.rol !== "leerling"))).catch(() => {}); + listResource("lokalen").then(setLokalen).catch(() => setLokalen([])); + }, []); + + 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 delWens = (i) => setWensen((ws) => ws.filter((_, idx) => idx !== i)); + + async function genereer() { + setError(null); setResultaat(null); setVoorstel(null); setNiet([]); + if (!groep) { setError("Kies eerst een groep."); return; } + const geldig = wensen.filter((w) => w.activiteit && Number(w.aantal) > 0); + if (geldig.length === 0) { setError("Voeg minstens één wens toe (vak + aantal)."); return; } + setBezig(true); + 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 })), + }); + setVoorstel(r.voorstel || []); setNiet(r.niet_geplaatst || []); + } catch (e) { setError(e.message); } + finally { setBezig(false); } + } + async function toepassen() { + if (!voorstel || voorstel.length === 0) return; + setBezig(true); setError(null); + let ok = 0; const mislukt = []; + for (const v of voorstel) { + try { + await createResource("roosterblokken", { + schooljaar: Number(schooljaar), tijdslot: v.tijdslot, activiteit: v.activiteit, + groep: v.groep, subgroep: null, leerling: null, + begeleiders: v.begeleider ? [v.begeleider] : [], lokaal: v.lokaal || null, + }); + ok += 1; + } catch (e) { mislukt.push(`${v.activiteit_naam} (${v.tijdslot_label}): ${e.message}`); } + } + setResultaat({ ok, mislukt }); + setVoorstel(null); + setBezig(false); + } + + const perDag = {}; + (voorstel || []).forEach((v) => { (perDag[v.dag] = perDag[v.dag] || []).push(v); }); + + return ( +
+

Automatisch rooster

+

Geef per groep je wensen op; het systeem stelt een conflictvrij voorstel voor (geen dubbele begeleider of lokaal, gespreid over de week). Daarna pas je het gewoon aan in de Roostermaker.

+ + +
+ {schooljaren.length > 1 && ( + + )} + +
+ +

Wensen

+ + + + {wensen.map((w, i) => ( + + + + + + + + ))} + +
VakAantal/weekBegeleider (optioneel)Lokaal (optioneel)
+ + setWens(i, "aantal", e.target.value)} style={{ width: 70 }} /> + + + + {wensen.length > 1 && }
+
+ + +
+ + {resultaat && ( +
+ {resultaat.ok} blok(ken) toegevoegd aan het rooster.{resultaat.mislukt.length > 0 && <> {resultaat.mislukt.length} mislukt: {resultaat.mislukt.join("; ")}} + {" "}Stel het verder af in de Roostermaker. +
+ )} + + {voorstel && ( +
+

Voorstel ({voorstel.length} blokken)

+ {niet.length > 0 && ( +
Niet alles paste: {niet.map((n) => `${n.activiteit_naam} (${n.tekort} te kort)`).join(", ")}. Voeg tijdsloten toe of verlaag het aantal.
+ )} + {voorstel.length === 0 ?

Geen blokken voorgesteld.

: ( +
+ {WEEKDAGEN.map((d) => ( +
+
{d.label}
+ {(perDag[d.value] || []).length === 0 &&
} + {(perDag[d.value] || []).map((v, i) => { + const kleur = (activiteiten.find((a) => a.id === v.activiteit) || {}).kleur || "#2563eb"; + return ( +
+
{v.tijdslot_label.split(" ")[1]}
+
{v.activiteit_naam}
+ {v.begeleider_naam &&
{v.begeleider_naam}
} + {v.lokaal_naam &&
{v.lokaal_naam}
} +
+ ); + })} +
+ ))} +
+ )} +
+ + +
+
+ )} +
+ ); +}