Add roles modules audit logging and CI
This commit is contained in:
parent
dd52b81b02
commit
c845588e5e
42 changed files with 1780 additions and 1360 deletions
41
.github/workflows/ci.yml
vendored
Normal file
41
.github/workflows/ci.yml
vendored
Normal 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
|
||||
|
|
@ -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},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
58
backend/core/audit.py
Normal 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
139
backend/core/permissions.py
Normal 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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
1
backend/core/tests/__init__.py
Normal file
1
backend/core/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
78
backend/core/tests/test_permissions.py
Normal file
78
backend/core/tests/test_permissions.py
Normal 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())
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1
backend/modules/importexport/__init__.py
Normal file
1
backend/modules/importexport/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
29
backend/modules/importexport/apps.py
Normal file
29
backend/modules/importexport/apps.py
Normal 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"),
|
||||
),
|
||||
)
|
||||
)
|
||||
8
backend/modules/importexport/urls.py
Normal file
8
backend/modules/importexport/urls.py
Normal 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"),
|
||||
]
|
||||
101
backend/modules/importexport/views.py
Normal file
101
backend/modules/importexport/views.py
Normal 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})
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
1
backend/modules/portal/__init__.py
Normal file
1
backend/modules/portal/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
29
backend/modules/portal/apps.py
Normal file
29
backend/modules/portal/apps.py
Normal 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"),
|
||||
),
|
||||
)
|
||||
)
|
||||
7
backend/modules/portal/urls.py
Normal file
7
backend/modules/portal/urls.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path("portal/me/", views.portal_me, name="portal-me"),
|
||||
]
|
||||
33
backend/modules/portal/views.py
Normal file
33
backend/modules/portal/views.py
Normal 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,
|
||||
})
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
1
backend/modules/vervanging/__init__.py
Normal file
1
backend/modules/vervanging/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
29
backend/modules/vervanging/apps.py
Normal file
29
backend/modules/vervanging/apps.py
Normal 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"),
|
||||
),
|
||||
)
|
||||
)
|
||||
8
backend/modules/vervanging/urls.py
Normal file
8
backend/modules/vervanging/urls.py
Normal 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"),
|
||||
]
|
||||
86
backend/modules/vervanging/views.py
Normal file
86
backend/modules/vervanging/views.py
Normal 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})
|
||||
40
backend/plugins/migrations/0002_auditevent.py
Normal file
40
backend/plugins/migrations/0002_auditevent.py
Normal 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"),
|
||||
),
|
||||
]
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1862
frontend/package-lock.json
generated
1862
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
|
|
|
|||
Loading…
Reference in a new issue