roosterwijs/backend/core/models.py

336 lines
11 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.",
)
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):
# Een groepskoppeling hoort alleen bij leerlingen.
if self.rol != Rol.LEERLING:
self.groep = 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.
self.functies.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)
leden = models.ManyToManyField(
Persoon,
verbose_name="leden",
related_name="subgroepen",
blank=True,
limit_choices_to={"rol": Rol.LEERLING},
)
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)
actief = models.BooleanField("actief", default=True)
class Meta:
verbose_name = "tijdslot"
verbose_name_plural = "tijdsloten"
ordering = ["dag", "volgorde", "start_tijd"]
def __str__(self) -> str:
return f"{self.get_dag_display()} {self.start_tijd:%H:%M}-{self.eind_tijd:%H:%M} {self.naam}"
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)
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}"