module: automatisch rooster genereren
This commit is contained in:
parent
31d037d8af
commit
2b6a9ac47a
8 changed files with 305 additions and 1 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
0
backend/modules/autorooster/__init__.py
Normal file
0
backend/modules/autorooster/__init__.py
Normal file
30
backend/modules/autorooster/apps.py
Normal file
30
backend/modules/autorooster/apps.py
Normal 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),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
7
backend/modules/autorooster/urls.py
Normal file
7
backend/modules/autorooster/urls.py
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("autorooster/voorstel/", views.voorstel, name="autorooster-voorstel"),
|
||||||
|
]
|
||||||
102
backend/modules/autorooster/views.py
Normal file
102
backend/modules/autorooster/views.py
Normal 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})
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue