auto-rooster met voorkeuren (dagdeel, vrije dagen, max per dag)
This commit is contained in:
parent
f40918c077
commit
54a9dd5dc2
3 changed files with 72 additions and 15 deletions
|
|
@ -132,8 +132,9 @@ volgt.
|
||||||
|
|
||||||
## Later
|
## Later
|
||||||
|
|
||||||
- [ ] **Auto-rooster slimmer**: voorkeuren toevoegen (vak liever in de ochtend/
|
- [x] **Auto-rooster slimmer**: voorkeuren — vak liever ochtend/middag (zachte
|
||||||
middag, bepaalde dagen vrijhouden, max. blokken per dag per groep).
|
voorkeur met fallback), bepaalde dagen vrijhouden, en max. blokken per dag
|
||||||
|
per groep.
|
||||||
- [ ] **Algemene visuele opfris** (UX/uiterlijk).
|
- [ ] **Algemene visuele opfris** (UX/uiterlijk).
|
||||||
|
|
||||||
- [ ] **Module: automatisch rooster genereren.** De gebruiker ge
|
- [ ] **Module: automatisch rooster genereren.** De gebruiker ge
|
||||||
|
|
@ -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 django.db.models import Q
|
||||||
from rest_framework.decorators import api_view
|
from rest_framework.decorators import api_view
|
||||||
from rest_framework.response import Response
|
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
|
from core.models import Activiteit, Groep, Lokaal, Persoon, Roosterblok, Schooljaar, Tijdslot
|
||||||
|
|
||||||
|
|
||||||
|
def _is_ochtend(slot):
|
||||||
|
return slot.start_tijd.hour < 12
|
||||||
|
|
||||||
|
|
||||||
@api_view(["POST"])
|
@api_view(["POST"])
|
||||||
def voorstel(request):
|
def voorstel(request):
|
||||||
sjid = request.data.get("schooljaar")
|
sjid = request.data.get("schooljaar")
|
||||||
groep_id = request.data.get("groep")
|
groep_id = request.data.get("groep")
|
||||||
wensen = request.data.get("wensen") or []
|
wensen = request.data.get("wensen") or []
|
||||||
respecteer = request.data.get("respecteer_bestaand", True)
|
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:
|
if not sjid or not groep_id:
|
||||||
return Response({"detail": "Geef een schooljaar en een groep op."}, status=400)
|
return Response({"detail": "Geef een schooljaar en een groep op."}, status=400)
|
||||||
try:
|
try:
|
||||||
|
|
@ -20,23 +34,30 @@ def voorstel(request):
|
||||||
except (Schooljaar.DoesNotExist, Groep.DoesNotExist):
|
except (Schooljaar.DoesNotExist, Groep.DoesNotExist):
|
||||||
return Response({"detail": "Schooljaar of groep niet gevonden."}, status=404)
|
return Response({"detail": "Schooljaar of groep niet gevonden."}, status=404)
|
||||||
|
|
||||||
# Kandidaat-tijdsloten: groep-specifiek of algemeen (geen groep).
|
# Kandidaat-tijdsloten: groep-specifiek of algemeen, vrije dagen uitgesloten.
|
||||||
sloten = list(
|
sloten = [
|
||||||
Tijdslot.objects.filter(actief=True)
|
s for s in Tijdslot.objects.filter(actief=True)
|
||||||
.filter(Q(groep=groep) | Q(groep__isnull=True))
|
.filter(Q(groep=groep) | Q(groep__isnull=True))
|
||||||
.order_by("dag", "volgorde", "start_tijd")
|
.order_by("dag", "volgorde", "start_tijd")
|
||||||
)
|
if s.dag not in vrije_dagen
|
||||||
|
]
|
||||||
if not sloten:
|
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).
|
# Bezetting bijhouden (eventueel inclusief bestaande blokken).
|
||||||
groep_bezet = set()
|
groep_bezet = set()
|
||||||
beg_bezet = {}
|
beg_bezet = {}
|
||||||
lok_bezet = {}
|
lok_bezet = {}
|
||||||
|
per_dag = {} # weekdag -> aantal blokken voor deze groep
|
||||||
if respecteer:
|
if respecteer:
|
||||||
for b in Roosterblok.objects.filter(schooljaar=sj).prefetch_related("begeleiders"):
|
for b in Roosterblok.objects.filter(schooljaar=sj).prefetch_related("begeleiders"):
|
||||||
if b.groep_id == groep.id:
|
if b.groep_id == groep.id:
|
||||||
groep_bezet.add(b.tijdslot_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():
|
for p in b.begeleiders.all():
|
||||||
beg_bezet.setdefault(b.tijdslot_id, set()).add(p.id)
|
beg_bezet.setdefault(b.tijdslot_id, set()).add(p.id)
|
||||||
if b.lokaal_id:
|
if b.lokaal_id:
|
||||||
|
|
@ -54,21 +75,32 @@ def voorstel(request):
|
||||||
aantal = int(w.get("aantal") or 0)
|
aantal = int(w.get("aantal") or 0)
|
||||||
beg = w.get("begeleider") or None
|
beg = w.get("begeleider") or None
|
||||||
lok = w.get("lokaal") or None
|
lok = w.get("lokaal") or None
|
||||||
|
dagdeel = (w.get("dagdeel") or "").strip()
|
||||||
if not act_id or aantal <= 0:
|
if not act_id or aantal <= 0:
|
||||||
continue
|
continue
|
||||||
beg = int(beg) if beg else None
|
beg = int(beg) if beg else None
|
||||||
lok = int(lok) if lok 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
|
geplaatst = 0
|
||||||
vak_dagen = set()
|
vak_dagen = set()
|
||||||
# Twee passes: eerst spreiden (max 1 per dag), daarna opvullen.
|
for pas in (1, 2): # pas 1 = spreiden (max 1 per dag), pas 2 = opvullen
|
||||||
for pas in (1, 2):
|
for s in kandidaten:
|
||||||
for s in sloten:
|
|
||||||
if geplaatst >= aantal:
|
if geplaatst >= aantal:
|
||||||
break
|
break
|
||||||
if s.id in groep_bezet:
|
if s.id in groep_bezet:
|
||||||
continue
|
continue
|
||||||
if pas == 1 and s.dag in vak_dagen:
|
if pas == 1 and s.dag in vak_dagen:
|
||||||
continue
|
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()):
|
if beg and beg in beg_bezet.get(s.id, set()):
|
||||||
continue
|
continue
|
||||||
if lok and lok in lok_bezet.get(s.id, set()):
|
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"],
|
"tijdslot_label": slot_info[s.id]["label"],
|
||||||
"dag": slot_info[s.id]["dag"],
|
"dag": slot_info[s.id]["dag"],
|
||||||
"dag_label": slot_info[s.id]["dag_label"],
|
"dag_label": slot_info[s.id]["dag_label"],
|
||||||
|
"dagdeel": "ochtend" if _is_ochtend(s) else "middag",
|
||||||
"activiteit": act_id,
|
"activiteit": act_id,
|
||||||
"activiteit_naam": act_naam.get(act_id, "?"),
|
"activiteit_naam": act_naam.get(act_id, "?"),
|
||||||
"groep": groep.id,
|
"groep": groep.id,
|
||||||
|
|
@ -88,6 +121,7 @@ def voorstel(request):
|
||||||
})
|
})
|
||||||
groep_bezet.add(s.id)
|
groep_bezet.add(s.id)
|
||||||
vak_dagen.add(s.dag)
|
vak_dagen.add(s.dag)
|
||||||
|
per_dag[s.dag] = per_dag.get(s.dag, 0) + 1
|
||||||
if beg:
|
if beg:
|
||||||
beg_bezet.setdefault(s.id, set()).add(beg)
|
beg_bezet.setdefault(s.id, set()).add(beg)
|
||||||
if lok:
|
if lok:
|
||||||
|
|
|
||||||
|
|
@ -3226,7 +3226,9 @@ export function AutoRoosterPage() {
|
||||||
const [activiteiten, setActiviteiten] = useState([]);
|
const [activiteiten, setActiviteiten] = useState([]);
|
||||||
const [medewerkers, setMedewerkers] = useState([]);
|
const [medewerkers, setMedewerkers] = useState([]);
|
||||||
const [lokalen, setLokalen] = 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 [voorstel, setVoorstel] = useState(null);
|
||||||
const [niet, setNiet] = useState([]);
|
const [niet, setNiet] = useState([]);
|
||||||
const [error, setError] = useState(null);
|
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 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));
|
const delWens = (i) => setWensen((ws) => ws.filter((_, idx) => idx !== i));
|
||||||
|
|
||||||
async function genereer() {
|
async function genereer() {
|
||||||
|
|
@ -3254,7 +3256,9 @@ export function AutoRoosterPage() {
|
||||||
try {
|
try {
|
||||||
const r = await createResource("autorooster/voorstel", {
|
const r = await createResource("autorooster/voorstel", {
|
||||||
schooljaar: Number(schooljaar), groep: Number(groep),
|
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 || []);
|
setVoorstel(r.voorstel || []); setNiet(r.niet_geplaatst || []);
|
||||||
} catch (e) { setError(e.message); }
|
} catch (e) { setError(e.message); }
|
||||||
|
|
@ -3300,9 +3304,20 @@ export function AutoRoosterPage() {
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h2>Voorkeuren</h2>
|
||||||
|
<div className="form-row" style={{ alignItems: "center", flexWrap: "wrap" }}>
|
||||||
|
<span className="muted small">Dagen vrijhouden:</span>
|
||||||
|
{WEEKDAGEN.map((d) => (
|
||||||
|
<label key={d.value} className="veld">
|
||||||
|
<input type="checkbox" checked={vrijeDagen.includes(d.value)} onChange={(e) => setVrijeDagen((v) => e.target.checked ? [...v, d.value] : v.filter((x) => x !== d.value))} /> {d.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
<label className="veld-kol"><span className="veld-kop">Max. blokken per dag</span><input type="number" min="1" max="12" placeholder="geen limiet" value={maxPerDag} onChange={(e) => setMaxPerDag(e.target.value)} style={{ width: 110 }} /></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2>Wensen</h2>
|
<h2>Wensen</h2>
|
||||||
<table className="grid">
|
<table className="grid">
|
||||||
<thead><tr><th>Vak</th><th>Aantal/week</th><th>Begeleider (optioneel)</th><th>Lokaal (optioneel)</th><th></th></tr></thead>
|
<thead><tr><th>Vak</th><th>Aantal/week</th><th>Voorkeur dagdeel</th><th>Begeleider (optioneel)</th><th>Lokaal (optioneel)</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{wensen.map((w, i) => (
|
{wensen.map((w, i) => (
|
||||||
<tr key={i}>
|
<tr key={i}>
|
||||||
|
|
@ -3313,6 +3328,13 @@ export function AutoRoosterPage() {
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</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><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.dagdeel} onChange={(e) => setWens(i, "dagdeel", e.target.value)}>
|
||||||
|
<option value="">geen</option>
|
||||||
|
<option value="ochtend">Ochtend</option>
|
||||||
|
<option value="middag">Middag</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<select value={w.begeleider} onChange={(e) => setWens(i, "begeleider", e.target.value)}>
|
<select value={w.begeleider} onChange={(e) => setWens(i, "begeleider", e.target.value)}>
|
||||||
<option value="">—</option>
|
<option value="">—</option>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue