roosterwijs/backend/core/models.py

544 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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")
icoon = models.CharField("icoon", max_length=8, blank=True, help_text="Optioneel emoji; leeg = automatisch gekozen op naam.")
pictogram = models.ImageField(
"pictogram", upload_to="pictogrammen/", null=True, blank=True,
help_text="Optioneel pictogram (afbeelding) ter ondersteuning van leerlingen.",
)
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 Lokaal(models.Model):
"""Een fysiek lokaal/ruimte met dubbele-boekingbewaking.
Apart van de vrije 'Locatie'-lijst: lokalen worden gedeeld door lessen
(Roosterblok) en interne stages, en er wordt gecontroleerd of een lokaal
niet op hetzelfde tijdslot dubbel geboekt wordt.
"""
naam = models.CharField("naam", max_length=80, unique=True)
omschrijving = models.CharField("omschrijving", max_length=255, blank=True)
capaciteit = models.PositiveSmallIntegerField("capaciteit", null=True, blank=True)
actief = models.BooleanField("actief", default=True)
aangemaakt_op = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = "lokaal"
verbose_name_plural = "lokalen"
ordering = ["naam"]
def __str__(self) -> str:
return self.naam
class Lokaalinstelling(models.Model):
"""Eén-rij-instelling: blokkeert dubbele lokaalboekingen hard (per school)."""
dubbel_blokkeren = models.BooleanField(
"dubbele boeking hard blokkeren", default=False,
help_text="Aan = opslaan weigeren bij dubbele lokaalboeking; uit = alleen waarschuwen.",
)
class Meta:
verbose_name = "lokaalinstelling"
verbose_name_plural = "lokaalinstelling"
@classmethod
def actueel(cls):
obj, _ = cls.objects.get_or_create(pk=1)
return obj
def __str__(self) -> str:
return "Lokaalinstelling"
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",
)
lokaal = models.ForeignKey(
Lokaal, 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)
# Geldigheidsperiode: leeg = altijd (wekelijks). Gevuld = alleen binnen dit
# datumbereik. geldig_van == geldig_tot betekent een eenmalig blok op die dag.
geldig_van = models.DateField("geldig vanaf", null=True, blank=True)
geldig_tot = models.DateField("geldig tot en met", null=True, blank=True)
# Sjabloonblok: hoort bij de Perioderooster-opzet en telt NIET mee in het
# echte rooster/conflicten. Bij 'voltooien' worden er echte (sjabloon=False)
# blokken van gekopieerd voor de gekozen periode.
sjabloon = models.BooleanField("sjabloon", default=False)
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."
)
if self.geldig_van and self.geldig_tot and self.geldig_tot < self.geldig_van:
raise ValidationError("'geldig tot' ligt vóór 'geldig vanaf'.")
@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}"
class Periode(models.Model):
"""Een periode binnen een schooljaar (Periode 1, 2, ...) met datumbereik.
Het Perioderooster zet bij 'voltooien' het opgebouwde sjabloon om in echte
roosterblokken met geldig_van/geldig_tot gelijk aan deze periode.
"""
schooljaar = models.ForeignKey(
Schooljaar, on_delete=models.CASCADE, related_name="periodes"
)
nummer = models.PositiveIntegerField("periodenummer", default=1)
start_datum = models.DateField("startdatum")
eind_datum = models.DateField("einddatum")
aangemaakt_op = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = "periode"
verbose_name_plural = "periodes"
ordering = ["schooljaar", "nummer"]
unique_together = ("schooljaar", "nummer")
@property
def naam(self) -> str:
return f"{self.schooljaar.naam} · Periode {self.nummer}"
def clean(self):
if self.eind_datum < self.start_datum:
raise ValidationError("'einddatum' ligt vóór 'startdatum'.")
def __str__(self) -> str:
return self.naam
# --------------------------------------------------------------------------
# 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})"