module: automatisch rooster genereren

This commit is contained in:
bes-r 2026-06-17 14:48:04 +02:00
parent 31d037d8af
commit 2b6a9ac47a
8 changed files with 305 additions and 1 deletions

View file

@ -77,6 +77,7 @@ MODULE_APPS = [
"modules.uren", # Urenoverzicht "modules.uren", # Urenoverzicht
"modules.blokwizard", # Vak-wizard "modules.blokwizard", # Vak-wizard
"modules.personenwizard", # Personen-wizard & import "modules.personenwizard", # Personen-wizard & import
"modules.autorooster", # Automatisch rooster
] ]
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + CORE_APPS + MODULE_APPS INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + CORE_APPS + MODULE_APPS

View file

@ -15,6 +15,7 @@ urlpatterns = [
path("api/", include("modules.stage.urls")), path("api/", include("modules.stage.urls")),
path("api/", include("modules.uren.urls")), path("api/", include("modules.uren.urls")),
path("api/", include("modules.personenwizard.urls")), path("api/", include("modules.personenwizard.urls")),
path("api/", include("modules.autorooster.urls")),
path("api/", include("modules.accounts.urls")), path("api/", include("modules.accounts.urls")),
] ]

View file

View file

@ -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),
),
)
)

View file

@ -0,0 +1,7 @@
from django.urls import path
from . import views
urlpatterns = [
path("autorooster/voorstel/", views.voorstel, name="autorooster-voorstel"),
]

View file

@ -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})

View file

@ -7,7 +7,7 @@ import {
RoosterWeergavePage, RoostermakerPage, RoosterWeergavePage, RoostermakerPage,
AfwezigheidPage, GroepsindelingPage, StagePage, GebruikersPage, UrenPage, AfwezigheidPage, GroepsindelingPage, StagePage, GebruikersPage, UrenPage,
LeerlingRoosterPage, OuderRoosterPage, LeerlingRoosterPage, OuderRoosterPage,
VakWizardPage, PersonenWizardPage, VakWizardPage, PersonenWizardPage, AutoRoosterPage,
} from "./pages.jsx"; } from "./pages.jsx";
const ICONS = { const ICONS = {
@ -41,6 +41,7 @@ const PAGES = {
"/ouderrooster": OuderRoosterPage, "/ouderrooster": OuderRoosterPage,
"/vak-wizard": VakWizardPage, "/vak-wizard": VakWizardPage,
"/personen-wizard": PersonenWizardPage, "/personen-wizard": PersonenWizardPage,
"/auto-rooster": AutoRoosterPage,
}; };
export default function App() { export default function App() {

View file

@ -3168,3 +3168,165 @@ export function PersonenWizardPage() {
</section> </section>
); );
} }
/* ------------------------------- 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 (
<section className="autorooster">
<h1>Automatisch rooster</h1>
<p className="muted">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.</p>
<Foutmelding msg={error} />
<div className="form-row">
{schooljaren.length > 1 && (
<select value={schooljaar} onChange={(e) => setSchooljaar(e.target.value)}>
{schooljaren.map((j) => <option key={j.id} value={j.id}>{j.naam}</option>)}
</select>
)}
<select value={groep} onChange={(e) => setGroep(e.target.value)}>
<option value=""> kies groep </option>
{groepen.map((g) => <option key={g.id} value={g.id}>{g.naam}</option>)}
</select>
</div>
<h2>Wensen</h2>
<table className="grid">
<thead><tr><th>Vak</th><th>Aantal/week</th><th>Begeleider (optioneel)</th><th>Lokaal (optioneel)</th><th></th></tr></thead>
<tbody>
{wensen.map((w, i) => (
<tr key={i}>
<td>
<select value={w.activiteit} onChange={(e) => setWens(i, "activiteit", e.target.value)}>
<option value=""> vak </option>
{activiteiten.map((a) => <option key={a.id} value={a.id}>{a.naam}</option>)}
</select>
</td>
<td><input type="number" min="1" max="20" value={w.aantal} onChange={(e) => setWens(i, "aantal", e.target.value)} style={{ width: 70 }} /></td>
<td>
<select value={w.begeleider} onChange={(e) => setWens(i, "begeleider", e.target.value)}>
<option value=""></option>
{medewerkers.map((m) => <option key={m.id} value={m.id}>{m.volledige_naam}</option>)}
</select>
</td>
<td>
<select value={w.lokaal} onChange={(e) => setWens(i, "lokaal", e.target.value)}>
<option value=""></option>
{lokalen.map((l) => <option key={l.id} value={l.id}>{l.naam}</option>)}
</select>
</td>
<td className="row-actions">{wensen.length > 1 && <button type="button" className="link danger" onClick={() => delWens(i)}>×</button>}</td>
</tr>
))}
</tbody>
</table>
<div className="form-row">
<button type="button" className="btn" onClick={addWens}>+ wens</button>
<button type="button" className="btn primary" disabled={bezig} onClick={genereer}>{bezig ? "Bezig…" : "Genereer voorstel"}</button>
</div>
{resultaat && (
<div className={resultaat.mislukt.length ? "banner error" : "banner ok"}>
{resultaat.ok} blok(ken) toegevoegd aan het rooster.{resultaat.mislukt.length > 0 && <> {resultaat.mislukt.length} mislukt: <span className="small">{resultaat.mislukt.join("; ")}</span></>}
{" "}Stel het verder af in de Roostermaker.
</div>
)}
{voorstel && (
<div className="voorstel">
<h2>Voorstel ({voorstel.length} blokken)</h2>
{niet.length > 0 && (
<div className="banner error">Niet alles paste: {niet.map((n) => `${n.activiteit_naam} (${n.tekort} te kort)`).join(", ")}. Voeg tijdsloten toe of verlaag het aantal.</div>
)}
{voorstel.length === 0 ? <p className="muted">Geen blokken voorgesteld.</p> : (
<div className="ez-week">
{WEEKDAGEN.map((d) => (
<div className="ez-dag-kolom" key={d.value}>
<div className="ez-dag-kop">{d.label}</div>
{(perDag[d.value] || []).length === 0 && <div className="muted small"></div>}
{(perDag[d.value] || []).map((v, i) => {
const kleur = (activiteiten.find((a) => a.id === v.activiteit) || {}).kleur || "#2563eb";
return (
<div className="ez-blok" key={i} style={{ borderColor: kleur, background: kleur + "18" }}>
<div className="ez-tijd">{v.tijdslot_label.split(" ")[1]}</div>
<div className="ez-vak" style={{ color: kleur }}>{v.activiteit_naam}</div>
{v.begeleider_naam && <div className="muted small">{v.begeleider_naam}</div>}
{v.lokaal_naam && <div className="muted small">{v.lokaal_naam}</div>}
</div>
);
})}
</div>
))}
</div>
)}
<div className="form-row">
<button type="button" className="btn primary" disabled={bezig || voorstel.length === 0} onClick={toepassen}>{bezig ? "Bezig…" : "Voorstel toepassen op rooster"}</button>
<button type="button" className="btn" onClick={() => setVoorstel(null)}>Verwerpen</button>
</div>
</div>
)}
</section>
);
}