Add roles modules audit logging and CI
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled

This commit is contained in:
bes-r 2026-07-04 23:01:06 +02:00
parent dd52b81b02
commit c845588e5e
42 changed files with 1780 additions and 1360 deletions

41
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,41 @@
name: CI
on:
push:
branches: ["**"]
pull_request:
jobs:
backend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
env:
DJANGO_DEBUG: "1"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: python -m pip install --upgrade pip
- run: pip install -r requirements.txt
- run: python manage.py check
- run: python manage.py makemigrations --check --dry-run
- run: python manage.py test core.tests
frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: frontend/package-lock.json
- run: npm ci
- run: npm audit --audit-level=moderate
- run: npm run build

View file

@ -79,6 +79,9 @@ MODULE_APPS = [
"modules.personenwizard", # Personen-wizard & import
"modules.autorooster", # Automatisch rooster
"modules.huisstijl", # Schoollogo / huisstijl
"modules.importexport", # Import/export
"modules.vervanging", # Vervangingen bij afwezigheid
"modules.portal", # Leerling/ouder-portaal
]
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + CORE_APPS + MODULE_APPS
@ -162,6 +165,8 @@ MEDIA_ROOT = os.environ.get("DJANGO_MEDIA_ROOT", str(BASE_DIR / "media"))
# een andere gebruiker draait) ze niet serveren → gebroken afbeeldingen.
FILE_UPLOAD_PERMISSIONS = 0o644
FILE_UPLOAD_DIRECTORY_PERMISSIONS = 0o755
FILE_UPLOAD_MAX_MEMORY_SIZE = int(os.environ.get("DJANGO_FILE_UPLOAD_MAX_MEMORY_SIZE", str(3 * 1024 * 1024)))
DATA_UPLOAD_MAX_MEMORY_SIZE = int(os.environ.get("DJANGO_DATA_UPLOAD_MAX_MEMORY_SIZE", str(5 * 1024 * 1024)))
# E-mail (voor o.a. wachtwoordreset). Zonder SMTP-config gaat e-mail naar de
# console-log (handig voor test); zet DJANGO_EMAIL_BACKEND/EMAIL_* in productie.
@ -248,5 +253,6 @@ LOGGING = {
"loggers": {
"django.request": {"level": "WARNING"},
"roosterwijs": {"level": "INFO"},
"roosterwijs.audit": {"handlers": ["console"], "level": "INFO", "propagate": False},
},
}

View file

@ -18,6 +18,9 @@ urlpatterns = [
path("api/", include("modules.autorooster.urls")),
path("api/", include("modules.accounts.urls")),
path("api/", include("modules.huisstijl.urls")),
path("api/", include("modules.importexport.urls")),
path("api/", include("modules.vervanging.urls")),
path("api/", include("modules.portal.urls")),
]
if settings.DEBUG:

58
backend/core/audit.py Normal file
View file

@ -0,0 +1,58 @@
"""Kleine auditlog-helper voor gevoelige acties."""
import logging
from rest_framework import viewsets
logger = logging.getLogger("roosterwijs.audit")
def audit_log(request, action, target="", extra=None):
"""Log wie wat deed, zonder request bodies of wachtwoorden te loggen."""
user = getattr(request, "user", None)
username = user.get_username() if user and user.is_authenticated else "anonymous"
ip = None
if request is not None:
forwarded = request.META.get("HTTP_X_FORWARDED_FOR", "")
ip = (forwarded.split(",", 1)[0].strip() or request.META.get("REMOTE_ADDR") or None)
logger.info(
"actor=%r action=%s target=%s extra=%s",
username,
action,
target or "-",
extra or {},
)
try:
from plugins.models import AuditEvent
AuditEvent.objects.create(
actor=username,
action=action,
target=target or "",
ip_address=ip,
extra=extra or {},
)
except Exception:
logger.exception("Kon audit-event niet opslaan")
class AuditedModelViewSet(viewsets.ModelViewSet):
"""ModelViewSet die create/update/delete-acties auditbaar maakt."""
audit_name = None
def _audit_target(self, obj):
name = self.audit_name or obj.__class__.__name__
return f"{name}:{getattr(obj, 'pk', '-')}"
def perform_create(self, serializer):
obj = serializer.save()
audit_log(self.request, "create", self._audit_target(obj))
def perform_update(self, serializer):
obj = serializer.save()
audit_log(self.request, "update", self._audit_target(obj))
def perform_destroy(self, instance):
target = self._audit_target(instance)
super().perform_destroy(instance)
audit_log(self.request, "delete", target)

139
backend/core/permissions.py Normal file
View file

@ -0,0 +1,139 @@
"""Gedeelde permissies voor roltoegang en module-gating."""
from rest_framework.permissions import BasePermission, SAFE_METHODS
def _profile(user):
return getattr(user, "gebruikersprofiel", None)
def user_role(user):
profile = _profile(user)
return getattr(profile, "rol", None)
def linked_person(user):
profile = _profile(user)
return getattr(profile, "persoon", None)
def is_admin_user(user):
return bool(
user
and user.is_authenticated
and (user.is_staff or user_role(user) == "beheerder")
)
INTERNAL_READ_ROLES = {
"medewerker",
"roostermaker",
"directie",
"zorgcoordinator",
"stagebegeleider",
"kijker",
}
def has_view_write_role(user, view):
write_roles = set(getattr(view, "write_roles", ()))
return user_role(user) in write_roles
class CoreDataPermission(BasePermission):
"""Kerngegevens: schrijven per rol, lezen rolbewust."""
message = "Je hebt geen rechten voor deze roostergegevens."
def has_permission(self, request, view):
if not request.user or not request.user.is_authenticated:
return False
if is_admin_user(request.user):
return True
if request.method in SAFE_METHODS:
return True
return has_view_write_role(request.user, view)
def has_object_permission(self, request, view, obj):
if is_admin_user(request.user):
return True
if request.method not in SAFE_METHODS:
return has_view_write_role(request.user, view)
role = user_role(request.user)
person = linked_person(request.user)
if role in ("leerling", "ouder") and person is not None:
if obj.__class__.__name__ == "Persoon":
return obj.pk == person.pk
if hasattr(obj, "persoon_id"):
return obj.persoon_id == person.pk
return role in INTERNAL_READ_ROLES
class StaffDataPermission(BasePermission):
"""Rapportages: interne rollen mogen lezen, beheerders/rolhouders schrijven."""
message = "Je hebt geen rechten voor deze rapportage."
def has_permission(self, request, view):
if not request.user or not request.user.is_authenticated:
return False
if is_admin_user(request.user):
return True
if request.method in SAFE_METHODS:
return user_role(request.user) in INTERNAL_READ_ROLES
return has_view_write_role(request.user, view)
class AdminRolePermission(BasePermission):
"""Beheerder via Django staff of via de applicatierol beheerder."""
message = "Je hebt beheerdersrechten nodig."
def has_permission(self, request, view):
return is_admin_user(request.user)
def restrict_core_queryset(request, queryset):
"""Filter gevoelige lijsten voor leerling/ouderaccounts met gekoppelde persoon."""
user = request.user
if is_admin_user(user) or user_role(user) in INTERNAL_READ_ROLES:
return queryset
person = linked_person(user)
if person is None:
return queryset.none()
model_name = queryset.model.__name__
if model_name == "Groep":
return queryset.filter(pk=person.groep_id) if getattr(person, "groep_id", None) else queryset.none()
if model_name == "Subgroep":
return queryset.filter(leden=person).distinct()
if model_name == "Persoon":
return queryset.filter(pk=person.pk)
if model_name == "Afwezigheid":
return queryset.filter(persoon=person)
if model_name == "Roosterblok":
qs = queryset.filter(leerling=person)
if getattr(person, "groep_id", None):
qs = qs | queryset.filter(groep_id=person.groep_id)
qs = qs | queryset.filter(subgroep__leden=person)
return qs.distinct()
if model_name == "BlokUitzondering":
qs = queryset.filter(roosterblok__leerling=person)
if getattr(person, "groep_id", None):
qs = qs | queryset.filter(roosterblok__groep_id=person.groep_id)
qs = qs | queryset.filter(roosterblok__subgroep__leden=person)
return qs.distinct()
return queryset
def module_permission_class(module_key):
"""Maak een DRF-permissieklasse die een module-aan/uit vlag afdwingt."""
class IsModuleEnabled(BasePermission):
message = f"Module '{module_key}' is uitgeschakeld."
def has_permission(self, request, view):
from plugins import services
return services.is_enabled(module_key)
IsModuleEnabled.__name__ = f"IsModuleEnabled_{module_key}"
return IsModuleEnabled

View file

@ -7,6 +7,10 @@ from .models import (
Subgroep, Tijdslot,
)
MAX_IMAGE_BYTES = 3 * 1024 * 1024
MAX_IMAGE_PIXELS = 4000 * 4000
ALLOWED_IMAGE_FORMATS = {"PNG", "JPEG", "GIF", "WEBP"}
class FunctieSerializer(serializers.ModelSerializer):
aantal_personen = serializers.IntegerField(source="personen.count", read_only=True)
@ -201,6 +205,31 @@ class ActiviteitSerializer(serializers.ModelSerializer):
model = Activiteit
fields = ["id", "naam", "kleur", "icoon", "pictogram", "omschrijving", "theorievak", "actief"]
def validate_pictogram(self, value):
if value is None:
return value
if getattr(value, "size", 0) > MAX_IMAGE_BYTES:
raise serializers.ValidationError("Pictogram is te groot; maximaal 3 MB.")
try:
from PIL import Image
pos = value.tell() if hasattr(value, "tell") else None
image = Image.open(value)
image.verify()
if pos is not None:
value.seek(pos)
image = Image.open(value)
except Exception as exc:
raise serializers.ValidationError("Pictogram is geen leesbare afbeelding.") from exc
if image.format not in ALLOWED_IMAGE_FORMATS:
raise serializers.ValidationError("Gebruik PNG, JPG, GIF of WebP.")
width, height = image.size
if width * height > MAX_IMAGE_PIXELS:
raise serializers.ValidationError("Pictogram is te groot in pixels; maximaal 4000 x 4000.")
if hasattr(value, "seek"):
value.seek(0)
return value
class LocatieSerializer(serializers.ModelSerializer):
class Meta:

View file

@ -0,0 +1 @@

View file

@ -0,0 +1,78 @@
from datetime import date, time
from django.contrib.auth import get_user_model
from django.test import TestCase
from rest_framework.test import APIClient
from core.models import Activiteit, Groep, Schooljaar, Tijdslot
from modules.accounts.models import Gebruikersprofiel
from plugins.models import AuditEvent, ModuleState
User = get_user_model()
class RolePermissionTests(TestCase):
def setUp(self):
self.client = APIClient()
self.schooljaar = Schooljaar.objects.create(
naam="2026-2027",
start_datum=date(2026, 8, 1),
eind_datum=date(2027, 7, 31),
)
self.groep = Groep.objects.create(naam="Groep A")
self.tijdslot = Tijdslot.objects.create(
naam="Blok 1",
dag=0,
start_tijd=time(8, 30),
eind_tijd=time(9, 15),
volgorde=1,
)
self.activiteit = Activiteit.objects.create(naam="Rekenen")
def user_met_rol(self, username, rol, is_staff=False):
user = User.objects.create_user(username=username, password="SterkWachtwoord123!", is_staff=is_staff)
Gebruikersprofiel.objects.create(user=user, rol=rol)
return user
def test_roostermaker_mag_roosterblok_maken_maar_geen_persoon(self):
user = self.user_met_rol("roostermaker", "roostermaker")
self.client.force_authenticate(user=user)
rooster_response = self.client.post(
"/api/roosterblokken/",
{
"schooljaar": self.schooljaar.id,
"tijdslot": self.tijdslot.id,
"activiteit": self.activiteit.id,
"groep": self.groep.id,
"begeleiders": [],
},
format="json",
)
self.assertEqual(rooster_response.status_code, 201, rooster_response.data)
persoon_response = self.client.post(
"/api/personen/",
{"voornaam": "Ada", "achternaam": "Lovelace", "rol": "leerling"},
format="json",
)
self.assertEqual(persoon_response.status_code, 403)
def test_module_gating_blokkeert_uitgeschakelde_importexport(self):
user = self.user_met_rol("beheer", "beheerder", is_staff=True)
self.client.force_authenticate(user=user)
ModuleState.objects.update_or_create(key="importexport", defaults={"enabled": False})
response = self.client.get("/api/export/personen/")
self.assertEqual(response.status_code, 403)
def test_audit_event_wordt_opgeslagen_bij_mutatie(self):
user = self.user_met_rol("beheer", "beheerder", is_staff=True)
self.client.force_authenticate(user=user)
response = self.client.post("/api/groepen/", {"naam": "Auditgroep"}, format="json")
self.assertEqual(response.status_code, 201, response.data)
self.assertTrue(AuditEvent.objects.filter(actor="beheer", action="create", target__startswith="Groep:").exists())

View file

@ -1,15 +1,16 @@
from rest_framework import viewsets
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from plugins import services
from .audit import AuditedModelViewSet, audit_log
from .models import (
Activiteit, Afwezigheid, BlokUitzondering, Functie, Groep, KalenderDag,
Locatie, Lokaal, Lokaalinstelling, Periode, Persoon, Roosterblok, Schooljaar,
Subgroep, Tijdslot,
)
from .permissions import CoreDataPermission, restrict_core_queryset
from .serializers import (
ActiviteitSerializer, AfwezigheidSerializer, BlokUitzonderingSerializer,
FunctieSerializer, GroepSerializer, KalenderDagSerializer, LocatieSerializer,
@ -18,6 +19,10 @@ from .serializers import (
SubgroepSerializer, TijdslotSerializer,
)
SCHEDULE_WRITE_ROLES = ("roostermaker", "directie")
ORGANIZATION_WRITE_ROLES = ("directie", "zorgcoordinator")
FACILITY_WRITE_ROLES = ("roostermaker", "directie")
@api_view(["GET"])
@ -42,71 +47,89 @@ def info(request):
)
class GroepViewSet(viewsets.ModelViewSet):
class CoreModelViewSet(AuditedModelViewSet):
permission_classes = [CoreDataPermission]
def get_queryset(self):
return restrict_core_queryset(self.request, super().get_queryset())
class GroepViewSet(CoreModelViewSet):
queryset = Groep.objects.all()
serializer_class = GroepSerializer
write_roles = ORGANIZATION_WRITE_ROLES
class PersoonViewSet(viewsets.ModelViewSet):
class PersoonViewSet(CoreModelViewSet):
serializer_class = PersoonSerializer
write_roles = ORGANIZATION_WRITE_ROLES
def get_queryset(self):
qs = Persoon.objects.select_related("groep", "stamgroep").prefetch_related("functies", "vakken").all()
rol = self.request.query_params.get("rol")
if rol:
qs = qs.filter(rol=rol)
return qs
return restrict_core_queryset(self.request, qs)
class SubgroepViewSet(viewsets.ModelViewSet):
class SubgroepViewSet(CoreModelViewSet):
queryset = Subgroep.objects.prefetch_related("leden").all()
serializer_class = SubgroepSerializer
write_roles = ORGANIZATION_WRITE_ROLES
class FunctieViewSet(viewsets.ModelViewSet):
class FunctieViewSet(CoreModelViewSet):
queryset = Functie.objects.all()
serializer_class = FunctieSerializer
write_roles = ORGANIZATION_WRITE_ROLES
# --- Fase 2 ---------------------------------------------------------------
class SchooljaarViewSet(viewsets.ModelViewSet):
class SchooljaarViewSet(CoreModelViewSet):
queryset = Schooljaar.objects.all()
serializer_class = SchooljaarSerializer
write_roles = SCHEDULE_WRITE_ROLES
class KalenderDagViewSet(viewsets.ModelViewSet):
class KalenderDagViewSet(CoreModelViewSet):
serializer_class = KalenderDagSerializer
write_roles = SCHEDULE_WRITE_ROLES
def get_queryset(self):
qs = KalenderDag.objects.all()
schooljaar = self.request.query_params.get("schooljaar")
if schooljaar:
qs = qs.filter(schooljaar_id=schooljaar)
return qs
return restrict_core_queryset(self.request, qs)
class TijdslotViewSet(viewsets.ModelViewSet):
class TijdslotViewSet(CoreModelViewSet):
queryset = Tijdslot.objects.all()
serializer_class = TijdslotSerializer
write_roles = SCHEDULE_WRITE_ROLES
class ActiviteitViewSet(viewsets.ModelViewSet):
class ActiviteitViewSet(CoreModelViewSet):
queryset = Activiteit.objects.all()
serializer_class = ActiviteitSerializer
write_roles = SCHEDULE_WRITE_ROLES
class LocatieViewSet(viewsets.ModelViewSet):
class LocatieViewSet(CoreModelViewSet):
queryset = Locatie.objects.all()
serializer_class = LocatieSerializer
write_roles = FACILITY_WRITE_ROLES
class LokaalViewSet(viewsets.ModelViewSet):
class LokaalViewSet(CoreModelViewSet):
queryset = Lokaal.objects.all()
serializer_class = LokaalSerializer
write_roles = FACILITY_WRITE_ROLES
@api_view(["GET", "PUT"])
@permission_classes([CoreDataPermission])
def lokaalinstelling(request):
"""Lees of wijzig de (per-school) instelling voor dubbele lokaalboekingen."""
obj = Lokaalinstelling.actueel()
@ -114,12 +137,14 @@ def lokaalinstelling(request):
ser = LokaalinstellingSerializer(obj, data=request.data, partial=True)
ser.is_valid(raise_exception=True)
ser.save()
audit_log(request, "update", "Lokaalinstelling:1")
return Response(ser.data)
return Response(LokaalinstellingSerializer(obj).data)
class RoosterblokViewSet(viewsets.ModelViewSet):
class RoosterblokViewSet(CoreModelViewSet):
serializer_class = RoosterblokSerializer
write_roles = SCHEDULE_WRITE_ROLES
def get_queryset(self):
qs = (
@ -139,22 +164,24 @@ class RoosterblokViewSet(viewsets.ModelViewSet):
qs = qs.filter(sjabloon=True)
else:
qs = qs.filter(sjabloon=False)
return qs
return restrict_core_queryset(self.request, qs)
class PeriodeViewSet(viewsets.ModelViewSet):
class PeriodeViewSet(CoreModelViewSet):
serializer_class = PeriodeSerializer
write_roles = SCHEDULE_WRITE_ROLES
def get_queryset(self):
qs = Periode.objects.select_related("schooljaar").all()
schooljaar = self.request.query_params.get("schooljaar")
if schooljaar:
qs = qs.filter(schooljaar_id=schooljaar)
return qs
return restrict_core_queryset(self.request, qs)
class AfwezigheidViewSet(viewsets.ModelViewSet):
class AfwezigheidViewSet(CoreModelViewSet):
serializer_class = AfwezigheidSerializer
write_roles = SCHEDULE_WRITE_ROLES + ORGANIZATION_WRITE_ROLES
def get_queryset(self):
qs = Afwezigheid.objects.select_related("persoon").all()
@ -168,11 +195,12 @@ class AfwezigheidViewSet(viewsets.ModelViewSet):
qs = qs.filter(eind_datum__gte=van)
if tot:
qs = qs.filter(start_datum__lte=tot)
return qs
return restrict_core_queryset(self.request, qs)
class BlokUitzonderingViewSet(viewsets.ModelViewSet):
class BlokUitzonderingViewSet(CoreModelViewSet):
serializer_class = BlokUitzonderingSerializer
write_roles = SCHEDULE_WRITE_ROLES
def get_queryset(self):
qs = BlokUitzondering.objects.select_related("vervanger", "roosterblok").all()
@ -188,7 +216,7 @@ class BlokUitzonderingViewSet(viewsets.ModelViewSet):
schooljaar = self.request.query_params.get("schooljaar")
if schooljaar:
qs = qs.filter(roosterblok__schooljaar_id=schooljaar)
return qs
return restrict_core_queryset(self.request, qs)
def _leerlingen_van_blok(blok):
@ -203,6 +231,7 @@ def _leerlingen_van_blok(blok):
@api_view(["GET"])
@permission_classes([CoreDataPermission])
def conflicten(request):
"""Signaleer roosterconflicten binnen een schooljaar.

View file

@ -25,6 +25,7 @@ class AccountsConfig(AppConfig):
default_enabled=True,
menu_items=(
MenuItem("Gebruikers", "/gebruikers", icon="key", order=300, group="Instellingen"),
MenuItem("Auditlog", "/auditlog", icon="audit", order=302, group="Instellingen"),
),
)
)

View file

@ -0,0 +1,31 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("module_accounts", "0002_alter_gebruikersprofiel_persoon"),
]
operations = [
migrations.AlterField(
model_name="gebruikersprofiel",
name="rol",
field=models.CharField(
choices=[
("beheerder", "Beheerder"),
("medewerker", "Medewerker"),
("roostermaker", "Roostermaker"),
("directie", "Directie"),
("zorgcoordinator", "Zorgcoordinator"),
("stagebegeleider", "Stagebegeleider"),
("kijker", "Alleen lezen"),
("ouder", "Ouder"),
("leerling", "Leerling"),
],
default="medewerker",
max_length=16,
verbose_name="rol",
),
),
]

View file

@ -11,6 +11,11 @@ from django.db import models
class Gebruikersrol(models.TextChoices):
BEHEERDER = "beheerder", "Beheerder"
MEDEWERKER = "medewerker", "Medewerker"
ROOSTERMAKER = "roostermaker", "Roostermaker"
DIRECTIE = "directie", "Directie"
ZORGCOORDINATOR = "zorgcoordinator", "Zorgcoordinator"
STAGEBEGELEIDER = "stagebegeleider", "Stagebegeleider"
KIJKER = "kijker", "Alleen lezen"
OUDER = "ouder", "Ouder"
LEERLING = "leerling", "Leerling"

View file

@ -1,4 +1,6 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError as DjangoValidationError
from rest_framework import serializers
from core.models import Persoon
@ -48,6 +50,18 @@ class GebruikerSerializer(serializers.ModelSerializer):
def validate(self, data):
if not self.instance and not data.get("password"):
raise serializers.ValidationError({"password": "Een wachtwoord is verplicht voor een nieuwe gebruiker."})
password = data.get("password")
if password:
user = self.instance or User(
username=data.get("username", ""),
first_name=data.get("first_name", ""),
last_name=data.get("last_name", ""),
email=data.get("email", ""),
)
try:
validate_password(password, user)
except DjangoValidationError as exc:
raise serializers.ValidationError({"password": list(exc.messages)}) from exc
return data
def create(self, validated_data):

View file

@ -10,29 +10,49 @@ import unicodedata
from django.contrib.auth import authenticate, get_user_model
from django.contrib.auth import login as auth_login
from django.contrib.auth import logout as auth_logout
from django.contrib.auth.password_validation import validate_password
from django.contrib.auth.tokens import default_token_generator
from django.core.exceptions import ValidationError as DjangoValidationError
from django.core.mail import send_mail
from django.db.models import Q
from django.utils.encoding import force_bytes, force_str
from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
from django.views.decorators.csrf import ensure_csrf_cookie
from rest_framework import status, viewsets
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny, IsAdminUser
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from core.audit import AuditedModelViewSet, audit_log
from core.permissions import AdminRolePermission, module_permission_class
from .models import Gebruikersprofiel, Gebruikersrol
from .serializers import GebruikerSerializer
logger = logging.getLogger("roosterwijs.auth")
User = get_user_model()
AccountsModuleEnabled = module_permission_class("accounts")
def _password_errors(password, user):
try:
validate_password(password, user)
except DjangoValidationError as exc:
return list(exc.messages)
return None
def _user_payload(user):
profiel = getattr(user, "gebruikersprofiel", None)
persoon = getattr(profiel, "persoon", None) if profiel else None
return {
"authenticated": True,
"username": user.get_username(),
"is_staff": user.is_staff,
"role": profiel.rol if profiel else None,
"role_label": profiel.get_rol_display() if profiel else None,
"person": persoon.id if persoon else None,
"person_name": persoon.volledige_naam if persoon else None,
}
@ -117,24 +137,28 @@ def wachtwoord_reset(request):
user = None
if not user or not default_token_generator.check_token(user, token):
return Response({"detail": "Deze resetlink is ongeldig of verlopen."}, status=400)
fouten = _password_errors(nieuw, user)
if fouten:
return Response({"detail": fouten}, status=400)
user.set_password(nieuw)
user.save()
logger.info("Wachtwoord gereset voor %r", user.get_username())
audit_log(request, "password_reset", f"User:{user.pk}")
return Response({"detail": "Je wachtwoord is aangepast. Je kunt nu inloggen."})
class GebruikerViewSet(viewsets.ModelViewSet):
class GebruikerViewSet(AuditedModelViewSet):
"""Gebruikersbeheer — alleen voor beheerders (is_staff)."""
serializer_class = GebruikerSerializer
permission_classes = [IsAdminUser]
permission_classes = [AdminRolePermission, AccountsModuleEnabled]
def get_queryset(self):
return User.objects.select_related("gebruikersprofiel", "gebruikersprofiel__persoon").order_by("username")
@api_view(["POST"])
@permission_classes([IsAdminUser])
@permission_classes([AdminRolePermission, AccountsModuleEnabled])
def wachtwoord_zet(request, pk):
"""Beheerder zet/genereert een (tijdelijk) wachtwoord; geeft het terug."""
try:
@ -144,9 +168,13 @@ def wachtwoord_zet(request, pk):
nieuw = str(request.data.get("wachtwoord", "")).strip() or secrets.token_urlsafe(9)
if len(nieuw) < 8:
return Response({"detail": "Het wachtwoord moet minstens 8 tekens zijn."}, status=400)
fouten = _password_errors(nieuw, user)
if fouten:
return Response({"detail": fouten}, status=400)
user.set_password(nieuw)
user.save()
logger.info("Beheerder %r zette nieuw wachtwoord voor %r", request.user.get_username(), user.get_username())
audit_log(request, "password_set", f"User:{user.pk}")
return Response({"wachtwoord": nieuw, "gebruiker": user.get_username()})
@ -175,7 +203,7 @@ def _uniek(basis: str, gebruikt: set) -> str:
@api_view(["POST"])
@permission_classes([IsAdminUser])
@permission_classes([AdminRolePermission, AccountsModuleEnabled])
def gebruikers_import(request):
"""Bulk: maak meerdere gebruikers met automatisch gegenereerd wachtwoord.
@ -201,6 +229,9 @@ def gebruikers_import(request):
gn = _uniek(gn, gebruikt)
wachtwoord = _genereer_wachtwoord()
user = User(username=gn, email=email, first_name=vn, last_name=an)
fouten = _password_errors(wachtwoord, user)
if fouten:
return Response({"detail": fouten, "gebruiker": gn}, status=400)
user.set_password(wachtwoord)
user.save()
persoon = None
@ -219,4 +250,5 @@ def gebruikers_import(request):
"rol": rol,
"persoon_naam": persoon.volledige_naam if persoon else None,
})
audit_log(request, "import", "User", {"aangemaakt": len(resultaat)})
return Response({"aangemaakt": len(resultaat), "gebruikers": resultaat})

View file

@ -4,10 +4,14 @@ Werkt voor meerdere klassen tegelijk. Een wens (vak) kan voor meerdere klassen
gelden; begeleiders en lokalen worden over álle klassen heen niet dubbel geboekt.
Voorkeuren: vak liever ochtend/middag, dagen vrijhouden, max. blokken per dag.
"""
from rest_framework.decorators import api_view
from rest_framework.decorators import api_view, permission_classes
from rest_framework.response import Response
from core.audit import audit_log
from core.models import Activiteit, Groep, Lokaal, Persoon, Roosterblok, Schooljaar, Tijdslot
from core.permissions import CoreDataPermission, module_permission_class
AutoroosterModuleEnabled = module_permission_class("autorooster")
def _is_ochtend(slot):
@ -15,6 +19,7 @@ def _is_ochtend(slot):
@api_view(["POST"])
@permission_classes([CoreDataPermission, AutoroosterModuleEnabled])
def voorstel(request):
sjid = request.data.get("schooljaar")
# Meerdere klassen; val terug op het oude enkele 'groep'-veld.
@ -146,4 +151,5 @@ def voorstel(request):
niet.append({"activiteit_naam": act_naam.get(act_id, "?"), "groep_naam": groepen[gid].naam, "tekort": aantal - geplaatst})
voorstel_lijst.sort(key=lambda x: (x["groep_naam"], x["dag"], x["tijdslot_label"]))
audit_log(request, "generate", "AutoroosterVoorstel", {"schooljaar": sj.pk, "groepen": ordered_gids})
return Response({"voorstel": voorstel_lijst, "niet_geplaatst": niet})

View file

@ -5,15 +5,20 @@ PUT /api/huisstijl/ -> upload logo (alleen beheerders); voert een
kwaliteitscheck uit en geeft waarschuwingen terug.
DELETE /api/huisstijl/ -> logo verwijderen (alleen beheerders).
"""
from rest_framework.decorators import api_view, parser_classes
from rest_framework.decorators import api_view, parser_classes, permission_classes
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.response import Response
from core.audit import audit_log
from core.permissions import CoreDataPermission, module_permission_class
from .models import Huisstijl
from .serializers import HuisstijlSerializer
TOEGESTAAN = {"png", "jpg", "jpeg", "gif", "webp", "svg"}
TOEGESTAAN = {"png", "jpg", "jpeg", "gif", "webp"}
MAX_BYTES = 3 * 1024 * 1024 # 3 MB
MAX_PIXELS = 4000 * 4000
HuisstijlModuleEnabled = module_permission_class("huisstijl")
def _hex(rgb):
@ -31,14 +36,11 @@ def analyseer_logo(f):
ext = naam.rsplit(".", 1)[-1] if "." in naam else ""
fouten, waarsch = [], []
if ext not in TOEGESTAAN:
fouten.append(f"Bestandstype '.{ext}' wordt niet ondersteund. Gebruik PNG, SVG, JPG, GIF of WebP.")
fouten.append(f"Bestandstype '.{ext}' wordt niet ondersteund. Gebruik PNG, JPG, GIF of WebP.")
return {"ok": False, "fouten": fouten, "waarschuwingen": waarsch, "info": {}}
if getattr(f, "size", 0) and f.size > MAX_BYTES:
waarsch.append(f"Het bestand is groot ({f.size // 1024} KB); overweeg een kleiner logo.")
if ext == "svg":
return {"ok": True, "fouten": [], "waarschuwingen": waarsch,
"info": {"formaat": "SVG", "transparant": True, "breedte": None, "hoogte": None, "achtergrond": None}}
fouten.append(f"Het bestand is te groot ({f.size // 1024} KB); maximaal 3 MB.")
return {"ok": False, "fouten": fouten, "waarschuwingen": waarsch, "info": {}}
try:
from PIL import Image
@ -54,6 +56,9 @@ def analyseer_logo(f):
return {"ok": False, "fouten": ["Kan de afbeelding niet lezen of het bestand is beschadigd."], "waarschuwingen": [], "info": {}}
breedte, hoogte = img.size
if breedte * hoogte > MAX_PIXELS:
fouten.append("Afbeelding is te groot in pixels; maximaal 4000 x 4000.")
return {"ok": False, "fouten": fouten, "waarschuwingen": waarsch, "info": {}}
transparant = False
achtergrond = None
heeft_alpha = img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info)
@ -97,6 +102,7 @@ def analyseer_logo(f):
@api_view(["GET", "PUT", "POST", "DELETE"])
@parser_classes([MultiPartParser, FormParser, JSONParser])
@permission_classes([CoreDataPermission, HuisstijlModuleEnabled])
def huisstijl(request):
obj = Huisstijl.actueel()
schrijven = request.method in ("PUT", "POST", "DELETE")
@ -106,6 +112,7 @@ def huisstijl(request):
if request.method == "DELETE":
if obj.logo:
obj.logo.delete(save=True)
audit_log(request, "delete", "Huisstijl.logo")
return Response(HuisstijlSerializer(obj, context={"request": request}).data)
if request.method in ("PUT", "POST"):
@ -134,6 +141,7 @@ def huisstijl(request):
obj.save()
except Exception as e: # noqa: BLE001
return Response({"detail": f"Opslaan mislukte: {e}"}, status=400)
audit_log(request, "update", "Huisstijl:1", {"logo": bool(f)})
data = HuisstijlSerializer(obj, context={"request": request}).data
if analyse is not None:
data["kwaliteit"] = analyse

View file

@ -0,0 +1 @@

View file

@ -0,0 +1,29 @@
from django.apps import AppConfig
class ImportExportConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "modules.importexport"
label = "module_importexport"
verbose_name = "Module: Import/export"
def ready(self):
from plugins.registry import MenuItem, ModuleSpec, registry
registry.register(
ModuleSpec(
key="importexport",
name="Import/export",
description=(
"Exporteer kerngegevens naar CSV en importeer personen in bulk. "
"Handig voor migraties, controles en schooljaarovergangen."
),
version="0.1.0",
category="Hulpmiddelen",
core=False,
default_enabled=False,
menu_items=(
MenuItem("Import/export", "/import-export", icon="printer", order=90, group="Hulpmiddelen"),
),
)
)

View file

@ -0,0 +1,8 @@
from django.urls import path
from . import views
urlpatterns = [
path("export/<str:resource>/", views.export_csv, name="export-csv"),
path("import/personen/", views.import_personen, name="import-personen"),
]

View file

@ -0,0 +1,101 @@
import csv
import io
from django.http import HttpResponse
from rest_framework.decorators import api_view, permission_classes
from rest_framework.response import Response
from core.audit import audit_log
from core.models import Afwezigheid, Groep, Persoon, Roosterblok
from core.permissions import CoreDataPermission, StaffDataPermission, module_permission_class
ImportExportModuleEnabled = module_permission_class("importexport")
def _doel_naam(blok):
if blok.groep_id:
return blok.groep.naam
if blok.subgroep_id:
return blok.subgroep.naam
if blok.leerling_id:
return blok.leerling.volledige_naam
return ""
EXPORTS = {
"personen": (
["id", "voornaam", "achternaam", "rol", "groep"],
lambda: Persoon.objects.select_related("groep").all(),
lambda p: [p.id, p.voornaam, p.achternaam, p.rol, p.groep.naam if p.groep_id else ""],
),
"groepen": (
["id", "naam", "actief"],
lambda: Groep.objects.all(),
lambda g: [g.id, g.naam, g.actief],
),
"roosterblokken": (
["id", "schooljaar", "tijdslot", "activiteit", "doel_type", "doel", "begeleiders"],
lambda: Roosterblok.objects.select_related(
"schooljaar", "tijdslot", "activiteit", "groep", "subgroep", "leerling"
).prefetch_related("begeleiders"),
lambda b: [
b.id,
b.schooljaar.naam,
str(b.tijdslot),
b.activiteit.naam,
b.doel_type,
_doel_naam(b),
", ".join(p.volledige_naam for p in b.begeleiders.all()),
],
),
"afwezigheden": (
["id", "persoon", "start_datum", "eind_datum", "reden"],
lambda: Afwezigheid.objects.select_related("persoon").all(),
lambda a: [a.id, a.persoon.volledige_naam, a.start_datum, a.eind_datum, a.reden],
),
}
@api_view(["GET"])
@permission_classes([StaffDataPermission, ImportExportModuleEnabled])
def export_csv(request, resource):
if resource not in EXPORTS:
return Response({"detail": "Onbekende export."}, status=404)
headers, queryset_factory, row_factory = EXPORTS[resource]
response = HttpResponse(content_type="text/csv; charset=utf-8")
response["Content-Disposition"] = f'attachment; filename="{resource}.csv"'
response.write("\ufeff")
writer = csv.writer(response)
writer.writerow(headers)
for obj in queryset_factory():
writer.writerow(row_factory(obj))
audit_log(request, "export", f"CSV:{resource}")
return response
@api_view(["POST"])
@permission_classes([CoreDataPermission, ImportExportModuleEnabled])
def import_personen(request):
tekst = request.data.get("csv") or ""
if not tekst.strip():
return Response({"detail": "CSV-inhoud ontbreekt."}, status=400)
reader = csv.DictReader(io.StringIO(tekst))
aangemaakt = 0
meldingen = []
for nummer, row in enumerate(reader, start=2):
voornaam = (row.get("voornaam") or row.get("Voornaam") or "").strip()
achternaam = (row.get("achternaam") or row.get("Achternaam") or "").strip()
rol = (row.get("rol") or row.get("Rol") or "leerling").strip()
groep_naam = (row.get("groep") or row.get("Groep") or "").strip()
if not voornaam and not achternaam:
continue
groep = None
if groep_naam:
groep, _ = Groep.objects.get_or_create(naam=groep_naam)
try:
Persoon.objects.create(voornaam=voornaam, achternaam=achternaam, rol=rol, groep=groep)
aangemaakt += 1
except Exception as exc:
meldingen.append(f"Rij {nummer}: {exc}")
audit_log(request, "import", "Persoon", {"aangemaakt": aangemaakt, "meldingen": len(meldingen)})
return Response({"aangemaakt": aangemaakt, "meldingen": meldingen})

View file

@ -1,16 +1,32 @@
from rest_framework import viewsets
from core.audit import AuditedModelViewSet
from core.permissions import CoreDataPermission, linked_person, module_permission_class, user_role
from .models import Leerplein, SubgroepGroep
from .serializers import LeerpleinSerializer, SubgroepGroepSerializer
LeerpleinModuleEnabled = module_permission_class("leerplein")
class LeerpleinViewSet(viewsets.ModelViewSet):
queryset = Leerplein.objects.prefetch_related("groepen", "subgroepen", "leerlingen").all()
class LeerpleinViewSet(AuditedModelViewSet):
serializer_class = LeerpleinSerializer
permission_classes = [CoreDataPermission, LeerpleinModuleEnabled]
def get_queryset(self):
qs = Leerplein.objects.prefetch_related("groepen", "subgroepen", "leerlingen").all()
if user_role(self.request.user) in ("leerling", "ouder"):
person = linked_person(self.request.user)
if person is None:
return qs.none()
qs = qs.filter(leerlingen=person)
if getattr(person, "groep_id", None):
qs = qs | Leerplein.objects.filter(groepen=person.groep_id)
qs = qs | Leerplein.objects.filter(subgroepen__leden=person)
return qs.distinct()
return qs
class SubgroepGroepViewSet(viewsets.ModelViewSet):
class SubgroepGroepViewSet(AuditedModelViewSet):
serializer_class = SubgroepGroepSerializer
permission_classes = [CoreDataPermission, LeerpleinModuleEnabled]
def get_queryset(self):
qs = SubgroepGroep.objects.select_related("groep", "subgroep").all()

View file

@ -1,13 +1,17 @@
"""Bulk-import van personen (leerlingen/medewerkers). Geen eigen model."""
from rest_framework.decorators import api_view
from rest_framework.decorators import api_view, permission_classes
from rest_framework.response import Response
from core.audit import audit_log
from core.models import Groep, Persoon, Rol
from core.permissions import CoreDataPermission, module_permission_class
GELDIGE_ROLLEN = {r.value for r in Rol}
PersonenwizardModuleEnabled = module_permission_class("personenwizard")
@api_view(["POST"])
@permission_classes([CoreDataPermission, PersonenwizardModuleEnabled])
def personen_import(request):
rol = request.data.get("rol")
personen = request.data.get("personen") or []
@ -42,4 +46,5 @@ def personen_import(request):
meldingen.append({"regel": i + 1, "melding": f"groep '{gnaam}' niet gevonden — leerling zonder groep aangemaakt"})
obj.save()
aangemaakt += 1
audit_log(request, "import", "Persoon", {"aangemaakt": aangemaakt, "rol": rol})
return Response({"aangemaakt": aangemaakt, "meldingen": meldingen})

View file

@ -0,0 +1 @@

View file

@ -0,0 +1,29 @@
from django.apps import AppConfig
class PortalConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "modules.portal"
label = "module_portal"
verbose_name = "Module: Portal"
def ready(self):
from plugins.registry import MenuItem, ModuleSpec, registry
registry.register(
ModuleSpec(
key="portal",
name="Leerling/ouder-portaal",
description=(
"Een rustige startpagina voor leerlingen en ouders met hun "
"gekoppelde persoon en snelle toegang tot het eigen rooster."
),
version="0.1.0",
category="Portaal",
core=False,
default_enabled=True,
menu_items=(
MenuItem("Portaal", "/portaal", icon="book", order=8, group="Rooster"),
),
)
)

View file

@ -0,0 +1,7 @@
from django.urls import path
from . import views
urlpatterns = [
path("portal/me/", views.portal_me, name="portal-me"),
]

View file

@ -0,0 +1,33 @@
from rest_framework.decorators import api_view, permission_classes
from rest_framework.response import Response
from core.models import Afwezigheid
from core.permissions import module_permission_class
PortalModuleEnabled = module_permission_class("portal")
@api_view(["GET"])
@permission_classes([PortalModuleEnabled])
def portal_me(request):
profiel = getattr(request.user, "gebruikersprofiel", None)
persoon = getattr(profiel, "persoon", None) if profiel else None
afwezigheden = []
if persoon:
afwezigheden = [
{
"id": a.id,
"start_datum": a.start_datum,
"eind_datum": a.eind_datum,
"reden": a.reden,
}
for a in Afwezigheid.objects.filter(persoon=persoon)[:5]
]
return Response({
"username": request.user.get_username(),
"role": profiel.rol if profiel else None,
"role_label": profiel.get_rol_display() if profiel else None,
"person": persoon.id if persoon else None,
"person_name": persoon.volledige_naam if persoon else None,
"afwezigheden": afwezigheden,
})

View file

@ -1,13 +1,18 @@
from rest_framework import viewsets
from rest_framework.decorators import api_view
from rest_framework.decorators import api_view, permission_classes
from rest_framework.response import Response
from core.audit import AuditedModelViewSet
from core.permissions import CoreDataPermission, linked_person, module_permission_class, user_role
from .models import Stage
from .serializers import StageSerializer, _theorievak_botsing, _lokaal_bezet_door_ander
StageModuleEnabled = module_permission_class("stage")
class StageViewSet(viewsets.ModelViewSet):
class StageViewSet(AuditedModelViewSet):
serializer_class = StageSerializer
permission_classes = [CoreDataPermission, StageModuleEnabled]
def get_queryset(self):
qs = Stage.objects.select_related(
@ -22,14 +27,22 @@ class StageViewSet(viewsets.ModelViewSet):
actief = self.request.query_params.get("actief")
if actief in ("1", "true", "True"):
qs = qs.filter(actief=True)
if user_role(self.request.user) in ("leerling", "ouder"):
person = linked_person(self.request.user)
qs = qs.filter(leerling=person) if person is not None else qs.none()
return qs
@api_view(["GET"])
@permission_classes([CoreDataPermission, StageModuleEnabled])
def stage_conflicten(request):
"""Signaleer stage-conflicten: begeleider op theorievak + dubbele lokaalboeking."""
resultaat = []
for stage in Stage.objects.filter(actief=True).select_related("leerling", "begeleider", "lokaal"):
qs = Stage.objects.filter(actief=True).select_related("leerling", "begeleider", "lokaal")
if user_role(request.user) in ("leerling", "ouder"):
person = linked_person(request.user)
qs = qs.filter(leerling=person) if person is not None else qs.none()
for stage in qs:
botsing = _theorievak_botsing(
stage.begeleider, stage.omvang, stage.dag, stage.dagdeel, stage.tijdslot
)

View file

@ -7,13 +7,17 @@ geldigheidsperiode (geldig_van/geldig_tot) telt alleen binnen dat bereik mee.
"""
from datetime import timedelta
from rest_framework.decorators import api_view
from rest_framework.decorators import api_view, permission_classes
from rest_framework.response import Response
from core.audit import audit_log
from core.models import KalenderDag, Persoon, Roosterblok, Rol, Schooljaar, Subgroep, Tijdslot
from core.permissions import StaffDataPermission, module_permission_class
from .models import UrenMomentopname
UrenModuleEnabled = module_permission_class("uren")
def _duur_uren(ts):
s, e = ts.start_tijd, ts.eind_tijd
@ -143,6 +147,7 @@ def bereken_uren(sj):
@api_view(["GET"])
@permission_classes([StaffDataPermission, UrenModuleEnabled])
def urenoverzicht(request):
sjid = request.query_params.get("schooljaar")
if not sjid:
@ -155,6 +160,7 @@ def urenoverzicht(request):
@api_view(["GET", "POST"])
@permission_classes([StaffDataPermission, UrenModuleEnabled])
def momentopnames(request):
"""GET: lijst van momentopnames (samenvatting). POST: bewaar huidige stand."""
sjid = request.query_params.get("schooljaar") or request.data.get("schooljaar")
@ -168,6 +174,7 @@ def momentopnames(request):
if request.method == "POST":
payload = bereken_uren(sj)
opname = UrenMomentopname.objects.create(schooljaar=sj, payload=payload)
audit_log(request, "create", f"UrenMomentopname:{opname.pk}", {"schooljaar": sj.pk})
return Response({"id": opname.id, "gemaakt_op": opname.gemaakt_op}, status=201)
opnames = UrenMomentopname.objects.filter(schooljaar=sj)

View file

@ -0,0 +1 @@

View file

@ -0,0 +1,29 @@
from django.apps import AppConfig
class VervangingConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "modules.vervanging"
label = "module_vervanging"
verbose_name = "Module: Vervanging"
def ready(self):
from plugins.registry import MenuItem, ModuleSpec, registry
registry.register(
ModuleSpec(
key="vervanging",
name="Vervanging",
description=(
"Zoek beschikbare vervangers bij afwezige medewerkers en leg "
"eenmalige roosterwijzigingen vast."
),
version="0.1.0",
category="Rooster",
core=False,
default_enabled=False,
menu_items=(
MenuItem("Vervanging", "/vervanging", icon="away", order=18, group="Rooster"),
),
)
)

View file

@ -0,0 +1,8 @@
from django.urls import path
from . import views
urlpatterns = [
path("vervangingen/suggesties/", views.suggesties, name="vervanging-suggesties"),
path("vervangingen/toepassen/", views.toepassen, name="vervanging-toepassen"),
]

View file

@ -0,0 +1,86 @@
from datetime import timedelta
from rest_framework.decorators import api_view, permission_classes
from rest_framework.response import Response
from core.audit import audit_log
from core.models import Afwezigheid, BlokUitzondering, Persoon, Roosterblok
from core.permissions import StaffDataPermission, module_permission_class
VervangingModuleEnabled = module_permission_class("vervanging")
def _datums(start, eind, weekdag):
dag = start
while dag <= eind:
if dag.weekday() == weekdag:
yield dag
dag += timedelta(days=1)
def _beschikbare_medewerkers(blok):
bezet = Persoon.objects.filter(begeleider_roosterblokken__tijdslot=blok.tijdslot).values("id")
qs = Persoon.objects.exclude(rol="leerling").filter(actief=True).exclude(id__in=bezet)
return qs.exclude(id__in=blok.begeleiders.values("id")).distinct()
@api_view(["GET"])
@permission_classes([StaffDataPermission, VervangingModuleEnabled])
def suggesties(request):
afw_id = request.query_params.get("afwezigheid")
try:
afw = Afwezigheid.objects.select_related("persoon").get(pk=afw_id)
except Afwezigheid.DoesNotExist:
return Response({"detail": "Afwezigheid niet gevonden."}, status=404)
blokken = (
Roosterblok.objects
.filter(begeleiders=afw.persoon, sjabloon=False)
.select_related("tijdslot", "activiteit", "groep", "subgroep", "leerling")
.prefetch_related("begeleiders")
)
resultaat = []
for blok in blokken:
datums = list(_datums(afw.start_datum, afw.eind_datum, blok.tijdslot.dag))
if not datums:
continue
kandidaten = _beschikbare_medewerkers(blok)[:8]
resultaat.append({
"roosterblok": blok.id,
"activiteit": blok.activiteit.naam,
"tijdslot": str(blok.tijdslot),
"doel": blok.groep.naam if blok.groep_id else blok.subgroep.naam if blok.subgroep_id else blok.leerling.volledige_naam,
"datums": datums,
"kandidaten": [
{"id": p.id, "naam": p.volledige_naam, "rol": p.get_rol_display()}
for p in kandidaten
],
})
return Response({
"afwezigheid": afw.id,
"persoon": afw.persoon.volledige_naam,
"suggesties": resultaat,
})
@api_view(["POST"])
@permission_classes([StaffDataPermission, VervangingModuleEnabled])
def toepassen(request):
blok_id = request.data.get("roosterblok")
datum = request.data.get("datum")
vervanger_id = request.data.get("vervanger")
if not (blok_id and datum and vervanger_id):
return Response({"detail": "roosterblok, datum en vervanger zijn verplicht."}, status=400)
if not StaffDataPermission().has_permission(request, type("View", (), {"write_roles": ("roostermaker", "directie")})()):
return Response({"detail": "Je hebt geen rechten om vervangingen toe te passen."}, status=403)
obj, _ = BlokUitzondering.objects.update_or_create(
roosterblok_id=blok_id,
datum=datum,
defaults={
"vervalt": False,
"vervanger_id": vervanger_id,
"opmerking": request.data.get("opmerking", "Vervanging"),
},
)
audit_log(request, "replacement_apply", f"BlokUitzondering:{obj.pk}", {"roosterblok": blok_id, "datum": datum})
return Response({"id": obj.id, "roosterblok": obj.roosterblok_id, "datum": obj.datum, "vervanger": obj.vervanger_id})

View file

@ -0,0 +1,40 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("plugins", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="AuditEvent",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("actor", models.CharField(blank=True, max_length=150)),
("action", models.CharField(max_length=80)),
("target", models.CharField(blank=True, max_length=200)),
("ip_address", models.GenericIPAddressField(blank=True, null=True)),
("extra", models.JSONField(blank=True, default=dict)),
],
options={
"verbose_name": "audit event",
"verbose_name_plural": "audit events",
"ordering": ["-created_at"],
},
),
migrations.AddIndex(
model_name="auditevent",
index=models.Index(fields=["-created_at"], name="plugins_aud_created_46edd2_idx"),
),
migrations.AddIndex(
model_name="auditevent",
index=models.Index(fields=["actor"], name="plugins_aud_actor_7de72d_idx"),
),
migrations.AddIndex(
model_name="auditevent",
index=models.Index(fields=["action"], name="plugins_aud_action_d86f22_idx"),
),
]

View file

@ -23,3 +23,27 @@ class ModuleState(models.Model):
def __str__(self) -> str:
status = "aan" if self.enabled else "uit"
return f"{self.key} ({status})"
class AuditEvent(models.Model):
"""Persistent auditspoor voor gevoelige mutaties en beheerdersacties."""
created_at = models.DateTimeField(auto_now_add=True)
actor = models.CharField(max_length=150, blank=True)
action = models.CharField(max_length=80)
target = models.CharField(max_length=200, blank=True)
ip_address = models.GenericIPAddressField(null=True, blank=True)
extra = models.JSONField(default=dict, blank=True)
class Meta:
verbose_name = "audit event"
verbose_name_plural = "audit events"
ordering = ["-created_at"]
indexes = [
models.Index(fields=["-created_at"]),
models.Index(fields=["actor"]),
models.Index(fields=["action"]),
]
def __str__(self) -> str:
return f"{self.created_at:%Y-%m-%d %H:%M} {self.actor} {self.action} {self.target}".strip()

View file

@ -5,4 +5,5 @@ from . import views
urlpatterns = [
path("modules/", views.module_list, name="module-list"),
path("modules/<str:key>/state/", views.module_set_state, name="module-set-state"),
path("audit-log/", views.audit_log_list, name="audit-log"),
]

View file

@ -1,9 +1,12 @@
"""API voor het plugin-framework: modules opvragen en aan/uit zetten."""
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAdminUser
from rest_framework.response import Response
from core.audit import audit_log
from core.permissions import AdminRolePermission
from .models import AuditEvent
from . import services
@ -15,7 +18,7 @@ def module_list(request):
@api_view(["POST"])
@permission_classes([IsAdminUser])
@permission_classes([AdminRolePermission])
def module_set_state(request, key: str):
"""Zet één module aan of uit (alleen beheerders). Body: {"enabled": true|false}."""
enabled = request.data.get("enabled")
@ -29,4 +32,33 @@ def module_set_state(request, key: str):
services.set_enabled(key, enabled)
except services.ModuleError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
audit_log(request, "module_set_state", f"Module:{key}", {"enabled": enabled})
return Response(services.describe_all())
@api_view(["GET"])
@permission_classes([AdminRolePermission])
def audit_log_list(request):
qs = AuditEvent.objects.all()
action = request.query_params.get("action")
actor = request.query_params.get("actor")
if action:
qs = qs.filter(action__icontains=action)
if actor:
qs = qs.filter(actor__icontains=actor)
try:
limit = min(max(int(request.query_params.get("limit", 100)), 1), 500)
except ValueError:
limit = 100
return Response([
{
"id": event.id,
"created_at": event.created_at,
"actor": event.actor,
"action": event.action,
"target": event.target,
"ip_address": event.ip_address,
"extra": event.extra,
}
for event in qs[:limit]
])

View file

@ -1,8 +1,8 @@
Django>=5.2,<6.0
djangorestframework>=3.15
django-cors-headers>=4.4
Django==5.2.15
djangorestframework==3.17.1
django-cors-headers==4.9.0
# --- Productie / test-server ---------------------------------------------
gunicorn>=22.0 # WSGI-server die Django draait
psycopg[binary]>=3.2 # PostgreSQL-driver
Pillow>=10.0 # beeldverwerking voor pictogram-upload
gunicorn==26.0.0 # WSGI-server die Django draait
psycopg[binary]==3.3.4 # PostgreSQL-driver
Pillow==12.3.0 # beeldverwerking voor pictogram-upload

File diff suppressed because it is too large Load diff

View file

@ -13,7 +13,7 @@
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.4.2"
"@vitejs/plugin-react": "^6.0.3",
"vite": "^8.1.3"
}
}

View file

@ -8,13 +8,13 @@ import {
AfwezigheidPage, GroepsindelingPage, StagePage, GebruikersPage, UrenPage,
LeerlingRoosterPage, OuderRoosterPage,
VakWizardPage, PersonenWizardPage, AutoRoosterPage, WachtwoordResetPage,
HuisstijlPage,
HuisstijlPage, AuditLogPage, ImportExportPage, VervangingPage, PortalPage,
} from "./pages.jsx";
const ICONS = {
calendar: "📅", users: "👥", layers: "🗂️", printer: "🖨️",
key: "🔑", puzzle: "🧩", badge: "🪪", book: "📚", pin: "📍", clock: "🕘",
away: "🌴", gear: "⚙️", door: "🚪",
away: "🌴", gear: "⚙️", door: "🚪", audit: "🧾",
};
// Iconen voor de groep-dropdowns in de bovenbalk.
@ -48,6 +48,10 @@ const PAGES = {
"/personen-wizard": PersonenWizardPage,
"/auto-rooster": AutoRoosterPage,
"/vormgeving": HuisstijlPage,
"/auditlog": AuditLogPage,
"/import-export": ImportExportPage,
"/vervanging": VervangingPage,
"/portaal": PortalPage,
};
// Thema (vormgeving) app-breed toepassen via CSS-variabelen.
@ -221,6 +225,14 @@ function Shell({ user, onLogout }) {
useEffect(() => { load(); }, []);
useEffect(() => {
const onNav = (event) => {
if (event.detail) setActive(normaliseerPad(event.detail));
};
window.addEventListener("roosterwijs:navigate", onNav);
return () => window.removeEventListener("roosterwijs:navigate", onNav);
}, []);
async function doLogout() {
try {
await logout();
@ -245,7 +257,8 @@ function Shell({ user, onLogout }) {
if (!m.enabled) continue;
for (const it of m.menu_items) voegToe({ ...it, module: m.key });
}
if (user.is_staff) {
const isBeheerder = user.is_staff || user.role === "beheerder";
if (isBeheerder) {
voegToe({ label: "Modulebeheer", path: "/modules", icon: "gear", order: 9999, group: "Instellingen" });
}
const items = [...perPad.values()];
@ -255,7 +268,7 @@ function Shell({ user, onLogout }) {
instellingenTabs: items.filter((i) => i.group === "Instellingen"),
alleItems: items,
};
}, [modules, user.is_staff]);
}, [modules, user.is_staff, user.role]);
const activePad = normaliseerPad(active);
const Page = PAGES[activePad];
@ -382,7 +395,7 @@ function Shell({ user, onLogout }) {
{error && <div className="banner error"> {error}</div>}
{loading ? (
<p>Laden</p>
) : activePad === "/modules" && user.is_staff ? (
) : activePad === "/modules" && (user.is_staff || user.role === "beheerder") ? (
<ModuleManager modules={modules} onChange={load} />
) : Page ? (
<Page />

View file

@ -73,6 +73,7 @@ export async function uploadLogo(file) {
export const getModules = () => request("GET", "/modules/");
export const setModuleState = (key, enabled) =>
request("POST", `/modules/${key}/state/`, { enabled });
export const getAuditLog = (query = "") => request("GET", `/audit-log/${query}`);
// --- Generieke CRUD voor het kerndomein -----------------------------------
export const listResource = (resource, query = "") =>
@ -84,6 +85,12 @@ export const updateResource = (resource, id, body) =>
export const deleteResource = (resource, id) =>
request("DELETE", `/${resource}/${id}/`);
export const importPersonenCsv = (csv) => request("POST", "/import/personen/", { csv });
export const exportUrl = (resource) => `${BASE}/export/${resource}/`;
export const getVervangSuggesties = (afwezigheid) => request("GET", `/vervangingen/suggesties/?afwezigheid=${afwezigheid}`);
export const pasVervangingToe = (body) => request("POST", "/vervangingen/toepassen/", body);
export const getPortal = () => request("GET", "/portal/me/");
// --- Lokaal-instelling (singleton; dubbele-boekingbeleid) ------------------
export const getLokaalinstelling = () => request("GET", "/lokaalinstelling/");
export const setLokaalinstelling = (data) => request("PUT", "/lokaalinstelling/", data);

View file

@ -5,8 +5,14 @@ import {
updateResource,
deleteResource,
getModules,
getAuditLog,
getLokaalinstelling,
setLokaalinstelling,
exportUrl,
importPersonenCsv,
getPortal,
getVervangSuggesties,
pasVervangingToe,
uploadBestand,
wachtwoordReset,
adminWachtwoordReset,
@ -1395,6 +1401,11 @@ export function StagePage() {
const GEBRUIKERSROLLEN = [
{ value: "beheerder", label: "Beheerder" },
{ value: "medewerker", label: "Medewerker" },
{ value: "roostermaker", label: "Roostermaker" },
{ value: "directie", label: "Directie" },
{ value: "zorgcoordinator", label: "Zorgcoordinator" },
{ value: "stagebegeleider", label: "Stagebegeleider" },
{ value: "kijker", label: "Alleen lezen" },
{ value: "ouder", label: "Ouder" },
{ value: "leerling", label: "Leerling" },
];
@ -4216,6 +4227,177 @@ export function AutoRoosterPage() {
}
/* ------------------------- Audit, export en portaal ----------------------- */
export function AuditLogPage() {
const [items, setItems] = useState([]);
const [filter, setFilter] = useState("");
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
async function laad() {
setLoading(true);
try {
const q = filter.trim() ? `?actor=${encodeURIComponent(filter.trim())}&limit=200` : "?limit=200";
setItems(await getAuditLog(q));
setError(null);
} catch (e) { setError(e.message); }
finally { setLoading(false); }
}
useEffect(() => { laad(); }, []);
return (
<section>
<h1>Auditlog</h1>
<Foutmelding msg={error} />
<div className="form-row">
<input placeholder="Filter op gebruiker" value={filter} onChange={(e) => setFilter(e.target.value)} />
<button type="button" className="btn" onClick={laad}>Ververs</button>
</div>
{loading ? <p>Laden</p> : (
<table className="grid">
<thead><tr><th>Tijd</th><th>Gebruiker</th><th>Actie</th><th>Doel</th><th>IP</th><th>Extra</th></tr></thead>
<tbody>
{items.map((e) => (
<tr key={e.id}>
<td className="muted small">{new Date(e.created_at).toLocaleString("nl-NL")}</td>
<td>{e.actor || "—"}</td>
<td>{e.action}</td>
<td className="muted">{e.target || "—"}</td>
<td className="muted small">{e.ip_address || "—"}</td>
<td className="muted small"><code>{e.extra ? JSON.stringify(e.extra) : "{}"}</code></td>
</tr>
))}
{items.length === 0 && <tr><td colSpan="6" className="muted">Nog geen auditregels.</td></tr>}
</tbody>
</table>
)}
</section>
);
}
export function ImportExportPage() {
const [csv, setCsv] = useState("voornaam,achternaam,rol,groep\n");
const [resultaat, setResultaat] = useState(null);
const [error, setError] = useState(null);
const exports = ["personen", "groepen", "roosterblokken", "afwezigheden"];
async function importeer(e) {
e.preventDefault();
setError(null); setResultaat(null);
try { setResultaat(await importPersonenCsv(csv)); }
catch (err) { setError(err.message); }
}
function leesBestand(file) {
const rd = new FileReader();
rd.onload = () => setCsv(String(rd.result || ""));
rd.readAsText(file);
}
return (
<section>
<h1>Import/export</h1>
<Foutmelding msg={error} />
<h2>Export</h2>
<div className="form-row">
{exports.map((r) => <a key={r} className="btn" href={exportUrl(r)}>{r}.csv</a>)}
</div>
<h2>Personen importeren</h2>
{resultaat && <div className="banner ok">{resultaat.aangemaakt} persoon/personen aangemaakt.{resultaat.meldingen?.length ? ` ${resultaat.meldingen.length} melding(en).` : ""}</div>}
<form className="form-card" onSubmit={importeer}>
<div className="form-row">
<label className="link small">CSV-bestand kiezen<input type="file" accept=".csv,.txt,text/csv" style={{ display: "none" }} onChange={(e) => { const f = e.target.files[0]; if (f) leesBestand(f); e.target.value = ""; }} /></label>
<button className="btn primary" type="submit">Importeer personen</button>
</div>
<textarea className="bulk-invoer" rows={8} value={csv} onChange={(e) => setCsv(e.target.value)} />
</form>
{resultaat?.meldingen?.length > 0 && (
<ul className="muted small">{resultaat.meldingen.map((m, i) => <li key={i}>{m}</li>)}</ul>
)}
</section>
);
}
export function VervangingPage() {
const { items: afwezigheden, error, loading } = useList("afwezigheden");
const [keuze, setKeuze] = useState("");
const [data, setData] = useState(null);
const [msg, setMsg] = useState(null);
const [busy, setBusy] = useState(false);
async function laad() {
if (!keuze) return;
setBusy(true); setMsg(null);
try { setData(await getVervangSuggesties(keuze)); }
catch (e) { setMsg(e.message); }
finally { setBusy(false); }
}
async function toepassen(s, datum, kandidaat) {
setMsg(null);
try {
await pasVervangingToe({ roosterblok: s.roosterblok, datum, vervanger: kandidaat.id });
setMsg(`${kandidaat.naam} vastgelegd op ${datum}.`);
} catch (e) { setMsg(e.message); }
}
return (
<section>
<h1>Vervanging</h1>
<Foutmelding msg={error || msg} />
<div className="form-row">
<select value={keuze} onChange={(e) => setKeuze(e.target.value)}>
<option value="">Kies afwezigheid</option>
{afwezigheden.map((a) => <option key={a.id} value={a.id}>{a.persoon_naam} {a.start_datum} t/m {a.eind_datum}</option>)}
</select>
<button type="button" className="btn primary" disabled={!keuze || busy || loading} onClick={laad}>{busy ? "Bezig…" : "Zoek vervangers"}</button>
</div>
{data && (
<div>
<h2>{data.persoon}</h2>
{data.suggesties.length === 0 && <p className="muted">Geen roosterblokken gevonden waarvoor vervanging nodig is.</p>}
{data.suggesties.map((s) => (
<div className="form-card" key={s.roosterblok}>
<div className="card-title">{s.activiteit}</div>
<p className="muted small">{s.tijdslot} · {s.doel}</p>
{s.datums.map((d) => (
<div className="form-row" key={d}>
<span className="tag">{d}</span>
{s.kandidaten.length === 0 && <span className="muted small">Geen vrije kandidaat gevonden.</span>}
{s.kandidaten.map((k) => <button type="button" className="chip" key={k.id} onClick={() => toepassen(s, d, k)}>{k.naam}</button>)}
</div>
))}
</div>
))}
</div>
)}
</section>
);
}
export function PortalPage() {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
useEffect(() => { getPortal().then(setData).catch((e) => setError(e.message)); }, []);
return (
<section>
<h1>Portaal</h1>
<Foutmelding msg={error} />
{!data ? <p>Laden</p> : (
<>
<div className="form-card">
<div className="card-title">{data.person_name || data.username}</div>
<p className="muted">{data.role_label || "Gebruiker"}</p>
<div className="form-row">
<a className="btn primary" href="#" onClick={(e) => { e.preventDefault(); window.dispatchEvent(new CustomEvent("roosterwijs:navigate", { detail: "/leerlingrooster" })); }}>Mijn rooster</a>
<a className="btn" href="#" onClick={(e) => { e.preventDefault(); window.dispatchEvent(new CustomEvent("roosterwijs:navigate", { detail: "/ouderrooster" })); }}>Ouderrooster</a>
</div>
</div>
<h2>Afwezigheden</h2>
{data.afwezigheden.length === 0 ? <p className="muted">Geen afwezigheden bekend.</p> : (
<table className="grid"><tbody>{data.afwezigheden.map((a) => <tr key={a.id}><td>{a.start_datum}</td><td>{a.eind_datum}</td><td className="muted">{a.reden || "—"}</td></tr>)}</tbody></table>
)}
</>
)}
</section>
);
}
/* --------------------------- Wachtwoord opnieuw --------------------------- */
export function WachtwoordResetPage({ uid, token }) {
const [w1, setW1] = useState("");