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.personenwizard", # Personen-wizard & import
|
||||||
"modules.autorooster", # Automatisch rooster
|
"modules.autorooster", # Automatisch rooster
|
||||||
"modules.huisstijl", # Schoollogo / huisstijl
|
"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
|
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.
|
# een andere gebruiker draait) ze niet serveren → gebroken afbeeldingen.
|
||||||
FILE_UPLOAD_PERMISSIONS = 0o644
|
FILE_UPLOAD_PERMISSIONS = 0o644
|
||||||
FILE_UPLOAD_DIRECTORY_PERMISSIONS = 0o755
|
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
|
# 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.
|
# console-log (handig voor test); zet DJANGO_EMAIL_BACKEND/EMAIL_* in productie.
|
||||||
|
|
@ -248,5 +253,6 @@ LOGGING = {
|
||||||
"loggers": {
|
"loggers": {
|
||||||
"django.request": {"level": "WARNING"},
|
"django.request": {"level": "WARNING"},
|
||||||
"roosterwijs": {"level": "INFO"},
|
"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.autorooster.urls")),
|
||||||
path("api/", include("modules.accounts.urls")),
|
path("api/", include("modules.accounts.urls")),
|
||||||
path("api/", include("modules.huisstijl.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:
|
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,
|
Subgroep, Tijdslot,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
MAX_IMAGE_BYTES = 3 * 1024 * 1024
|
||||||
|
MAX_IMAGE_PIXELS = 4000 * 4000
|
||||||
|
ALLOWED_IMAGE_FORMATS = {"PNG", "JPEG", "GIF", "WEBP"}
|
||||||
|
|
||||||
|
|
||||||
class FunctieSerializer(serializers.ModelSerializer):
|
class FunctieSerializer(serializers.ModelSerializer):
|
||||||
aantal_personen = serializers.IntegerField(source="personen.count", read_only=True)
|
aantal_personen = serializers.IntegerField(source="personen.count", read_only=True)
|
||||||
|
|
@ -201,6 +205,31 @@ class ActiviteitSerializer(serializers.ModelSerializer):
|
||||||
model = Activiteit
|
model = Activiteit
|
||||||
fields = ["id", "naam", "kleur", "icoon", "pictogram", "omschrijving", "theorievak", "actief"]
|
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 LocatieSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
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.decorators import api_view, permission_classes
|
||||||
from rest_framework.permissions import AllowAny
|
from rest_framework.permissions import AllowAny
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from plugins import services
|
from plugins import services
|
||||||
|
|
||||||
|
from .audit import AuditedModelViewSet, audit_log
|
||||||
from .models import (
|
from .models import (
|
||||||
Activiteit, Afwezigheid, BlokUitzondering, Functie, Groep, KalenderDag,
|
Activiteit, Afwezigheid, BlokUitzondering, Functie, Groep, KalenderDag,
|
||||||
Locatie, Lokaal, Lokaalinstelling, Periode, Persoon, Roosterblok, Schooljaar,
|
Locatie, Lokaal, Lokaalinstelling, Periode, Persoon, Roosterblok, Schooljaar,
|
||||||
Subgroep, Tijdslot,
|
Subgroep, Tijdslot,
|
||||||
)
|
)
|
||||||
|
from .permissions import CoreDataPermission, restrict_core_queryset
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
ActiviteitSerializer, AfwezigheidSerializer, BlokUitzonderingSerializer,
|
ActiviteitSerializer, AfwezigheidSerializer, BlokUitzonderingSerializer,
|
||||||
FunctieSerializer, GroepSerializer, KalenderDagSerializer, LocatieSerializer,
|
FunctieSerializer, GroepSerializer, KalenderDagSerializer, LocatieSerializer,
|
||||||
|
|
@ -18,6 +19,10 @@ from .serializers import (
|
||||||
SubgroepSerializer, TijdslotSerializer,
|
SubgroepSerializer, TijdslotSerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
SCHEDULE_WRITE_ROLES = ("roostermaker", "directie")
|
||||||
|
ORGANIZATION_WRITE_ROLES = ("directie", "zorgcoordinator")
|
||||||
|
FACILITY_WRITE_ROLES = ("roostermaker", "directie")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@api_view(["GET"])
|
@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()
|
queryset = Groep.objects.all()
|
||||||
serializer_class = GroepSerializer
|
serializer_class = GroepSerializer
|
||||||
|
write_roles = ORGANIZATION_WRITE_ROLES
|
||||||
|
|
||||||
|
|
||||||
class PersoonViewSet(viewsets.ModelViewSet):
|
class PersoonViewSet(CoreModelViewSet):
|
||||||
serializer_class = PersoonSerializer
|
serializer_class = PersoonSerializer
|
||||||
|
write_roles = ORGANIZATION_WRITE_ROLES
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
qs = Persoon.objects.select_related("groep", "stamgroep").prefetch_related("functies", "vakken").all()
|
qs = Persoon.objects.select_related("groep", "stamgroep").prefetch_related("functies", "vakken").all()
|
||||||
rol = self.request.query_params.get("rol")
|
rol = self.request.query_params.get("rol")
|
||||||
if rol:
|
if rol:
|
||||||
qs = qs.filter(rol=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()
|
queryset = Subgroep.objects.prefetch_related("leden").all()
|
||||||
serializer_class = SubgroepSerializer
|
serializer_class = SubgroepSerializer
|
||||||
|
write_roles = ORGANIZATION_WRITE_ROLES
|
||||||
|
|
||||||
|
|
||||||
class FunctieViewSet(viewsets.ModelViewSet):
|
class FunctieViewSet(CoreModelViewSet):
|
||||||
queryset = Functie.objects.all()
|
queryset = Functie.objects.all()
|
||||||
serializer_class = FunctieSerializer
|
serializer_class = FunctieSerializer
|
||||||
|
write_roles = ORGANIZATION_WRITE_ROLES
|
||||||
|
|
||||||
|
|
||||||
# --- Fase 2 ---------------------------------------------------------------
|
# --- Fase 2 ---------------------------------------------------------------
|
||||||
|
|
||||||
class SchooljaarViewSet(viewsets.ModelViewSet):
|
class SchooljaarViewSet(CoreModelViewSet):
|
||||||
queryset = Schooljaar.objects.all()
|
queryset = Schooljaar.objects.all()
|
||||||
serializer_class = SchooljaarSerializer
|
serializer_class = SchooljaarSerializer
|
||||||
|
write_roles = SCHEDULE_WRITE_ROLES
|
||||||
|
|
||||||
|
|
||||||
class KalenderDagViewSet(viewsets.ModelViewSet):
|
class KalenderDagViewSet(CoreModelViewSet):
|
||||||
serializer_class = KalenderDagSerializer
|
serializer_class = KalenderDagSerializer
|
||||||
|
write_roles = SCHEDULE_WRITE_ROLES
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
qs = KalenderDag.objects.all()
|
qs = KalenderDag.objects.all()
|
||||||
schooljaar = self.request.query_params.get("schooljaar")
|
schooljaar = self.request.query_params.get("schooljaar")
|
||||||
if schooljaar:
|
if schooljaar:
|
||||||
qs = qs.filter(schooljaar_id=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()
|
queryset = Tijdslot.objects.all()
|
||||||
serializer_class = TijdslotSerializer
|
serializer_class = TijdslotSerializer
|
||||||
|
write_roles = SCHEDULE_WRITE_ROLES
|
||||||
|
|
||||||
|
|
||||||
class ActiviteitViewSet(viewsets.ModelViewSet):
|
class ActiviteitViewSet(CoreModelViewSet):
|
||||||
queryset = Activiteit.objects.all()
|
queryset = Activiteit.objects.all()
|
||||||
serializer_class = ActiviteitSerializer
|
serializer_class = ActiviteitSerializer
|
||||||
|
write_roles = SCHEDULE_WRITE_ROLES
|
||||||
|
|
||||||
|
|
||||||
class LocatieViewSet(viewsets.ModelViewSet):
|
class LocatieViewSet(CoreModelViewSet):
|
||||||
queryset = Locatie.objects.all()
|
queryset = Locatie.objects.all()
|
||||||
serializer_class = LocatieSerializer
|
serializer_class = LocatieSerializer
|
||||||
|
write_roles = FACILITY_WRITE_ROLES
|
||||||
|
|
||||||
|
|
||||||
class LokaalViewSet(viewsets.ModelViewSet):
|
class LokaalViewSet(CoreModelViewSet):
|
||||||
queryset = Lokaal.objects.all()
|
queryset = Lokaal.objects.all()
|
||||||
serializer_class = LokaalSerializer
|
serializer_class = LokaalSerializer
|
||||||
|
write_roles = FACILITY_WRITE_ROLES
|
||||||
|
|
||||||
|
|
||||||
@api_view(["GET", "PUT"])
|
@api_view(["GET", "PUT"])
|
||||||
|
@permission_classes([CoreDataPermission])
|
||||||
def lokaalinstelling(request):
|
def lokaalinstelling(request):
|
||||||
"""Lees of wijzig de (per-school) instelling voor dubbele lokaalboekingen."""
|
"""Lees of wijzig de (per-school) instelling voor dubbele lokaalboekingen."""
|
||||||
obj = Lokaalinstelling.actueel()
|
obj = Lokaalinstelling.actueel()
|
||||||
|
|
@ -114,12 +137,14 @@ def lokaalinstelling(request):
|
||||||
ser = LokaalinstellingSerializer(obj, data=request.data, partial=True)
|
ser = LokaalinstellingSerializer(obj, data=request.data, partial=True)
|
||||||
ser.is_valid(raise_exception=True)
|
ser.is_valid(raise_exception=True)
|
||||||
ser.save()
|
ser.save()
|
||||||
|
audit_log(request, "update", "Lokaalinstelling:1")
|
||||||
return Response(ser.data)
|
return Response(ser.data)
|
||||||
return Response(LokaalinstellingSerializer(obj).data)
|
return Response(LokaalinstellingSerializer(obj).data)
|
||||||
|
|
||||||
|
|
||||||
class RoosterblokViewSet(viewsets.ModelViewSet):
|
class RoosterblokViewSet(CoreModelViewSet):
|
||||||
serializer_class = RoosterblokSerializer
|
serializer_class = RoosterblokSerializer
|
||||||
|
write_roles = SCHEDULE_WRITE_ROLES
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
qs = (
|
qs = (
|
||||||
|
|
@ -139,22 +164,24 @@ class RoosterblokViewSet(viewsets.ModelViewSet):
|
||||||
qs = qs.filter(sjabloon=True)
|
qs = qs.filter(sjabloon=True)
|
||||||
else:
|
else:
|
||||||
qs = qs.filter(sjabloon=False)
|
qs = qs.filter(sjabloon=False)
|
||||||
return qs
|
return restrict_core_queryset(self.request, qs)
|
||||||
|
|
||||||
|
|
||||||
class PeriodeViewSet(viewsets.ModelViewSet):
|
class PeriodeViewSet(CoreModelViewSet):
|
||||||
serializer_class = PeriodeSerializer
|
serializer_class = PeriodeSerializer
|
||||||
|
write_roles = SCHEDULE_WRITE_ROLES
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
qs = Periode.objects.select_related("schooljaar").all()
|
qs = Periode.objects.select_related("schooljaar").all()
|
||||||
schooljaar = self.request.query_params.get("schooljaar")
|
schooljaar = self.request.query_params.get("schooljaar")
|
||||||
if schooljaar:
|
if schooljaar:
|
||||||
qs = qs.filter(schooljaar_id=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
|
serializer_class = AfwezigheidSerializer
|
||||||
|
write_roles = SCHEDULE_WRITE_ROLES + ORGANIZATION_WRITE_ROLES
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
qs = Afwezigheid.objects.select_related("persoon").all()
|
qs = Afwezigheid.objects.select_related("persoon").all()
|
||||||
|
|
@ -168,11 +195,12 @@ class AfwezigheidViewSet(viewsets.ModelViewSet):
|
||||||
qs = qs.filter(eind_datum__gte=van)
|
qs = qs.filter(eind_datum__gte=van)
|
||||||
if tot:
|
if tot:
|
||||||
qs = qs.filter(start_datum__lte=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
|
serializer_class = BlokUitzonderingSerializer
|
||||||
|
write_roles = SCHEDULE_WRITE_ROLES
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
qs = BlokUitzondering.objects.select_related("vervanger", "roosterblok").all()
|
qs = BlokUitzondering.objects.select_related("vervanger", "roosterblok").all()
|
||||||
|
|
@ -188,7 +216,7 @@ class BlokUitzonderingViewSet(viewsets.ModelViewSet):
|
||||||
schooljaar = self.request.query_params.get("schooljaar")
|
schooljaar = self.request.query_params.get("schooljaar")
|
||||||
if schooljaar:
|
if schooljaar:
|
||||||
qs = qs.filter(roosterblok__schooljaar_id=schooljaar)
|
qs = qs.filter(roosterblok__schooljaar_id=schooljaar)
|
||||||
return qs
|
return restrict_core_queryset(self.request, qs)
|
||||||
|
|
||||||
|
|
||||||
def _leerlingen_van_blok(blok):
|
def _leerlingen_van_blok(blok):
|
||||||
|
|
@ -203,6 +231,7 @@ def _leerlingen_van_blok(blok):
|
||||||
|
|
||||||
|
|
||||||
@api_view(["GET"])
|
@api_view(["GET"])
|
||||||
|
@permission_classes([CoreDataPermission])
|
||||||
def conflicten(request):
|
def conflicten(request):
|
||||||
"""Signaleer roosterconflicten binnen een schooljaar.
|
"""Signaleer roosterconflicten binnen een schooljaar.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ class AccountsConfig(AppConfig):
|
||||||
default_enabled=True,
|
default_enabled=True,
|
||||||
menu_items=(
|
menu_items=(
|
||||||
MenuItem("Gebruikers", "/gebruikers", icon="key", order=300, group="Instellingen"),
|
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):
|
class Gebruikersrol(models.TextChoices):
|
||||||
BEHEERDER = "beheerder", "Beheerder"
|
BEHEERDER = "beheerder", "Beheerder"
|
||||||
MEDEWERKER = "medewerker", "Medewerker"
|
MEDEWERKER = "medewerker", "Medewerker"
|
||||||
|
ROOSTERMAKER = "roostermaker", "Roostermaker"
|
||||||
|
DIRECTIE = "directie", "Directie"
|
||||||
|
ZORGCOORDINATOR = "zorgcoordinator", "Zorgcoordinator"
|
||||||
|
STAGEBEGELEIDER = "stagebegeleider", "Stagebegeleider"
|
||||||
|
KIJKER = "kijker", "Alleen lezen"
|
||||||
OUDER = "ouder", "Ouder"
|
OUDER = "ouder", "Ouder"
|
||||||
LEERLING = "leerling", "Leerling"
|
LEERLING = "leerling", "Leerling"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
from django.contrib.auth import get_user_model
|
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 rest_framework import serializers
|
||||||
|
|
||||||
from core.models import Persoon
|
from core.models import Persoon
|
||||||
|
|
@ -48,6 +50,18 @@ class GebruikerSerializer(serializers.ModelSerializer):
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
if not self.instance and not data.get("password"):
|
if not self.instance and not data.get("password"):
|
||||||
raise serializers.ValidationError({"password": "Een wachtwoord is verplicht voor een nieuwe gebruiker."})
|
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
|
return data
|
||||||
|
|
||||||
def create(self, validated_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 authenticate, get_user_model
|
||||||
from django.contrib.auth import login as auth_login
|
from django.contrib.auth import login as auth_login
|
||||||
from django.contrib.auth import logout as auth_logout
|
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.contrib.auth.tokens import default_token_generator
|
||||||
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||||
from django.core.mail import send_mail
|
from django.core.mail import send_mail
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils.encoding import force_bytes, force_str
|
from django.utils.encoding import force_bytes, force_str
|
||||||
from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
|
from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
|
||||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
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.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 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 .models import Gebruikersprofiel, Gebruikersrol
|
||||||
from .serializers import GebruikerSerializer
|
from .serializers import GebruikerSerializer
|
||||||
|
|
||||||
logger = logging.getLogger("roosterwijs.auth")
|
logger = logging.getLogger("roosterwijs.auth")
|
||||||
User = get_user_model()
|
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):
|
def _user_payload(user):
|
||||||
|
profiel = getattr(user, "gebruikersprofiel", None)
|
||||||
|
persoon = getattr(profiel, "persoon", None) if profiel else None
|
||||||
return {
|
return {
|
||||||
"authenticated": True,
|
"authenticated": True,
|
||||||
"username": user.get_username(),
|
"username": user.get_username(),
|
||||||
"is_staff": user.is_staff,
|
"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
|
user = None
|
||||||
if not user or not default_token_generator.check_token(user, token):
|
if not user or not default_token_generator.check_token(user, token):
|
||||||
return Response({"detail": "Deze resetlink is ongeldig of verlopen."}, status=400)
|
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.set_password(nieuw)
|
||||||
user.save()
|
user.save()
|
||||||
logger.info("Wachtwoord gereset voor %r", user.get_username())
|
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."})
|
return Response({"detail": "Je wachtwoord is aangepast. Je kunt nu inloggen."})
|
||||||
|
|
||||||
|
|
||||||
class GebruikerViewSet(viewsets.ModelViewSet):
|
class GebruikerViewSet(AuditedModelViewSet):
|
||||||
"""Gebruikersbeheer — alleen voor beheerders (is_staff)."""
|
"""Gebruikersbeheer — alleen voor beheerders (is_staff)."""
|
||||||
|
|
||||||
serializer_class = GebruikerSerializer
|
serializer_class = GebruikerSerializer
|
||||||
permission_classes = [IsAdminUser]
|
permission_classes = [AdminRolePermission, AccountsModuleEnabled]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return User.objects.select_related("gebruikersprofiel", "gebruikersprofiel__persoon").order_by("username")
|
return User.objects.select_related("gebruikersprofiel", "gebruikersprofiel__persoon").order_by("username")
|
||||||
|
|
||||||
|
|
||||||
@api_view(["POST"])
|
@api_view(["POST"])
|
||||||
@permission_classes([IsAdminUser])
|
@permission_classes([AdminRolePermission, AccountsModuleEnabled])
|
||||||
def wachtwoord_zet(request, pk):
|
def wachtwoord_zet(request, pk):
|
||||||
"""Beheerder zet/genereert een (tijdelijk) wachtwoord; geeft het terug."""
|
"""Beheerder zet/genereert een (tijdelijk) wachtwoord; geeft het terug."""
|
||||||
try:
|
try:
|
||||||
|
|
@ -144,9 +168,13 @@ def wachtwoord_zet(request, pk):
|
||||||
nieuw = str(request.data.get("wachtwoord", "")).strip() or secrets.token_urlsafe(9)
|
nieuw = str(request.data.get("wachtwoord", "")).strip() or secrets.token_urlsafe(9)
|
||||||
if len(nieuw) < 8:
|
if len(nieuw) < 8:
|
||||||
return Response({"detail": "Het wachtwoord moet minstens 8 tekens zijn."}, status=400)
|
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.set_password(nieuw)
|
||||||
user.save()
|
user.save()
|
||||||
logger.info("Beheerder %r zette nieuw wachtwoord voor %r", request.user.get_username(), user.get_username())
|
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()})
|
return Response({"wachtwoord": nieuw, "gebruiker": user.get_username()})
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -175,7 +203,7 @@ def _uniek(basis: str, gebruikt: set) -> str:
|
||||||
|
|
||||||
|
|
||||||
@api_view(["POST"])
|
@api_view(["POST"])
|
||||||
@permission_classes([IsAdminUser])
|
@permission_classes([AdminRolePermission, AccountsModuleEnabled])
|
||||||
def gebruikers_import(request):
|
def gebruikers_import(request):
|
||||||
"""Bulk: maak meerdere gebruikers met automatisch gegenereerd wachtwoord.
|
"""Bulk: maak meerdere gebruikers met automatisch gegenereerd wachtwoord.
|
||||||
|
|
||||||
|
|
@ -201,6 +229,9 @@ def gebruikers_import(request):
|
||||||
gn = _uniek(gn, gebruikt)
|
gn = _uniek(gn, gebruikt)
|
||||||
wachtwoord = _genereer_wachtwoord()
|
wachtwoord = _genereer_wachtwoord()
|
||||||
user = User(username=gn, email=email, first_name=vn, last_name=an)
|
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.set_password(wachtwoord)
|
||||||
user.save()
|
user.save()
|
||||||
persoon = None
|
persoon = None
|
||||||
|
|
@ -219,4 +250,5 @@ def gebruikers_import(request):
|
||||||
"rol": rol,
|
"rol": rol,
|
||||||
"persoon_naam": persoon.volledige_naam if persoon else None,
|
"persoon_naam": persoon.volledige_naam if persoon else None,
|
||||||
})
|
})
|
||||||
|
audit_log(request, "import", "User", {"aangemaakt": len(resultaat)})
|
||||||
return Response({"aangemaakt": len(resultaat), "gebruikers": 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.
|
gelden; begeleiders en lokalen worden over álle klassen heen niet dubbel geboekt.
|
||||||
Voorkeuren: vak liever ochtend/middag, dagen vrijhouden, max. blokken per dag.
|
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 rest_framework.response import Response
|
||||||
|
|
||||||
|
from core.audit import audit_log
|
||||||
from core.models import Activiteit, Groep, Lokaal, Persoon, Roosterblok, Schooljaar, Tijdslot
|
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):
|
def _is_ochtend(slot):
|
||||||
|
|
@ -15,6 +19,7 @@ def _is_ochtend(slot):
|
||||||
|
|
||||||
|
|
||||||
@api_view(["POST"])
|
@api_view(["POST"])
|
||||||
|
@permission_classes([CoreDataPermission, AutoroosterModuleEnabled])
|
||||||
def voorstel(request):
|
def voorstel(request):
|
||||||
sjid = request.data.get("schooljaar")
|
sjid = request.data.get("schooljaar")
|
||||||
# Meerdere klassen; val terug op het oude enkele 'groep'-veld.
|
# 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})
|
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"]))
|
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})
|
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.
|
kwaliteitscheck uit en geeft waarschuwingen terug.
|
||||||
DELETE /api/huisstijl/ -> logo verwijderen (alleen beheerders).
|
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.parsers import FormParser, JSONParser, MultiPartParser
|
||||||
from rest_framework.response import Response
|
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 .models import Huisstijl
|
||||||
from .serializers import HuisstijlSerializer
|
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_BYTES = 3 * 1024 * 1024 # 3 MB
|
||||||
|
MAX_PIXELS = 4000 * 4000
|
||||||
|
HuisstijlModuleEnabled = module_permission_class("huisstijl")
|
||||||
|
|
||||||
|
|
||||||
def _hex(rgb):
|
def _hex(rgb):
|
||||||
|
|
@ -31,14 +36,11 @@ def analyseer_logo(f):
|
||||||
ext = naam.rsplit(".", 1)[-1] if "." in naam else ""
|
ext = naam.rsplit(".", 1)[-1] if "." in naam else ""
|
||||||
fouten, waarsch = [], []
|
fouten, waarsch = [], []
|
||||||
if ext not in TOEGESTAAN:
|
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": {}}
|
return {"ok": False, "fouten": fouten, "waarschuwingen": waarsch, "info": {}}
|
||||||
if getattr(f, "size", 0) and f.size > MAX_BYTES:
|
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.")
|
fouten.append(f"Het bestand is te groot ({f.size // 1024} KB); maximaal 3 MB.")
|
||||||
|
return {"ok": False, "fouten": fouten, "waarschuwingen": waarsch, "info": {}}
|
||||||
if ext == "svg":
|
|
||||||
return {"ok": True, "fouten": [], "waarschuwingen": waarsch,
|
|
||||||
"info": {"formaat": "SVG", "transparant": True, "breedte": None, "hoogte": None, "achtergrond": None}}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from PIL import Image
|
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": {}}
|
return {"ok": False, "fouten": ["Kan de afbeelding niet lezen of het bestand is beschadigd."], "waarschuwingen": [], "info": {}}
|
||||||
|
|
||||||
breedte, hoogte = img.size
|
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
|
transparant = False
|
||||||
achtergrond = None
|
achtergrond = None
|
||||||
heeft_alpha = img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info)
|
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"])
|
@api_view(["GET", "PUT", "POST", "DELETE"])
|
||||||
@parser_classes([MultiPartParser, FormParser, JSONParser])
|
@parser_classes([MultiPartParser, FormParser, JSONParser])
|
||||||
|
@permission_classes([CoreDataPermission, HuisstijlModuleEnabled])
|
||||||
def huisstijl(request):
|
def huisstijl(request):
|
||||||
obj = Huisstijl.actueel()
|
obj = Huisstijl.actueel()
|
||||||
schrijven = request.method in ("PUT", "POST", "DELETE")
|
schrijven = request.method in ("PUT", "POST", "DELETE")
|
||||||
|
|
@ -106,6 +112,7 @@ def huisstijl(request):
|
||||||
if request.method == "DELETE":
|
if request.method == "DELETE":
|
||||||
if obj.logo:
|
if obj.logo:
|
||||||
obj.logo.delete(save=True)
|
obj.logo.delete(save=True)
|
||||||
|
audit_log(request, "delete", "Huisstijl.logo")
|
||||||
return Response(HuisstijlSerializer(obj, context={"request": request}).data)
|
return Response(HuisstijlSerializer(obj, context={"request": request}).data)
|
||||||
|
|
||||||
if request.method in ("PUT", "POST"):
|
if request.method in ("PUT", "POST"):
|
||||||
|
|
@ -134,6 +141,7 @@ def huisstijl(request):
|
||||||
obj.save()
|
obj.save()
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
return Response({"detail": f"Opslaan mislukte: {e}"}, status=400)
|
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
|
data = HuisstijlSerializer(obj, context={"request": request}).data
|
||||||
if analyse is not None:
|
if analyse is not None:
|
||||||
data["kwaliteit"] = analyse
|
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 .models import Leerplein, SubgroepGroep
|
||||||
from .serializers import LeerpleinSerializer, SubgroepGroepSerializer
|
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
|
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
|
serializer_class = SubgroepGroepSerializer
|
||||||
|
permission_classes = [CoreDataPermission, LeerpleinModuleEnabled]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
qs = SubgroepGroep.objects.select_related("groep", "subgroep").all()
|
qs = SubgroepGroep.objects.select_related("groep", "subgroep").all()
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,17 @@
|
||||||
"""Bulk-import van personen (leerlingen/medewerkers). Geen eigen model."""
|
"""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 rest_framework.response import Response
|
||||||
|
|
||||||
|
from core.audit import audit_log
|
||||||
from core.models import Groep, Persoon, Rol
|
from core.models import Groep, Persoon, Rol
|
||||||
|
from core.permissions import CoreDataPermission, module_permission_class
|
||||||
|
|
||||||
GELDIGE_ROLLEN = {r.value for r in Rol}
|
GELDIGE_ROLLEN = {r.value for r in Rol}
|
||||||
|
PersonenwizardModuleEnabled = module_permission_class("personenwizard")
|
||||||
|
|
||||||
|
|
||||||
@api_view(["POST"])
|
@api_view(["POST"])
|
||||||
|
@permission_classes([CoreDataPermission, PersonenwizardModuleEnabled])
|
||||||
def personen_import(request):
|
def personen_import(request):
|
||||||
rol = request.data.get("rol")
|
rol = request.data.get("rol")
|
||||||
personen = request.data.get("personen") or []
|
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"})
|
meldingen.append({"regel": i + 1, "melding": f"groep '{gnaam}' niet gevonden — leerling zonder groep aangemaakt"})
|
||||||
obj.save()
|
obj.save()
|
||||||
aangemaakt += 1
|
aangemaakt += 1
|
||||||
|
audit_log(request, "import", "Persoon", {"aangemaakt": aangemaakt, "rol": rol})
|
||||||
return Response({"aangemaakt": aangemaakt, "meldingen": meldingen})
|
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, permission_classes
|
||||||
from rest_framework.decorators import api_view
|
|
||||||
from rest_framework.response import Response
|
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 .models import Stage
|
||||||
from .serializers import StageSerializer, _theorievak_botsing, _lokaal_bezet_door_ander
|
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
|
serializer_class = StageSerializer
|
||||||
|
permission_classes = [CoreDataPermission, StageModuleEnabled]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
qs = Stage.objects.select_related(
|
qs = Stage.objects.select_related(
|
||||||
|
|
@ -22,14 +27,22 @@ class StageViewSet(viewsets.ModelViewSet):
|
||||||
actief = self.request.query_params.get("actief")
|
actief = self.request.query_params.get("actief")
|
||||||
if actief in ("1", "true", "True"):
|
if actief in ("1", "true", "True"):
|
||||||
qs = qs.filter(actief=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
|
return qs
|
||||||
|
|
||||||
|
|
||||||
@api_view(["GET"])
|
@api_view(["GET"])
|
||||||
|
@permission_classes([CoreDataPermission, StageModuleEnabled])
|
||||||
def stage_conflicten(request):
|
def stage_conflicten(request):
|
||||||
"""Signaleer stage-conflicten: begeleider op theorievak + dubbele lokaalboeking."""
|
"""Signaleer stage-conflicten: begeleider op theorievak + dubbele lokaalboeking."""
|
||||||
resultaat = []
|
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(
|
botsing = _theorievak_botsing(
|
||||||
stage.begeleider, stage.omvang, stage.dag, stage.dagdeel, stage.tijdslot
|
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 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 rest_framework.response import Response
|
||||||
|
|
||||||
|
from core.audit import audit_log
|
||||||
from core.models import KalenderDag, Persoon, Roosterblok, Rol, Schooljaar, Subgroep, Tijdslot
|
from core.models import KalenderDag, Persoon, Roosterblok, Rol, Schooljaar, Subgroep, Tijdslot
|
||||||
|
from core.permissions import StaffDataPermission, module_permission_class
|
||||||
|
|
||||||
from .models import UrenMomentopname
|
from .models import UrenMomentopname
|
||||||
|
|
||||||
|
UrenModuleEnabled = module_permission_class("uren")
|
||||||
|
|
||||||
|
|
||||||
def _duur_uren(ts):
|
def _duur_uren(ts):
|
||||||
s, e = ts.start_tijd, ts.eind_tijd
|
s, e = ts.start_tijd, ts.eind_tijd
|
||||||
|
|
@ -143,6 +147,7 @@ def bereken_uren(sj):
|
||||||
|
|
||||||
|
|
||||||
@api_view(["GET"])
|
@api_view(["GET"])
|
||||||
|
@permission_classes([StaffDataPermission, UrenModuleEnabled])
|
||||||
def urenoverzicht(request):
|
def urenoverzicht(request):
|
||||||
sjid = request.query_params.get("schooljaar")
|
sjid = request.query_params.get("schooljaar")
|
||||||
if not sjid:
|
if not sjid:
|
||||||
|
|
@ -155,6 +160,7 @@ def urenoverzicht(request):
|
||||||
|
|
||||||
|
|
||||||
@api_view(["GET", "POST"])
|
@api_view(["GET", "POST"])
|
||||||
|
@permission_classes([StaffDataPermission, UrenModuleEnabled])
|
||||||
def momentopnames(request):
|
def momentopnames(request):
|
||||||
"""GET: lijst van momentopnames (samenvatting). POST: bewaar huidige stand."""
|
"""GET: lijst van momentopnames (samenvatting). POST: bewaar huidige stand."""
|
||||||
sjid = request.query_params.get("schooljaar") or request.data.get("schooljaar")
|
sjid = request.query_params.get("schooljaar") or request.data.get("schooljaar")
|
||||||
|
|
@ -168,6 +174,7 @@ def momentopnames(request):
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
payload = bereken_uren(sj)
|
payload = bereken_uren(sj)
|
||||||
opname = UrenMomentopname.objects.create(schooljaar=sj, payload=payload)
|
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)
|
return Response({"id": opname.id, "gemaakt_op": opname.gemaakt_op}, status=201)
|
||||||
|
|
||||||
opnames = UrenMomentopname.objects.filter(schooljaar=sj)
|
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:
|
def __str__(self) -> str:
|
||||||
status = "aan" if self.enabled else "uit"
|
status = "aan" if self.enabled else "uit"
|
||||||
return f"{self.key} ({status})"
|
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 = [
|
urlpatterns = [
|
||||||
path("modules/", views.module_list, name="module-list"),
|
path("modules/", views.module_list, name="module-list"),
|
||||||
path("modules/<str:key>/state/", views.module_set_state, name="module-set-state"),
|
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."""
|
"""API voor het plugin-framework: modules opvragen en aan/uit zetten."""
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.decorators import api_view, permission_classes
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
from rest_framework.permissions import IsAdminUser
|
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from core.audit import audit_log
|
||||||
|
from core.permissions import AdminRolePermission
|
||||||
|
|
||||||
|
from .models import AuditEvent
|
||||||
from . import services
|
from . import services
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -15,7 +18,7 @@ def module_list(request):
|
||||||
|
|
||||||
|
|
||||||
@api_view(["POST"])
|
@api_view(["POST"])
|
||||||
@permission_classes([IsAdminUser])
|
@permission_classes([AdminRolePermission])
|
||||||
def module_set_state(request, key: str):
|
def module_set_state(request, key: str):
|
||||||
"""Zet één module aan of uit (alleen beheerders). Body: {"enabled": true|false}."""
|
"""Zet één module aan of uit (alleen beheerders). Body: {"enabled": true|false}."""
|
||||||
enabled = request.data.get("enabled")
|
enabled = request.data.get("enabled")
|
||||||
|
|
@ -29,4 +32,33 @@ def module_set_state(request, key: str):
|
||||||
services.set_enabled(key, enabled)
|
services.set_enabled(key, enabled)
|
||||||
except services.ModuleError as exc:
|
except services.ModuleError as exc:
|
||||||
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
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())
|
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
|
Django==5.2.15
|
||||||
djangorestframework>=3.15
|
djangorestframework==3.17.1
|
||||||
django-cors-headers>=4.4
|
django-cors-headers==4.9.0
|
||||||
|
|
||||||
# --- Productie / test-server ---------------------------------------------
|
# --- Productie / test-server ---------------------------------------------
|
||||||
gunicorn>=22.0 # WSGI-server die Django draait
|
gunicorn==26.0.0 # WSGI-server die Django draait
|
||||||
psycopg[binary]>=3.2 # PostgreSQL-driver
|
psycopg[binary]==3.3.4 # PostgreSQL-driver
|
||||||
Pillow>=10.0 # beeldverwerking voor pictogram-upload
|
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"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
"vite": "^5.4.2"
|
"vite": "^8.1.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,13 @@ import {
|
||||||
AfwezigheidPage, GroepsindelingPage, StagePage, GebruikersPage, UrenPage,
|
AfwezigheidPage, GroepsindelingPage, StagePage, GebruikersPage, UrenPage,
|
||||||
LeerlingRoosterPage, OuderRoosterPage,
|
LeerlingRoosterPage, OuderRoosterPage,
|
||||||
VakWizardPage, PersonenWizardPage, AutoRoosterPage, WachtwoordResetPage,
|
VakWizardPage, PersonenWizardPage, AutoRoosterPage, WachtwoordResetPage,
|
||||||
HuisstijlPage,
|
HuisstijlPage, AuditLogPage, ImportExportPage, VervangingPage, PortalPage,
|
||||||
} from "./pages.jsx";
|
} from "./pages.jsx";
|
||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
calendar: "📅", users: "👥", layers: "🗂️", printer: "🖨️",
|
calendar: "📅", users: "👥", layers: "🗂️", printer: "🖨️",
|
||||||
key: "🔑", puzzle: "🧩", badge: "🪪", book: "📚", pin: "📍", clock: "🕘",
|
key: "🔑", puzzle: "🧩", badge: "🪪", book: "📚", pin: "📍", clock: "🕘",
|
||||||
away: "🌴", gear: "⚙️", door: "🚪",
|
away: "🌴", gear: "⚙️", door: "🚪", audit: "🧾",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Iconen voor de groep-dropdowns in de bovenbalk.
|
// Iconen voor de groep-dropdowns in de bovenbalk.
|
||||||
|
|
@ -48,6 +48,10 @@ const PAGES = {
|
||||||
"/personen-wizard": PersonenWizardPage,
|
"/personen-wizard": PersonenWizardPage,
|
||||||
"/auto-rooster": AutoRoosterPage,
|
"/auto-rooster": AutoRoosterPage,
|
||||||
"/vormgeving": HuisstijlPage,
|
"/vormgeving": HuisstijlPage,
|
||||||
|
"/auditlog": AuditLogPage,
|
||||||
|
"/import-export": ImportExportPage,
|
||||||
|
"/vervanging": VervangingPage,
|
||||||
|
"/portaal": PortalPage,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Thema (vormgeving) app-breed toepassen via CSS-variabelen.
|
// Thema (vormgeving) app-breed toepassen via CSS-variabelen.
|
||||||
|
|
@ -221,6 +225,14 @@ function Shell({ user, onLogout }) {
|
||||||
|
|
||||||
useEffect(() => { load(); }, []);
|
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() {
|
async function doLogout() {
|
||||||
try {
|
try {
|
||||||
await logout();
|
await logout();
|
||||||
|
|
@ -245,7 +257,8 @@ function Shell({ user, onLogout }) {
|
||||||
if (!m.enabled) continue;
|
if (!m.enabled) continue;
|
||||||
for (const it of m.menu_items) voegToe({ ...it, module: m.key });
|
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" });
|
voegToe({ label: "Modulebeheer", path: "/modules", icon: "gear", order: 9999, group: "Instellingen" });
|
||||||
}
|
}
|
||||||
const items = [...perPad.values()];
|
const items = [...perPad.values()];
|
||||||
|
|
@ -255,7 +268,7 @@ function Shell({ user, onLogout }) {
|
||||||
instellingenTabs: items.filter((i) => i.group === "Instellingen"),
|
instellingenTabs: items.filter((i) => i.group === "Instellingen"),
|
||||||
alleItems: items,
|
alleItems: items,
|
||||||
};
|
};
|
||||||
}, [modules, user.is_staff]);
|
}, [modules, user.is_staff, user.role]);
|
||||||
|
|
||||||
const activePad = normaliseerPad(active);
|
const activePad = normaliseerPad(active);
|
||||||
const Page = PAGES[activePad];
|
const Page = PAGES[activePad];
|
||||||
|
|
@ -382,7 +395,7 @@ function Shell({ user, onLogout }) {
|
||||||
{error && <div className="banner error">⚠ {error}</div>}
|
{error && <div className="banner error">⚠ {error}</div>}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p>Laden…</p>
|
<p>Laden…</p>
|
||||||
) : activePad === "/modules" && user.is_staff ? (
|
) : activePad === "/modules" && (user.is_staff || user.role === "beheerder") ? (
|
||||||
<ModuleManager modules={modules} onChange={load} />
|
<ModuleManager modules={modules} onChange={load} />
|
||||||
) : Page ? (
|
) : Page ? (
|
||||||
<Page />
|
<Page />
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ export async function uploadLogo(file) {
|
||||||
export const getModules = () => request("GET", "/modules/");
|
export const getModules = () => request("GET", "/modules/");
|
||||||
export const setModuleState = (key, enabled) =>
|
export const setModuleState = (key, enabled) =>
|
||||||
request("POST", `/modules/${key}/state/`, { enabled });
|
request("POST", `/modules/${key}/state/`, { enabled });
|
||||||
|
export const getAuditLog = (query = "") => request("GET", `/audit-log/${query}`);
|
||||||
|
|
||||||
// --- Generieke CRUD voor het kerndomein -----------------------------------
|
// --- Generieke CRUD voor het kerndomein -----------------------------------
|
||||||
export const listResource = (resource, query = "") =>
|
export const listResource = (resource, query = "") =>
|
||||||
|
|
@ -84,6 +85,12 @@ export const updateResource = (resource, id, body) =>
|
||||||
export const deleteResource = (resource, id) =>
|
export const deleteResource = (resource, id) =>
|
||||||
request("DELETE", `/${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) ------------------
|
// --- Lokaal-instelling (singleton; dubbele-boekingbeleid) ------------------
|
||||||
export const getLokaalinstelling = () => request("GET", "/lokaalinstelling/");
|
export const getLokaalinstelling = () => request("GET", "/lokaalinstelling/");
|
||||||
export const setLokaalinstelling = (data) => request("PUT", "/lokaalinstelling/", data);
|
export const setLokaalinstelling = (data) => request("PUT", "/lokaalinstelling/", data);
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,14 @@ import {
|
||||||
updateResource,
|
updateResource,
|
||||||
deleteResource,
|
deleteResource,
|
||||||
getModules,
|
getModules,
|
||||||
|
getAuditLog,
|
||||||
getLokaalinstelling,
|
getLokaalinstelling,
|
||||||
setLokaalinstelling,
|
setLokaalinstelling,
|
||||||
|
exportUrl,
|
||||||
|
importPersonenCsv,
|
||||||
|
getPortal,
|
||||||
|
getVervangSuggesties,
|
||||||
|
pasVervangingToe,
|
||||||
uploadBestand,
|
uploadBestand,
|
||||||
wachtwoordReset,
|
wachtwoordReset,
|
||||||
adminWachtwoordReset,
|
adminWachtwoordReset,
|
||||||
|
|
@ -1395,6 +1401,11 @@ export function StagePage() {
|
||||||
const GEBRUIKERSROLLEN = [
|
const GEBRUIKERSROLLEN = [
|
||||||
{ value: "beheerder", label: "Beheerder" },
|
{ value: "beheerder", label: "Beheerder" },
|
||||||
{ value: "medewerker", label: "Medewerker" },
|
{ 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: "ouder", label: "Ouder" },
|
||||||
{ value: "leerling", label: "Leerling" },
|
{ 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 --------------------------- */
|
/* --------------------------- Wachtwoord opnieuw --------------------------- */
|
||||||
export function WachtwoordResetPage({ uid, token }) {
|
export function WachtwoordResetPage({ uid, token }) {
|
||||||
const [w1, setW1] = useState("");
|
const [w1, setW1] = useState("");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue