448 lines
15 KiB
Python
448 lines
15 KiB
Python
"""
|
||
Kerndomein van Roosterwijs.
|
||
|
||
Fase 1: Persoon, Groep, Subgroep, Functie (catalogus).
|
||
Fase 2: Schooljaar + KalenderDag (jaaroverzicht), Tijdslot, Activiteit,
|
||
Locatie en Roosterblok (de spil van het rooster).
|
||
|
||
Veel flexibiliteit voor het speciaal onderwijs:
|
||
- één Persoon-tabel met een rol (leerling of medewerker-rol);
|
||
- medewerkers kunnen meerdere FUNCTIES hebben uit een beheerbare catalogus;
|
||
- roosterblokken zijn toewijsbaar aan een groep, subgroep OF een individuele
|
||
leerling, met één of meer begeleiders.
|
||
"""
|
||
from django.core.exceptions import ValidationError
|
||
from django.db import models
|
||
|
||
|
||
class Rol(models.TextChoices):
|
||
LEERLING = "leerling", "Leerling"
|
||
LEERKRACHT = "leerkracht", "Leerkracht"
|
||
PRAKTIJKONDERSTEUNER = "praktijkondersteuner", "Praktijkondersteuner"
|
||
KLASSENONDERSTEUNER = "klassenondersteuner", "Klassenondersteuner"
|
||
|
||
|
||
class Functie(models.Model):
|
||
"""Een beheerbare functie/rol voor medewerkers (bv. Directie, Logopedist).
|
||
|
||
Dit is een vrije catalogus: een beheerder maakt functies aan en verwijdert
|
||
ze. Een medewerker kan meerdere functies hebben (zie Persoon.functies).
|
||
"""
|
||
|
||
naam = models.CharField("naam", max_length=80, unique=True)
|
||
omschrijving = models.CharField("omschrijving", max_length=255, blank=True)
|
||
actief = models.BooleanField("actief", default=True)
|
||
aangemaakt_op = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
verbose_name = "functie"
|
||
verbose_name_plural = "functies"
|
||
ordering = ["naam"]
|
||
|
||
def __str__(self) -> str:
|
||
return self.naam
|
||
|
||
|
||
class Groep(models.Model):
|
||
"""Een klas/groep waar leerlingen bij horen."""
|
||
|
||
naam = models.CharField("naam", max_length=100, unique=True)
|
||
omschrijving = models.CharField("omschrijving", max_length=255, blank=True)
|
||
actief = models.BooleanField("actief", default=True)
|
||
aangemaakt_op = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
verbose_name = "groep"
|
||
verbose_name_plural = "groepen"
|
||
ordering = ["naam"]
|
||
|
||
def __str__(self) -> str:
|
||
return self.naam
|
||
|
||
|
||
class Persoon(models.Model):
|
||
"""Leerling of medewerker - een tabel met een rol.
|
||
|
||
Leerlingen horen bij een groep. Medewerkers (niet-leerlingen) kunnen
|
||
meerdere functies hebben uit de Functie-catalogus.
|
||
"""
|
||
|
||
voornaam = models.CharField("voornaam", max_length=80)
|
||
achternaam = models.CharField("achternaam", max_length=120)
|
||
rol = models.CharField("rol", max_length=32, choices=Rol.choices)
|
||
groep = models.ForeignKey(
|
||
Groep,
|
||
verbose_name="groep",
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="leerlingen",
|
||
help_text="Alleen van toepassing op leerlingen.",
|
||
)
|
||
functies = models.ManyToManyField(
|
||
Functie,
|
||
verbose_name="functies",
|
||
related_name="personen",
|
||
blank=True,
|
||
help_text="Alleen van toepassing op medewerkers.",
|
||
)
|
||
stamgroep = models.ForeignKey(
|
||
Groep,
|
||
verbose_name="stamgroep",
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="medewerkers",
|
||
help_text="Stamgroep/plein van een medewerker.",
|
||
)
|
||
vakken = models.ManyToManyField(
|
||
"Activiteit",
|
||
verbose_name="vakken",
|
||
related_name="docenten",
|
||
blank=True,
|
||
help_text="Vakken die een medewerker kan geven.",
|
||
)
|
||
geboortedatum = models.DateField("geboortedatum", null=True, blank=True)
|
||
actief = models.BooleanField("actief", default=True)
|
||
opmerkingen = models.TextField("opmerkingen", blank=True)
|
||
aangemaakt_op = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
verbose_name = "persoon"
|
||
verbose_name_plural = "personen"
|
||
ordering = ["achternaam", "voornaam"]
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.volledige_naam} ({self.get_rol_display()})"
|
||
|
||
@property
|
||
def volledige_naam(self) -> str:
|
||
return f"{self.voornaam} {self.achternaam}".strip()
|
||
|
||
@property
|
||
def is_leerling(self) -> bool:
|
||
return self.rol == Rol.LEERLING
|
||
|
||
def save(self, *args, **kwargs):
|
||
# 'groep' is leerling-only; 'stamgroep' is medewerker-only.
|
||
if self.rol != Rol.LEERLING:
|
||
self.groep = None
|
||
else:
|
||
self.stamgroep = None
|
||
super().save(*args, **kwargs)
|
||
if self.pk:
|
||
# Wie geen leerling (meer) is, hoort niet in subgroepen thuis.
|
||
# (limit_choices_to geldt alleen voor formulieren, niet voor de API.)
|
||
if self.rol != Rol.LEERLING:
|
||
self.subgroepen.clear()
|
||
else:
|
||
# Een leerling heeft geen medewerkersfuncties of -vakken.
|
||
self.functies.clear()
|
||
self.vakken.clear()
|
||
|
||
|
||
class Subgroep(models.Model):
|
||
"""Een vrije, groep-overstijgende verzameling leerlingen.
|
||
|
||
Bijvoorbeeld een niveaugroepje rekenen met leerlingen uit meerdere groepen.
|
||
"""
|
||
|
||
naam = models.CharField("naam", max_length=100)
|
||
omschrijving = models.CharField("omschrijving", max_length=255, blank=True)
|
||
groep = models.ForeignKey(
|
||
Groep,
|
||
verbose_name="groep",
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="subgroepen",
|
||
help_text="De groep waar deze subgroep onder valt.",
|
||
)
|
||
leden = models.ManyToManyField(
|
||
Persoon,
|
||
verbose_name="leden",
|
||
related_name="subgroepen",
|
||
blank=True,
|
||
limit_choices_to={"rol": Rol.LEERLING},
|
||
)
|
||
begeleiders = models.ManyToManyField(
|
||
Persoon,
|
||
verbose_name="begeleiders",
|
||
related_name="begeleide_subgroepen",
|
||
blank=True,
|
||
help_text="Medewerkers die aan deze subgroep gekoppeld zijn.",
|
||
)
|
||
actief = models.BooleanField("actief", default=True)
|
||
aangemaakt_op = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
verbose_name = "subgroep"
|
||
verbose_name_plural = "subgroepen"
|
||
ordering = ["naam"]
|
||
|
||
def __str__(self) -> str:
|
||
return self.naam
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Fase 2: schooljaar/kalender en roosterblokken
|
||
# --------------------------------------------------------------------------
|
||
|
||
class Schooljaar(models.Model):
|
||
"""Een schooljaar met begin- en einddatum; bevat de kalender."""
|
||
|
||
naam = models.CharField("naam", max_length=40, unique=True, help_text="bv. 2026-2027")
|
||
start_datum = models.DateField("startdatum")
|
||
eind_datum = models.DateField("einddatum")
|
||
actief = models.BooleanField("actief", default=True)
|
||
|
||
class Meta:
|
||
verbose_name = "schooljaar"
|
||
verbose_name_plural = "schooljaren"
|
||
ordering = ["-start_datum"]
|
||
|
||
def __str__(self) -> str:
|
||
return self.naam
|
||
|
||
|
||
class DagType(models.TextChoices):
|
||
STUDIEDAG = "studiedag", "Studiedag"
|
||
VRIJE_DAG = "vrije_dag", "Vrije dag"
|
||
VAKANTIE = "vakantie", "Vakantie"
|
||
|
||
|
||
class KalenderDag(models.Model):
|
||
"""Een bijzondere dag in het schooljaar: studiedag, vrije dag of vakantie.
|
||
|
||
Op zulke dagen vervallen de roosterblokken (geen les).
|
||
"""
|
||
|
||
schooljaar = models.ForeignKey(
|
||
Schooljaar, on_delete=models.CASCADE, related_name="kalenderdagen"
|
||
)
|
||
datum = models.DateField("datum")
|
||
type = models.CharField("type", max_length=16, choices=DagType.choices)
|
||
omschrijving = models.CharField("omschrijving", max_length=120, blank=True)
|
||
|
||
class Meta:
|
||
verbose_name = "kalenderdag"
|
||
verbose_name_plural = "kalenderdagen"
|
||
ordering = ["datum"]
|
||
constraints = [
|
||
models.UniqueConstraint(
|
||
fields=["schooljaar", "datum"], name="uniek_schooljaar_datum"
|
||
)
|
||
]
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.datum} ({self.get_type_display()})"
|
||
|
||
@property
|
||
def is_lesdag(self) -> bool:
|
||
# Alle drie de types betekenen 'geen les'.
|
||
return False
|
||
|
||
|
||
class Weekdag(models.IntegerChoices):
|
||
MAANDAG = 0, "Maandag"
|
||
DINSDAG = 1, "Dinsdag"
|
||
WOENSDAG = 2, "Woensdag"
|
||
DONDERDAG = 3, "Donderdag"
|
||
VRIJDAG = 4, "Vrijdag"
|
||
|
||
|
||
class Tijdslot(models.Model):
|
||
"""Een vast tijdblok op een vaste schooldag (bv. 'Blok 1' ma 08:30-10:00)."""
|
||
|
||
naam = models.CharField("naam", max_length=60)
|
||
dag = models.IntegerField("weekdag", choices=Weekdag.choices)
|
||
start_tijd = models.TimeField("starttijd")
|
||
eind_tijd = models.TimeField("eindtijd")
|
||
volgorde = models.PositiveSmallIntegerField("volgorde", default=0)
|
||
groep = models.ForeignKey(
|
||
Groep,
|
||
verbose_name="groep",
|
||
on_delete=models.CASCADE,
|
||
null=True,
|
||
blank=True,
|
||
related_name="tijdsloten",
|
||
help_text="Leeg = algemene (vaste) tijden; gevuld = afwijkende tijden voor die groep.",
|
||
)
|
||
actief = models.BooleanField("actief", default=True)
|
||
|
||
class Meta:
|
||
verbose_name = "tijdslot"
|
||
verbose_name_plural = "tijdsloten"
|
||
ordering = ["dag", "volgorde", "start_tijd"]
|
||
|
||
def __str__(self) -> str:
|
||
suffix = f" ({self.groep.naam})" if self.groep_id else ""
|
||
return f"{self.get_dag_display()} {self.start_tijd:%H:%M}-{self.eind_tijd:%H:%M} {self.naam}{suffix}"
|
||
|
||
|
||
class Activiteit(models.Model):
|
||
"""Wat er gedaan wordt: rekenen, gym, therapie, pauze ..."""
|
||
|
||
naam = models.CharField("naam", max_length=80, unique=True)
|
||
kleur = models.CharField("kleur", max_length=7, default="#2563eb", help_text="hex, bv. #2563eb")
|
||
omschrijving = models.CharField("omschrijving", max_length=255, blank=True)
|
||
theorievak = models.BooleanField(
|
||
"theorievak", default=False,
|
||
help_text="Theorievakken worden standaard aan nieuwe leerkrachten gekoppeld.",
|
||
)
|
||
actief = models.BooleanField("actief", default=True)
|
||
|
||
class Meta:
|
||
verbose_name = "activiteit"
|
||
verbose_name_plural = "activiteiten"
|
||
ordering = ["naam"]
|
||
|
||
def __str__(self) -> str:
|
||
return self.naam
|
||
|
||
|
||
class Locatie(models.Model):
|
||
"""Een lokaal/ruimte (handig voor conflictdetectie in fase 4)."""
|
||
|
||
naam = models.CharField("naam", max_length=80, unique=True)
|
||
omschrijving = models.CharField("omschrijving", max_length=255, blank=True)
|
||
actief = models.BooleanField("actief", default=True)
|
||
|
||
class Meta:
|
||
verbose_name = "locatie"
|
||
verbose_name_plural = "locaties"
|
||
ordering = ["naam"]
|
||
|
||
def __str__(self) -> str:
|
||
return self.naam
|
||
|
||
|
||
class Roosterblok(models.Model):
|
||
"""De spil: wie doet wat, wanneer, waar, met wie.
|
||
|
||
De doelgroep is precies één van: groep, subgroep of individuele leerling.
|
||
Zo kun je een groepsrooster maken én er individueel van afwijken.
|
||
"""
|
||
|
||
schooljaar = models.ForeignKey(
|
||
Schooljaar, on_delete=models.CASCADE, related_name="roosterblokken"
|
||
)
|
||
tijdslot = models.ForeignKey(
|
||
Tijdslot, on_delete=models.PROTECT, related_name="roosterblokken"
|
||
)
|
||
activiteit = models.ForeignKey(
|
||
Activiteit, on_delete=models.PROTECT, related_name="roosterblokken"
|
||
)
|
||
locatie = models.ForeignKey(
|
||
Locatie, on_delete=models.SET_NULL, null=True, blank=True,
|
||
related_name="roosterblokken",
|
||
)
|
||
|
||
# Doelgroep: precies één van de drie is gevuld.
|
||
groep = models.ForeignKey(
|
||
Groep, on_delete=models.CASCADE, null=True, blank=True,
|
||
related_name="roosterblokken",
|
||
)
|
||
subgroep = models.ForeignKey(
|
||
Subgroep, on_delete=models.CASCADE, null=True, blank=True,
|
||
related_name="roosterblokken",
|
||
)
|
||
leerling = models.ForeignKey(
|
||
Persoon, on_delete=models.CASCADE, null=True, blank=True,
|
||
related_name="leerling_roosterblokken",
|
||
limit_choices_to={"rol": Rol.LEERLING},
|
||
)
|
||
|
||
begeleiders = models.ManyToManyField(
|
||
Persoon, related_name="begeleider_roosterblokken", blank=True,
|
||
)
|
||
opmerkingen = models.CharField("opmerkingen", max_length=255, blank=True)
|
||
aangemaakt_op = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
verbose_name = "roosterblok"
|
||
verbose_name_plural = "roosterblokken"
|
||
ordering = ["tijdslot__dag", "tijdslot__volgorde"]
|
||
|
||
def clean(self):
|
||
doelen = [self.groep_id, self.subgroep_id, self.leerling_id]
|
||
gevuld = [d for d in doelen if d]
|
||
if len(gevuld) != 1:
|
||
raise ValidationError(
|
||
"Kies precies één doelgroep: groep, subgroep of leerling."
|
||
)
|
||
|
||
@property
|
||
def doel_type(self) -> str:
|
||
if self.groep_id:
|
||
return "groep"
|
||
if self.subgroep_id:
|
||
return "subgroep"
|
||
if self.leerling_id:
|
||
return "leerling"
|
||
return "onbekend"
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.activiteit} @ {self.tijdslot}"
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Fase 3: flexibiliteit en uitzonderingen
|
||
# --------------------------------------------------------------------------
|
||
|
||
class Afwezigheid(models.Model):
|
||
"""Een persoon (leerling of medewerker) is afwezig in een periode."""
|
||
|
||
persoon = models.ForeignKey(
|
||
Persoon, on_delete=models.CASCADE, related_name="afwezigheden"
|
||
)
|
||
start_datum = models.DateField("van")
|
||
eind_datum = models.DateField("tot en met")
|
||
reden = models.CharField("reden", max_length=160, blank=True)
|
||
aangemaakt_op = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
verbose_name = "afwezigheid"
|
||
verbose_name_plural = "afwezigheden"
|
||
ordering = ["-start_datum"]
|
||
|
||
def clean(self):
|
||
if self.eind_datum and self.start_datum and self.eind_datum < self.start_datum:
|
||
raise ValidationError("De einddatum ligt vóór de startdatum.")
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.persoon} afwezig {self.start_datum}–{self.eind_datum}"
|
||
|
||
|
||
class BlokUitzondering(models.Model):
|
||
"""Een eenmalige afwijking op een roosterblok voor één specifieke datum.
|
||
|
||
- vervalt=True -> het blok gaat die dag niet door;
|
||
- vervanger -> een vervangende begeleider voor die dag;
|
||
- opmerking -> toelichting (bv. 'in ander lokaal').
|
||
"""
|
||
|
||
roosterblok = models.ForeignKey(
|
||
Roosterblok, on_delete=models.CASCADE, related_name="uitzonderingen"
|
||
)
|
||
datum = models.DateField("datum")
|
||
vervalt = models.BooleanField("vervalt", default=True)
|
||
vervanger = models.ForeignKey(
|
||
Persoon, on_delete=models.SET_NULL, null=True, blank=True,
|
||
related_name="vervangingen",
|
||
)
|
||
opmerking = models.CharField("opmerking", max_length=200, blank=True)
|
||
|
||
class Meta:
|
||
verbose_name = "blok-uitzondering"
|
||
verbose_name_plural = "blok-uitzonderingen"
|
||
ordering = ["datum"]
|
||
constraints = [
|
||
models.UniqueConstraint(
|
||
fields=["roosterblok", "datum"], name="uniek_blok_datum"
|
||
)
|
||
]
|
||
|
||
def __str__(self) -> str:
|
||
wat = "vervalt" if self.vervalt else "wijziging"
|
||
return f"{self.roosterblok} op {self.datum} ({wat})"
|