diff --git a/ROADMAP.md b/ROADMAP.md index 37ca9f7..d0a7f2a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -50,15 +50,16 @@ volgt. ## Koppelingen & inzetregels -- [ ] **Personeel koppelen aan leerplein en subgroep** (nu alleen stamgroep - mogelijk in het personeelsformulier). -- [ ] **Theorievakken automatisch beschikbaar voor leerkrachten** (een leerkracht - mag altijd op een theorievak ingezet worden). -- [ ] **Inzetregel begeleider ↔ vak:** een leerkracht of praktijkondersteuner kan - alleen aan een vak/blok gekoppeld worden als hij/zij aan dat vak gekoppeld - is; een **klassenondersteuner kan overal** ingezet worden. -- [ ] **Hiërarchie vastleggen:** subgroepen verbonden aan groepen, en groepen aan - leerpleinen (subgroep → groep → leerplein). +- [x] **Personeel koppelen aan leerplein en subgroep** (personeelsformulier: + stamgroep, subgroep-begeleiding én leerpleinen). +- [x] **Theorievakken automatisch beschikbaar voor leerkrachten** (een leerkracht + mag altijd op een theorievak ingezet worden; daarnaast worden theorievakken + bij het aanmaken automatisch gekoppeld). +- [x] **Inzetregel begeleider ↔ vak:** leerkracht/praktijkondersteuner alleen op + gekoppelde vakken (leerkracht bovendien altijd op theorievakken); + **klassenondersteuner overal**. +- [x] **Hiërarchie vastgelegd:** subgroep → groep (Subgroep.groep) en + groep → leerplein (Leerplein.groepen). ## Module: Urenoverzicht diff --git a/backend/core/admin.py b/backend/core/admin.py index 7fdaca6..045b67e 100644 --- a/backend/core/admin.py +++ b/backend/core/admin.py @@ -31,10 +31,11 @@ class PersoonAdmin(admin.ModelAdmin): @admin.register(Subgroep) class SubgroepAdmin(admin.ModelAdmin): - list_display = ("naam", "omschrijving", "actief") - list_filter = ("actief",) + list_display = ("naam", "groep", "omschrijving", "actief") + list_filter = ("actief", "groep") search_fields = ("naam",) - filter_horizontal = ("leden",) + autocomplete_fields = ("groep",) + filter_horizontal = ("leden", "begeleiders") @admin.register(Schooljaar) diff --git a/backend/core/migrations/0005_subgroep_begeleiders_subgroep_groep.py b/backend/core/migrations/0005_subgroep_begeleiders_subgroep_groep.py new file mode 100644 index 0000000..cbcf619 --- /dev/null +++ b/backend/core/migrations/0005_subgroep_begeleiders_subgroep_groep.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.15 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0004_activiteit_theorievak_persoon_geboortedatum_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='subgroep', + name='begeleiders', + field=models.ManyToManyField(blank=True, help_text='Medewerkers die aan deze subgroep gekoppeld zijn.', related_name='begeleide_subgroepen', to='core.persoon', verbose_name='begeleiders'), + ), + migrations.AddField( + model_name='subgroep', + name='groep', + field=models.ForeignKey(blank=True, help_text='De groep waar deze subgroep onder valt.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='subgroepen', to='core.groep', verbose_name='groep'), + ), + ] diff --git a/backend/core/serializers.py b/backend/core/serializers.py index e2d60c4..342fff1 100644 --- a/backend/core/serializers.py +++ b/backend/core/serializers.py @@ -38,6 +38,10 @@ class PersoonSerializer(serializers.ModelSerializer): many=True, queryset=Activiteit.objects.all(), required=False ) vakken_namen = serializers.SerializerMethodField() + begeleide_subgroepen = serializers.PrimaryKeyRelatedField( + many=True, queryset=Subgroep.objects.all(), required=False + ) + begeleide_subgroepen_namen = serializers.SerializerMethodField() class Meta: model = Persoon @@ -46,6 +50,7 @@ class PersoonSerializer(serializers.ModelSerializer): "rol", "rol_label", "groep", "groep_naam", "stamgroep", "stamgroep_naam", "functies", "functies_namen", "vakken", "vakken_namen", + "begeleide_subgroepen", "begeleide_subgroepen_namen", "geboortedatum", "actief", "opmerkingen", "aangemaakt_op", ] read_only_fields = ["aangemaakt_op"] @@ -56,13 +61,17 @@ class PersoonSerializer(serializers.ModelSerializer): def get_vakken_namen(self, obj): return [{"id": a.id, "naam": a.naam} for a in obj.vakken.all()] + def get_begeleide_subgroepen_namen(self, obj): + return [{"id": s.id, "naam": s.naam} for s in obj.begeleide_subgroepen.all()] + def validate(self, data): - # Functies/vakken horen alleen bij medewerkers; een leerling krijgt er geen. + # Functies/vakken/subgroep-begeleiding horen alleen bij medewerkers. # (De M2M wordt door DRF ná save() gezet, dus hier afdwingen.) rol = data.get("rol", getattr(self.instance, "rol", None)) if rol == "leerling": data["functies"] = [] data["vakken"] = [] + data["begeleide_subgroepen"] = [] return data @staticmethod @@ -73,17 +82,24 @@ class PersoonSerializer(serializers.ModelSerializer): return gekozen + [a for a in theorie if a.id not in ids] def create(self, validated_data): - # Een leerkracht krijgt de theorievakken standaard direct gekoppeld. + # begeleide_subgroepen is een reverse-M2M: apart afhandelen na save. + subg = validated_data.pop("begeleide_subgroepen", None) if validated_data.get("rol") == "leerkracht": validated_data["vakken"] = self._met_theorievakken(validated_data.get("vakken", [])) - return super().create(validated_data) + instance = super().create(validated_data) + if subg is not None: + instance.begeleide_subgroepen.set(subg) + return instance def update(self, instance, validated_data): - # Ook bij bewerken houdt een leerkracht altijd de theorievakken. + subg = validated_data.pop("begeleide_subgroepen", "__keep__") rol = validated_data.get("rol", instance.rol) if rol == "leerkracht" and "vakken" in validated_data: validated_data["vakken"] = self._met_theorievakken(validated_data["vakken"]) - return super().update(instance, validated_data) + instance = super().update(instance, validated_data) + if subg != "__keep__": + instance.begeleide_subgroepen.set(subg) + return instance class SubgroepSerializer(serializers.ModelSerializer): @@ -92,12 +108,19 @@ class SubgroepSerializer(serializers.ModelSerializer): ) leden_namen = serializers.SerializerMethodField() aantal_leden = serializers.SerializerMethodField() + groep_naam = serializers.CharField(source="groep.naam", read_only=True, default=None) + begeleiders = serializers.PrimaryKeyRelatedField( + many=True, queryset=Persoon.objects.exclude(rol="leerling"), required=False + ) + begeleiders_namen = serializers.SerializerMethodField() class Meta: model = Subgroep fields = [ "id", "naam", "omschrijving", "actief", - "leden", "leden_namen", "aantal_leden", "aangemaakt_op", + "groep", "groep_naam", + "leden", "leden_namen", "aantal_leden", + "begeleiders", "begeleiders_namen", "aangemaakt_op", ] read_only_fields = ["aangemaakt_op"] @@ -107,6 +130,9 @@ class SubgroepSerializer(serializers.ModelSerializer): def get_aantal_leden(self, obj): return obj.leden.count() + def get_begeleiders_namen(self, obj): + return [{"id": p.id, "naam": p.volledige_naam} for p in obj.begeleiders.all()] + # --- Fase 2 --------------------------------------------------------------- @@ -207,13 +233,31 @@ class RoosterblokSerializer(serializers.ModelSerializer): raise serializers.ValidationError( "Kies precies één doelgroep: groep, subgroep of leerling." ) - # Begeleiders zijn medewerkers; een leerling kan geen begeleider zijn. - # (Het Roosterblok-model kent geen rolbeperking, dus hier afdwingen.) - for p in data.get("begeleiders", []): + # Inzetregels voor begeleiders t.o.v. het vak (de activiteit): + # - een leerling kan geen begeleider zijn; + # - een klassenondersteuner kan overal worden ingezet; + # - een leerkracht mag op theorievakken én op de aan hem/haar gekoppelde + # vakken; een praktijkondersteuner alleen op de gekoppelde vakken. + activiteit = data.get("activiteit", getattr(self.instance, "activiteit", None)) + begeleiders = data.get("begeleiders", None) + if begeleiders is None and self.instance is not None: + begeleiders = list(self.instance.begeleiders.all()) + for p in (begeleiders or []): if p.rol == "leerling": raise serializers.ValidationError( f"'{p.volledige_naam}' is een leerling en kan geen begeleider zijn." ) + if p.rol == "klassenondersteuner": + continue + if activiteit is None: + continue + mag = p.vakken.filter(pk=activiteit.pk).exists() + if p.rol == "leerkracht" and getattr(activiteit, "theorievak", False): + mag = True + if not mag: + raise serializers.ValidationError( + f"'{p.volledige_naam}' is niet gekoppeld aan het vak '{activiteit.naam}'." + ) return data diff --git a/backend/modules/leerplein/admin.py b/backend/modules/leerplein/admin.py index 79e0efa..10b6144 100644 --- a/backend/modules/leerplein/admin.py +++ b/backend/modules/leerplein/admin.py @@ -8,7 +8,7 @@ class LeerpleinAdmin(admin.ModelAdmin): list_display = ("naam", "omschrijving", "actief") list_filter = ("actief",) search_fields = ("naam",) - filter_horizontal = ("groepen", "subgroepen", "leerlingen") + filter_horizontal = ("groepen", "subgroepen", "leerlingen", "medewerkers") @admin.register(SubgroepGroep) diff --git a/backend/modules/leerplein/migrations/0002_leerplein_medewerkers.py b/backend/modules/leerplein/migrations/0002_leerplein_medewerkers.py new file mode 100644 index 0000000..2610bf7 --- /dev/null +++ b/backend/modules/leerplein/migrations/0002_leerplein_medewerkers.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0005_subgroep_begeleiders_subgroep_groep'), + ('module_leerplein', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='leerplein', + name='medewerkers', + field=models.ManyToManyField(blank=True, related_name='leerpleinen_medewerker', to='core.persoon', verbose_name='medewerkers'), + ), + ] diff --git a/backend/modules/leerplein/models.py b/backend/modules/leerplein/models.py index 973460e..87f9685 100644 --- a/backend/modules/leerplein/models.py +++ b/backend/modules/leerplein/models.py @@ -26,6 +26,10 @@ class Leerplein(models.Model): "core.Persoon", verbose_name="losse leerlingen", related_name="leerpleinen", blank=True, limit_choices_to={"rol": "leerling"}, ) + medewerkers = models.ManyToManyField( + "core.Persoon", verbose_name="medewerkers", related_name="leerpleinen_medewerker", + blank=True, + ) aangemaakt_op = models.DateTimeField(auto_now_add=True) class Meta: diff --git a/backend/modules/leerplein/serializers.py b/backend/modules/leerplein/serializers.py index d8e143d..bb9becb 100644 --- a/backend/modules/leerplein/serializers.py +++ b/backend/modules/leerplein/serializers.py @@ -15,16 +15,20 @@ class LeerpleinSerializer(serializers.ModelSerializer): leerlingen = serializers.PrimaryKeyRelatedField( many=True, queryset=Persoon.objects.filter(rol="leerling"), required=False ) + medewerkers = serializers.PrimaryKeyRelatedField( + many=True, queryset=Persoon.objects.exclude(rol="leerling"), required=False + ) groepen_namen = serializers.SerializerMethodField() subgroepen_namen = serializers.SerializerMethodField() leerlingen_namen = serializers.SerializerMethodField() + medewerkers_namen = serializers.SerializerMethodField() class Meta: model = Leerplein fields = [ "id", "naam", "omschrijving", "actief", - "groepen", "subgroepen", "leerlingen", - "groepen_namen", "subgroepen_namen", "leerlingen_namen", + "groepen", "subgroepen", "leerlingen", "medewerkers", + "groepen_namen", "subgroepen_namen", "leerlingen_namen", "medewerkers_namen", "aangemaakt_op", ] read_only_fields = ["aangemaakt_op"] @@ -38,6 +42,9 @@ class LeerpleinSerializer(serializers.ModelSerializer): def get_leerlingen_namen(self, obj): return [{"id": p.id, "naam": p.volledige_naam} for p in obj.leerlingen.all()] + def get_medewerkers_namen(self, obj): + return [{"id": p.id, "naam": p.volledige_naam} for p in obj.medewerkers.all()] + class SubgroepGroepSerializer(serializers.ModelSerializer): groep_naam = serializers.CharField(source="groep.naam", read_only=True) diff --git a/frontend/src/pages.jsx b/frontend/src/pages.jsx index 429350a..d355f99 100644 --- a/frontend/src/pages.jsx +++ b/frontend/src/pages.jsx @@ -262,19 +262,36 @@ export function PersonenPage() { const [groepen, setGroepen] = useState([]); const [functies, setFuncties] = useState([]); const [vakken, setVakken] = useState([]); + const [subgroepen, setSubgroepen] = useState([]); + const [leerpleinen, setLeerpleinen] = useState([]); // Twee losse flows: medewerker en leerling. const [modus, setModus] = useState("leerling"); - const leegMedewerker = { voornaam: "", achternaam: "", rol: "leerkracht", stamgroep: "", functies: [], vakken: [] }; + const leegMedewerker = { voornaam: "", achternaam: "", rol: "leerkracht", stamgroep: "", functies: [], vakken: [], begeleide_subgroepen: [], leerpleinen: [] }; const leegLeerling = { voornaam: "", achternaam: "", groep: "", geboortedatum: "" }; const [form, setForm] = useState(leegLeerling); const [editId, setEditId] = useState(null); const [filter, setFilter] = useState(""); - useEffect(() => { + function laadHulplijsten() { listResource("groepen").then(setGroepen).catch(() => {}); listResource("functies").then(setFuncties).catch(() => {}); listResource("activiteiten").then(setVakken).catch(() => {}); - }, []); + listResource("subgroepen").then(setSubgroepen).catch(() => {}); + listResource("leerpleinen").then(setLeerpleinen).catch(() => setLeerpleinen([])); + } + useEffect(() => { laadHulplijsten(); }, []); + + // Leerplein-koppeling loopt via de leerplein-API (de module bezit die relatie). + async function syncLeerpleinen(persoonId, gekozenIds) { + const huidige = await listResource("leerpleinen").catch(() => []); + for (const lp of huidige) { + const inLijst = (lp.medewerkers || []).includes(persoonId); + const moet = gekozenIds.includes(lp.id); + if (moet && !inLijst) await updateResource("leerpleinen", lp.id, { medewerkers: [...lp.medewerkers, persoonId] }); + else if (!moet && inLijst) await updateResource("leerpleinen", lp.id, { medewerkers: lp.medewerkers.filter((x) => x !== persoonId) }); + } + listResource("leerpleinen").then(setLeerpleinen).catch(() => {}); + } function kiesModus(m) { setModus(m); @@ -291,14 +308,19 @@ export function PersonenPage() { ? { voornaam: form.voornaam, achternaam: form.achternaam, rol: form.rol, stamgroep: form.stamgroep || null, functies: form.functies, vakken: form.vakken, + begeleide_subgroepen: form.begeleide_subgroepen, } : { voornaam: form.voornaam, achternaam: form.achternaam, rol: "leerling", groep: form.groep || null, geboortedatum: form.geboortedatum || null, }; try { - if (editId) await updateResource("personen", editId, payload); - else await createResource("personen", payload); + const opgeslagen = editId + ? await updateResource("personen", editId, payload) + : await createResource("personen", payload); + if (modus === "medewerker" && opgeslagen && opgeslagen.id) { + await syncLeerpleinen(opgeslagen.id, form.leerpleinen || []); + } setForm(modus === "medewerker" ? leegMedewerker : leegLeerling); setEditId(null); await reload(); @@ -322,6 +344,8 @@ export function PersonenPage() { stamgroep: p.stamgroep || "", functies: p.functies_namen.map((x) => x.id), vakken: p.vakken_namen.map((x) => x.id), + begeleide_subgroepen: (p.begeleide_subgroepen_namen || []).map((x) => x.id), + leerpleinen: leerpleinen.filter((lp) => (lp.medewerkers || []).includes(p.id)).map((lp) => lp.id), }); } } @@ -382,6 +406,21 @@ export function PersonenPage() { {vakken.length === 0 && Nog geen vakken/activiteiten.} +
Vrije, groep-overstijgende groepjes leerlingen (bv. niveaugroep rekenen).
+Groepjes leerlingen binnen een groep (bv. niveaugroep rekenen), met eigen begeleiders.