diff --git a/.env.example b/.env.example index 6495914..af6391e 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,10 @@ DJANGO_ALLOWED_HOSTS=rooster-test.example.nl,localhost,127.0.0.1 # Volledige origin(s) inclusief http(s):// voor CSRF achter nginx. DJANGO_CSRF_TRUSTED_ORIGINS=http://rooster-test.example.nl:8080 +# Zet op 1 zodra de server achter HTTPS draait (TLS-certificaat aanwezig). +# Activeert o.a. veilige cookies, HSTS en een redirect naar https. +DJANGO_SECURE=0 + # --- PostgreSQL ----------------------------------------------------------- POSTGRES_DB=rooster POSTGRES_USER=rooster diff --git a/DEPLOY.md b/DEPLOY.md index 09187dc..0b11797 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -88,7 +88,12 @@ Migraties draaien automatisch mee bij het opstarten van de backend. - **HTTPS/domein:** deze opzet draait op poort 8080 zonder TLS. Voor productie zet je er een reverse proxy (bv. Caddy of Traefik) vóór die HTTPS regelt, of je breidt de nginx-config uit met certificaten. De nginx-container is daar al - het logische punt voor. + het logische punt voor. **Zet daarna `DJANGO_SECURE=1` in `.env`** — dat + activeert veilige cookies, HSTS en een https-redirect in Django. +- **Inloggen verplicht:** de hele API vereist een ingelogde gebruiker; de + frontend toont eerst een inlogscherm. Maak gebruikers aan via + `createsuperuser` (zie boven) of in de Django-admin. Modulebeheer is + voorbehouden aan beheerders (`is_staff`). - **Backups:** de database leeft in het volume `pgdata`. Maak hiervan backups, bijvoorbeeld met `docker compose exec db pg_dump -U rooster rooster > backup.sql`. - **`DJANGO_DEBUG` blijft 0** op de server. Zet hem nooit op 1 in productie. diff --git a/README.md b/README.md index b54ef0d..9c26c92 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,10 @@ Modulair, flexibel roostersysteem voor het speciaal onderwijs (uil-logo: een uil staat voor wijsheid → *Roosterwijs*). Zie `PLAN.md` voor de visie en de fasering, en `DEPLOY.md` voor draaien op een test-/productieserver. -Status: **Fase 0** (fundament + plugin-framework) en **Fase 1** (kerndomein: -personen, groepen, subgroepen) zijn klaar. +Status: **Fase 0** (fundament + plugin-framework), **Fase 1** (kerndomein: +personen, groepen, subgroepen, functies) en **Fase 2** (schooljaarkalender + +roosterblokken) zijn klaar. Medewerkers kunnen meerdere functies hebben uit een +beheerbare functie-catalogus. ## Wat er nu staat @@ -50,8 +52,12 @@ API-eindpunten: - `GET /api/modules/` — alle modules met status. - `POST /api/modules//state/` — body `{"enabled": true|false}`. - `GET /api/info/` — korte systeem-/gezondheidsinfo. -- `GET/POST /api/personen/`, `/api/groepen/`, `/api/subgroepen/` — CRUD - (plus `PATCH`/`DELETE` op `//`). Personen filteren kan met `?rol=leerling`. +- CRUD-endpoints (telkens met `PATCH`/`DELETE` op `//`): + `/api/personen/` (filter `?rol=leerling`), `/api/groepen/`, + `/api/subgroepen/`, `/api/functies/`, `/api/schooljaren/`, + `/api/kalenderdagen/` (filter `?schooljaar=`), `/api/tijdsloten/`, + `/api/activiteiten/`, `/api/locaties/`, `/api/roosterblokken/` + (filters `?schooljaar=`, `?groep=`, `?subgroep=`, `?leerling=`). - `/admin/` — Django-admin (personen, groepen, subgroepen en modulestatus). > Draait SQLite niet op je projectmap (bv. op een netwerkschijf)? Zet dan @@ -69,8 +75,8 @@ De dev-server stuurt `/api` automatisch door naar de backend op poort 8000. Open daarna http://localhost:5173 en ga naar **Modulebeheer** om modules aan/uit te zetten. -## Volgende stap (Fase 2) +## Volgende stap (Fase 3 & 4) -Schooljaar & roostering: een jaarkalender (studiedagen, vrije dagen, vakanties) -en roosterblokken die je toewijst aan een groep, subgroep of individuele -leerling, met begeleiders en locatie. +Flexibiliteit & uitzonderingen (individuele afwijkingen, afwezigheid/vervanging, +vrije dagen die blokken automatisch uitschakelen) en conflictdetectie +(dubbele inzet van personen/leerlingen, locatiebotsingen). diff --git a/SECURITY-AUDIT.md b/SECURITY-AUDIT.md new file mode 100644 index 0000000..94d440b --- /dev/null +++ b/SECURITY-AUDIT.md @@ -0,0 +1,138 @@ +# Security & Bug Audit — Roosterwijs + +Date: 2026-06-09 · Scope: backend (Django), frontend (React), Docker/nginx deployment. +Verdict: code quality is good and the architecture is clean, but the system is **not safe to expose on a network yet**. One critical gap (no authentication) and two high-risk items must be fixed before the test server holds real student data. + +> **STATUS 2026-06-10: all findings below are FIXED** (verified with 13 functional tests + frontend build). Remaining for you: (1) run `npm install` once in `frontend/` and commit `package-lock.json` so Docker switches to `npm ci`; (2) set `DJANGO_SECURE=1` in `.env` once the server runs behind HTTPS. Note #2 (TLS itself) still requires a certificate/reverse proxy on the server. + +--- + +## CRITICAL + +### 1. The entire API is unauthenticated +`REST_FRAMEWORK` has no `DEFAULT_PERMISSION_CLASSES`, so DRF defaults to `AllowAny`. Anyone who can reach port 8080 can, anonymously: + +- read, create, edit and **delete all persons** — including student names and the free-text `opmerkingen` field (sensitive special-education data → AVG/GDPR); +- delete groups and subgroups; +- enable/disable modules via `POST /api/modules//state/`. + +**Fix:** + +```python +# settings.py +REST_FRAMEWORK = { + "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework.authentication.SessionAuthentication", + ], + ... +} +``` + +Then add a login flow to the React app and send the CSRF token (see Low #11 — these must land together, otherwise all writes break). This naturally belongs to the `accounts` module, but a minimal session login must protect the API **before** that module is fully built — security cannot be an optional plugin. + +--- + +## HIGH + +### 2. No TLS anywhere; admin login travels in plain text +`docker-compose.yml` exposes plain HTTP on 8080 and `.env.example` shows `http://` CSRF origins. Django admin passwords and all student data cross the network unencrypted. + +**Fix:** terminate TLS (reverse proxy or certs in nginx), then enable in `settings.py`, driven by an env flag: + +```python +if _env_bool("DJANGO_SECURE", not DEBUG): + SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + SECURE_SSL_REDIRECT = True + SESSION_COOKIE_SECURE = True + CSRF_COOKIE_SECURE = True + SECURE_HSTS_SECONDS = 31536000 +``` + +### 3. SECRET_KEY silently falls back to a known dev key +If `DJANGO_SECRET_KEY` is missing in production, the app runs with `"dev-only-change-me-in-productie"` — sessions and password-reset tokens become forgeable, with no warning. + +**Fix:** fail hard: + +```python +SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "") +if not SECRET_KEY: + if _env_bool("DJANGO_DEBUG", False): + SECRET_KEY = "dev-only-change-me-in-productie" + else: + raise RuntimeError("DJANGO_SECRET_KEY ontbreekt; weiger te starten zonder geheime sleutel.") +``` + +(Note: `DEBUG` is read after this block today — reorder accordingly.) + +--- + +## MEDIUM + +### 4. Browsable API enabled in production +`BrowsableAPIRenderer` gives visitors a friendly UI to explore and submit writes. Gate it: include it in `DEFAULT_RENDERER_CLASSES` only when `DEBUG` is true. + +### 5. Bug — module toggle truthiness +`plugins/views.py`: `enabled = bool(request.data.get("enabled", False))`. A form-encoded or string payload `"false"` becomes `True`. Parse explicitly: + +```python +enabled = request.data.get("enabled") +if not isinstance(enabled, bool): + return Response({"detail": "enabled moet true of false zijn."}, status=400) +``` + +### 6. Bug — role change leaves stale subgroup membership +`Persoon.save()` clears `groep` when the rol is no longer *leerling*, but M2M `subgroepen` membership stays (`limit_choices_to` only affects admin forms, not the API). A teacher can remain "lid" of a niveaugroepje. + +**Fix:** in `Persoon.save()` (after `super().save()`): `if self.rol != Rol.LEERLING: self.subgroepen.clear()` — or validate in `PersoonSerializer.update()`. + +### 7. No rate limiting +No DRF throttling and no nginx `limit_req`. Add `DEFAULT_THROTTLE_CLASSES`/`RATES` (e.g. `"anon": "20/min"`, `"user": "200/min"`) — cheap insurance once the API is internet-reachable. + +### 8. Backend container runs as root +Add to the Dockerfile after `COPY . .`: + +```dockerfile +RUN useradd --create-home appuser && chown -R appuser /app +USER appuser +``` + +(Static volume permissions: run `collectstatic` before switching user, or chown the volume in entrypoint.) + +--- + +## LOW + +### 9. Race in `sync_states()` +Three gunicorn workers can pass the `key not in existing` check simultaneously → `IntegrityError` on the unique key. Use `ModuleState.objects.get_or_create(key=spec.key, defaults={"enabled": spec.default_enabled})`. + +### 10. Missing security headers on the SPA +Django adds `X-Frame-Options`/`nosniff` only to `/api` and `/admin`. Add to the nginx `location /` block: `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: same-origin`, and later a CSP. + +### 11. Frontend sends no CSRF token +`api.js` never sends `X-CSRFToken`. Harmless today (anonymous = no CSRF check), but the moment Fix #1 lands, every POST/PATCH/DELETE will 403. Read the `csrftoken` cookie and set the header in `request()`. Ship together with #1. + +### 12. `npm install` in Docker build +Use `npm ci` with a committed `package-lock.json` for reproducible, tamper-evident builds. + +### 13. No logging / audit trail +No `LOGGING` config; with student data you want a record of logins and destructive actions. Minimal console logging config now; an audit-log module fits the plugin architecture later. + +--- + +## Verified non-issues + +- `.gitignore` and `backend/.dockerignore` correctly exclude `.env`, `db.sqlite3` (image and repo stay clean). +- CORS is locked to localhost dev origins; production traffic is same-origin via nginx — correct. +- `ALLOWED_HOSTS` from env with safe defaults; nginx forwards `Host` so host-header spoofing is caught. +- Dependency-aware module enable/disable logic in `plugins/services.py` is sound (checked both directions). +- Postgres is not port-mapped to the host; only nginx is exposed. +- Strong password validators active; `DEBUG` defaults to off. + +## Recommended order of work + +1. #1 + #11 together (auth + CSRF in frontend) — blocks everything else being meaningful. +2. #3 (fail hard on missing SECRET_KEY) — one-file change, do immediately. +3. #2 (TLS + secure cookies) when the test server gets a hostname. +4. #5, #6, #9 (bugs) — small, independent fixes. +5. #4, #7, #8, #10, #12, #13 as hardening follow-ups. diff --git a/backend/Dockerfile b/backend/Dockerfile index a60e76e..1b72d03 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -21,6 +21,13 @@ COPY . . # entrypoint wacht op de database, migreert en verzamelt statics. RUN chmod +x /app/entrypoint.sh +# Draai niet als root: een eventueel lek in de app geeft dan geen +# root-rechten in de container. +RUN useradd --create-home --shell /usr/sbin/nologin appuser \ + && mkdir -p /app/staticfiles \ + && chown -R appuser:appuser /app +USER appuser + EXPOSE 8000 ENTRYPOINT ["/app/entrypoint.sh"] # 3 workers is ruim voldoende voor de kleine aantallen in het SO. diff --git a/backend/config/settings.py b/backend/config/settings.py index 51cd185..b61b793 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -19,12 +19,23 @@ def _env_bool(name, default): return os.environ.get(name, str(default)).strip().lower() in ("1", "true", "yes", "on") -# Dev-default alleen voor lokaal werken; ZET DJANGO_SECRET_KEY in productie. -SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "dev-only-change-me-in-productie") - # Standaard False (veilig). Lokaal zet je DJANGO_DEBUG=1. DEBUG = _env_bool("DJANGO_DEBUG", False) +# Geheime sleutel: VERPLICHT via omgeving zodra DEBUG uit staat. +# Een stille terugval op een bekende dev-sleutel zou sessies en tokens +# vervalsbaar maken; daarom weigert de app dan te starten. +SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "") +if not SECRET_KEY: + if DEBUG: + SECRET_KEY = "dev-only-change-me-in-productie" + else: + raise RuntimeError( + "DJANGO_SECRET_KEY ontbreekt. Genereer er een met " + "'python -c \"import secrets; print(secrets.token_urlsafe(50))\"' " + "en zet hem in het .env-bestand." + ) + # Komma-gescheiden lijst hosts, bv. "rooster-test.example.nl,localhost". ALLOWED_HOSTS = [h.strip() for h in os.environ.get( "DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1" @@ -139,11 +150,33 @@ STATIC_ROOT = os.environ.get("DJANGO_STATIC_ROOT", str(BASE_DIR / "staticfiles") DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" # --- API ------------------------------------------------------------------ +# Veiligheid eerst: alles vereist standaard een ingelogde gebruiker. +# Uitzonderingen (zoals het login-endpoint) krijgen expliciet AllowAny. REST_FRAMEWORK = { - "DEFAULT_RENDERER_CLASSES": [ - "rest_framework.renderers.JSONRenderer", - "rest_framework.renderers.BrowsableAPIRenderer", + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.IsAuthenticated", ], + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework.authentication.SessionAuthentication", + ], + # JSON altijd; de browsbare API alleen tijdens ontwikkelen. + "DEFAULT_RENDERER_CLASSES": ( + [ + "rest_framework.renderers.JSONRenderer", + "rest_framework.renderers.BrowsableAPIRenderer", + ] + if DEBUG + else ["rest_framework.renderers.JSONRenderer"] + ), + # Eenvoudige rem op misbruik (bv. wachtwoord-raden op het login-endpoint). + "DEFAULT_THROTTLE_CLASSES": [ + "rest_framework.throttling.AnonRateThrottle", + "rest_framework.throttling.UserRateThrottle", + ], + "DEFAULT_THROTTLE_RATES": { + "anon": "20/min", + "user": "300/min", + }, } # Frontend (React dev-server) mag de API benaderen. @@ -151,3 +184,39 @@ CORS_ALLOWED_ORIGINS = [ "http://localhost:5173", "http://127.0.0.1:5173", ] +CORS_ALLOW_CREDENTIALS = True + +# --- HTTPS-hardening -------------------------------------------------------- +# Zet DJANGO_SECURE=1 zodra de server achter TLS draait (zie DEPLOY.md). +# Staat los van DEBUG omdat de test-server nu nog op http://...:8080 draait. +if _env_bool("DJANGO_SECURE", False): + SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + SECURE_SSL_REDIRECT = True + SESSION_COOKIE_SECURE = True + CSRF_COOKIE_SECURE = True + SECURE_HSTS_SECONDS = 60 * 60 * 24 * 365 + SECURE_HSTS_INCLUDE_SUBDOMAINS = True + +# Sessie-cookies nooit leesbaar voor JavaScript; de CSRF-cookie moet juist +# wél leesbaar zijn (de frontend stuurt hem terug in de X-CSRFToken-header). +SESSION_COOKIE_HTTPONLY = True +CSRF_COOKIE_HTTPONLY = False + +# --- Logging ---------------------------------------------------------------- +# Console-logging zodat 'docker compose logs' een bruikbaar spoor geeft, +# o.a. van mislukte logins en serverfouten. +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "simple": {"format": "{levelname} {asctime} {name} {message}", "style": "{"}, + }, + "handlers": { + "console": {"class": "logging.StreamHandler", "formatter": "simple"}, + }, + "root": {"handlers": ["console"], "level": "INFO"}, + "loggers": { + "django.request": {"level": "WARNING"}, + "roosterwijs": {"level": "INFO"}, + }, +} diff --git a/backend/core/admin.py b/backend/core/admin.py index 207bea1..86b00b1 100644 --- a/backend/core/admin.py +++ b/backend/core/admin.py @@ -1,6 +1,16 @@ from django.contrib import admin -from .models import Groep, Persoon, Subgroep +from .models import ( + Activiteit, Functie, Groep, KalenderDag, Locatie, Persoon, + Roosterblok, Schooljaar, Subgroep, Tijdslot, +) + + +@admin.register(Functie) +class FunctieAdmin(admin.ModelAdmin): + list_display = ("naam", "omschrijving", "actief") + list_filter = ("actief",) + search_fields = ("naam",) @admin.register(Groep) @@ -13,9 +23,10 @@ class GroepAdmin(admin.ModelAdmin): @admin.register(Persoon) class PersoonAdmin(admin.ModelAdmin): list_display = ("achternaam", "voornaam", "rol", "groep", "actief") - list_filter = ("rol", "actief", "groep") + list_filter = ("rol", "actief", "groep", "functies") search_fields = ("voornaam", "achternaam") autocomplete_fields = ("groep",) + filter_horizontal = ("functies",) @admin.register(Subgroep) @@ -24,3 +35,44 @@ class SubgroepAdmin(admin.ModelAdmin): list_filter = ("actief",) search_fields = ("naam",) filter_horizontal = ("leden",) + + +@admin.register(Schooljaar) +class SchooljaarAdmin(admin.ModelAdmin): + list_display = ("naam", "start_datum", "eind_datum", "actief") + list_filter = ("actief",) + search_fields = ("naam",) + + +@admin.register(KalenderDag) +class KalenderDagAdmin(admin.ModelAdmin): + list_display = ("datum", "type", "omschrijving", "schooljaar") + list_filter = ("type", "schooljaar") + date_hierarchy = "datum" + + +@admin.register(Tijdslot) +class TijdslotAdmin(admin.ModelAdmin): + list_display = ("naam", "dag", "start_tijd", "eind_tijd", "volgorde", "actief") + list_filter = ("dag", "actief") + + +@admin.register(Activiteit) +class ActiviteitAdmin(admin.ModelAdmin): + list_display = ("naam", "kleur", "actief") + list_filter = ("actief",) + search_fields = ("naam",) + + +@admin.register(Locatie) +class LocatieAdmin(admin.ModelAdmin): + list_display = ("naam", "omschrijving", "actief") + list_filter = ("actief",) + search_fields = ("naam",) + + +@admin.register(Roosterblok) +class RoosterblokAdmin(admin.ModelAdmin): + list_display = ("activiteit", "tijdslot", "doel_type", "locatie", "schooljaar") + list_filter = ("schooljaar", "tijdslot__dag", "activiteit") + filter_horizontal = ("begeleiders",) diff --git a/backend/core/apps.py b/backend/core/apps.py index d3af019..c88987e 100644 --- a/backend/core/apps.py +++ b/backend/core/apps.py @@ -29,8 +29,13 @@ class CoreConfig(AppConfig): menu_items=( MenuItem("Rooster", "/rooster", icon="calendar", order=10), MenuItem("Personen", "/personen", icon="users", order=20), + MenuItem("Functies", "/functies", icon="badge", order=25), MenuItem("Groepen", "/groepen", icon="layers", order=30), MenuItem("Subgroepen", "/subgroepen", icon="layers", order=40), + MenuItem("Activiteiten", "/activiteiten", icon="book", order=50), + MenuItem("Locaties", "/locaties", icon="pin", order=55), + MenuItem("Tijdsloten", "/tijdsloten", icon="clock", order=60), + MenuItem("Schooljaar", "/schooljaar", icon="calendar", order=70), ), ) ) diff --git a/backend/core/migrations/0002_activiteit_functie_locatie_schooljaar_tijdslot_and_more.py b/backend/core/migrations/0002_activiteit_functie_locatie_schooljaar_tijdslot_and_more.py new file mode 100644 index 0000000..edcfc7c --- /dev/null +++ b/backend/core/migrations/0002_activiteit_functie_locatie_schooljaar_tijdslot_and_more.py @@ -0,0 +1,132 @@ +# Generated by Django 5.2.15 on 2026-06-10 12:46 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Activiteit', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('naam', models.CharField(max_length=80, unique=True, verbose_name='naam')), + ('kleur', models.CharField(default='#2563eb', help_text='hex, bv. #2563eb', max_length=7, verbose_name='kleur')), + ('omschrijving', models.CharField(blank=True, max_length=255, verbose_name='omschrijving')), + ('actief', models.BooleanField(default=True, verbose_name='actief')), + ], + options={ + 'verbose_name': 'activiteit', + 'verbose_name_plural': 'activiteiten', + 'ordering': ['naam'], + }, + ), + migrations.CreateModel( + name='Functie', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('naam', models.CharField(max_length=80, unique=True, verbose_name='naam')), + ('omschrijving', models.CharField(blank=True, max_length=255, verbose_name='omschrijving')), + ('actief', models.BooleanField(default=True, verbose_name='actief')), + ('aangemaakt_op', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'verbose_name': 'functie', + 'verbose_name_plural': 'functies', + 'ordering': ['naam'], + }, + ), + migrations.CreateModel( + name='Locatie', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('naam', models.CharField(max_length=80, unique=True, verbose_name='naam')), + ('omschrijving', models.CharField(blank=True, max_length=255, verbose_name='omschrijving')), + ('actief', models.BooleanField(default=True, verbose_name='actief')), + ], + options={ + 'verbose_name': 'locatie', + 'verbose_name_plural': 'locaties', + 'ordering': ['naam'], + }, + ), + migrations.CreateModel( + name='Schooljaar', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('naam', models.CharField(help_text='bv. 2026-2027', max_length=40, unique=True, verbose_name='naam')), + ('start_datum', models.DateField(verbose_name='startdatum')), + ('eind_datum', models.DateField(verbose_name='einddatum')), + ('actief', models.BooleanField(default=True, verbose_name='actief')), + ], + options={ + 'verbose_name': 'schooljaar', + 'verbose_name_plural': 'schooljaren', + 'ordering': ['-start_datum'], + }, + ), + migrations.CreateModel( + name='Tijdslot', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('naam', models.CharField(max_length=60, verbose_name='naam')), + ('dag', models.IntegerField(choices=[(0, 'Maandag'), (1, 'Dinsdag'), (2, 'Woensdag'), (3, 'Donderdag'), (4, 'Vrijdag')], verbose_name='weekdag')), + ('start_tijd', models.TimeField(verbose_name='starttijd')), + ('eind_tijd', models.TimeField(verbose_name='eindtijd')), + ('volgorde', models.PositiveSmallIntegerField(default=0, verbose_name='volgorde')), + ('actief', models.BooleanField(default=True, verbose_name='actief')), + ], + options={ + 'verbose_name': 'tijdslot', + 'verbose_name_plural': 'tijdsloten', + 'ordering': ['dag', 'volgorde', 'start_tijd'], + }, + ), + migrations.AddField( + model_name='persoon', + name='functies', + field=models.ManyToManyField(blank=True, help_text='Alleen van toepassing op medewerkers.', related_name='personen', to='core.functie', verbose_name='functies'), + ), + migrations.CreateModel( + name='Roosterblok', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('opmerkingen', models.CharField(blank=True, max_length=255, verbose_name='opmerkingen')), + ('aangemaakt_op', models.DateTimeField(auto_now_add=True)), + ('activiteit', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='roosterblokken', to='core.activiteit')), + ('begeleiders', models.ManyToManyField(blank=True, related_name='begeleider_roosterblokken', to='core.persoon')), + ('groep', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='roosterblokken', to='core.groep')), + ('leerling', models.ForeignKey(blank=True, limit_choices_to={'rol': 'leerling'}, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='leerling_roosterblokken', to='core.persoon')), + ('locatie', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='roosterblokken', to='core.locatie')), + ('subgroep', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='roosterblokken', to='core.subgroep')), + ('schooljaar', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='roosterblokken', to='core.schooljaar')), + ('tijdslot', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='roosterblokken', to='core.tijdslot')), + ], + options={ + 'verbose_name': 'roosterblok', + 'verbose_name_plural': 'roosterblokken', + 'ordering': ['tijdslot__dag', 'tijdslot__volgorde'], + }, + ), + migrations.CreateModel( + name='KalenderDag', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('datum', models.DateField(verbose_name='datum')), + ('type', models.CharField(choices=[('studiedag', 'Studiedag'), ('vrije_dag', 'Vrije dag'), ('vakantie', 'Vakantie')], max_length=16, verbose_name='type')), + ('omschrijving', models.CharField(blank=True, max_length=120, verbose_name='omschrijving')), + ('schooljaar', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='kalenderdagen', to='core.schooljaar')), + ], + options={ + 'verbose_name': 'kalenderdag', + 'verbose_name_plural': 'kalenderdagen', + 'ordering': ['datum'], + 'constraints': [models.UniqueConstraint(fields=('schooljaar', 'datum'), name='uniek_schooljaar_datum')], + }, + ), + ] diff --git a/backend/core/models.py b/backend/core/models.py index 7056c11..47f4e89 100644 --- a/backend/core/models.py +++ b/backend/core/models.py @@ -1,13 +1,17 @@ """ -Kerndomein van Roosterwijs (Fase 1). +Kerndomein van Roosterwijs. -Bewust klein gehouden, met veel flexibiliteit voor het speciaal onderwijs: -- één Persoon-tabel met een rol (leerling, leerkracht, ondersteuner); -- leerlingen horen bij een groep; -- subgroepen zijn vrije, groep-overstijgende verzamelingen leerlingen. +Fase 1: Persoon, Groep, Subgroep, Functie (catalogus). +Fase 2: Schooljaar + KalenderDag (jaaroverzicht), Tijdslot, Activiteit, + Locatie en Roosterblok (de spil van het rooster). -Roosterblokken, tijdslots en de kalender volgen in Fase 2. +Veel flexibiliteit voor het speciaal onderwijs: +- één Persoon-tabel met een rol (leerling of medewerker-rol); +- medewerkers kunnen meerdere FUNCTIES hebben uit een beheerbare catalogus; +- roosterblokken zijn toewijsbaar aan een groep, subgroep OF een individuele + leerling, met één of meer begeleiders. """ +from django.core.exceptions import ValidationError from django.db import models @@ -18,6 +22,27 @@ class Rol(models.TextChoices): KLASSENONDERSTEUNER = "klassenondersteuner", "Klassenondersteuner" +class Functie(models.Model): + """Een beheerbare functie/rol voor medewerkers (bv. Directie, Logopedist). + + Dit is een vrije catalogus: een beheerder maakt functies aan en verwijdert + ze. Een medewerker kan meerdere functies hebben (zie Persoon.functies). + """ + + naam = models.CharField("naam", max_length=80, unique=True) + omschrijving = models.CharField("omschrijving", max_length=255, blank=True) + actief = models.BooleanField("actief", default=True) + aangemaakt_op = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = "functie" + verbose_name_plural = "functies" + ordering = ["naam"] + + def __str__(self) -> str: + return self.naam + + class Groep(models.Model): """Een klas/groep waar leerlingen bij horen.""" @@ -36,7 +61,11 @@ class Groep(models.Model): class Persoon(models.Model): - """Leerling, leerkracht of ondersteuner - een tabel met een rol.""" + """Leerling of medewerker - een tabel met een rol. + + Leerlingen horen bij een groep. Medewerkers (niet-leerlingen) kunnen + meerdere functies hebben uit de Functie-catalogus. + """ voornaam = models.CharField("voornaam", max_length=80) achternaam = models.CharField("achternaam", max_length=120) @@ -50,6 +79,13 @@ class Persoon(models.Model): related_name="leerlingen", help_text="Alleen van toepassing op leerlingen.", ) + functies = models.ManyToManyField( + Functie, + verbose_name="functies", + related_name="personen", + blank=True, + help_text="Alleen van toepassing op medewerkers.", + ) actief = models.BooleanField("actief", default=True) opmerkingen = models.TextField("opmerkingen", blank=True) aangemaakt_op = models.DateTimeField(auto_now_add=True) @@ -75,6 +111,14 @@ class Persoon(models.Model): if self.rol != Rol.LEERLING: self.groep = None super().save(*args, **kwargs) + if self.pk: + # Wie geen leerling (meer) is, hoort niet in subgroepen thuis. + # (limit_choices_to geldt alleen voor formulieren, niet voor de API.) + if self.rol != Rol.LEERLING: + self.subgroepen.clear() + else: + # Een leerling heeft geen medewerkersfuncties. + self.functies.clear() class Subgroep(models.Model): @@ -102,3 +146,191 @@ class Subgroep(models.Model): def __str__(self) -> str: return self.naam + + +# -------------------------------------------------------------------------- +# Fase 2: schooljaar/kalender en roosterblokken +# -------------------------------------------------------------------------- + +class Schooljaar(models.Model): + """Een schooljaar met begin- en einddatum; bevat de kalender.""" + + naam = models.CharField("naam", max_length=40, unique=True, help_text="bv. 2026-2027") + start_datum = models.DateField("startdatum") + eind_datum = models.DateField("einddatum") + actief = models.BooleanField("actief", default=True) + + class Meta: + verbose_name = "schooljaar" + verbose_name_plural = "schooljaren" + ordering = ["-start_datum"] + + def __str__(self) -> str: + return self.naam + + +class DagType(models.TextChoices): + STUDIEDAG = "studiedag", "Studiedag" + VRIJE_DAG = "vrije_dag", "Vrije dag" + VAKANTIE = "vakantie", "Vakantie" + + +class KalenderDag(models.Model): + """Een bijzondere dag in het schooljaar: studiedag, vrije dag of vakantie. + + Op zulke dagen vervallen de roosterblokken (geen les). + """ + + schooljaar = models.ForeignKey( + Schooljaar, on_delete=models.CASCADE, related_name="kalenderdagen" + ) + datum = models.DateField("datum") + type = models.CharField("type", max_length=16, choices=DagType.choices) + omschrijving = models.CharField("omschrijving", max_length=120, blank=True) + + class Meta: + verbose_name = "kalenderdag" + verbose_name_plural = "kalenderdagen" + ordering = ["datum"] + constraints = [ + models.UniqueConstraint( + fields=["schooljaar", "datum"], name="uniek_schooljaar_datum" + ) + ] + + def __str__(self) -> str: + return f"{self.datum} ({self.get_type_display()})" + + @property + def is_lesdag(self) -> bool: + # Alle drie de types betekenen 'geen les'. + return False + + +class Weekdag(models.IntegerChoices): + MAANDAG = 0, "Maandag" + DINSDAG = 1, "Dinsdag" + WOENSDAG = 2, "Woensdag" + DONDERDAG = 3, "Donderdag" + VRIJDAG = 4, "Vrijdag" + + +class Tijdslot(models.Model): + """Een vast tijdblok op een vaste schooldag (bv. 'Blok 1' ma 08:30-10:00).""" + + naam = models.CharField("naam", max_length=60) + dag = models.IntegerField("weekdag", choices=Weekdag.choices) + start_tijd = models.TimeField("starttijd") + eind_tijd = models.TimeField("eindtijd") + volgorde = models.PositiveSmallIntegerField("volgorde", default=0) + actief = models.BooleanField("actief", default=True) + + class Meta: + verbose_name = "tijdslot" + verbose_name_plural = "tijdsloten" + ordering = ["dag", "volgorde", "start_tijd"] + + def __str__(self) -> str: + return f"{self.get_dag_display()} {self.start_tijd:%H:%M}-{self.eind_tijd:%H:%M} {self.naam}" + + +class Activiteit(models.Model): + """Wat er gedaan wordt: rekenen, gym, therapie, pauze ...""" + + naam = models.CharField("naam", max_length=80, unique=True) + kleur = models.CharField("kleur", max_length=7, default="#2563eb", help_text="hex, bv. #2563eb") + omschrijving = models.CharField("omschrijving", max_length=255, blank=True) + actief = models.BooleanField("actief", default=True) + + class Meta: + verbose_name = "activiteit" + verbose_name_plural = "activiteiten" + ordering = ["naam"] + + def __str__(self) -> str: + return self.naam + + +class Locatie(models.Model): + """Een lokaal/ruimte (handig voor conflictdetectie in fase 4).""" + + naam = models.CharField("naam", max_length=80, unique=True) + omschrijving = models.CharField("omschrijving", max_length=255, blank=True) + actief = models.BooleanField("actief", default=True) + + class Meta: + verbose_name = "locatie" + verbose_name_plural = "locaties" + ordering = ["naam"] + + def __str__(self) -> str: + return self.naam + + +class Roosterblok(models.Model): + """De spil: wie doet wat, wanneer, waar, met wie. + + De doelgroep is precies één van: groep, subgroep of individuele leerling. + Zo kun je een groepsrooster maken én er individueel van afwijken. + """ + + schooljaar = models.ForeignKey( + Schooljaar, on_delete=models.CASCADE, related_name="roosterblokken" + ) + tijdslot = models.ForeignKey( + Tijdslot, on_delete=models.PROTECT, related_name="roosterblokken" + ) + activiteit = models.ForeignKey( + Activiteit, on_delete=models.PROTECT, related_name="roosterblokken" + ) + locatie = models.ForeignKey( + Locatie, on_delete=models.SET_NULL, null=True, blank=True, + related_name="roosterblokken", + ) + + # Doelgroep: precies één van de drie is gevuld. + groep = models.ForeignKey( + Groep, on_delete=models.CASCADE, null=True, blank=True, + related_name="roosterblokken", + ) + subgroep = models.ForeignKey( + Subgroep, on_delete=models.CASCADE, null=True, blank=True, + related_name="roosterblokken", + ) + leerling = models.ForeignKey( + Persoon, on_delete=models.CASCADE, null=True, blank=True, + related_name="leerling_roosterblokken", + limit_choices_to={"rol": Rol.LEERLING}, + ) + + begeleiders = models.ManyToManyField( + Persoon, related_name="begeleider_roosterblokken", blank=True, + ) + opmerkingen = models.CharField("opmerkingen", max_length=255, blank=True) + aangemaakt_op = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = "roosterblok" + verbose_name_plural = "roosterblokken" + ordering = ["tijdslot__dag", "tijdslot__volgorde"] + + def clean(self): + doelen = [self.groep_id, self.subgroep_id, self.leerling_id] + gevuld = [d for d in doelen if d] + if len(gevuld) != 1: + raise ValidationError( + "Kies precies één doelgroep: groep, subgroep of leerling." + ) + + @property + def doel_type(self) -> str: + if self.groep_id: + return "groep" + if self.subgroep_id: + return "subgroep" + if self.leerling_id: + return "leerling" + return "onbekend" + + def __str__(self) -> str: + return f"{self.activiteit} @ {self.tijdslot}" diff --git a/backend/core/serializers.py b/backend/core/serializers.py index 405a1b1..78d00eb 100644 --- a/backend/core/serializers.py +++ b/backend/core/serializers.py @@ -1,42 +1,62 @@ """DRF-serializers voor het kerndomein.""" from rest_framework import serializers -from .models import Groep, Persoon, Subgroep +from .models import ( + Activiteit, Functie, Groep, KalenderDag, Locatie, Persoon, + Roosterblok, Schooljaar, Subgroep, Tijdslot, +) + + +class FunctieSerializer(serializers.ModelSerializer): + aantal_personen = serializers.IntegerField(source="personen.count", read_only=True) + + class Meta: + model = Functie + fields = ["id", "naam", "omschrijving", "actief", "aantal_personen", "aangemaakt_op"] + read_only_fields = ["aangemaakt_op"] class GroepSerializer(serializers.ModelSerializer): - aantal_leerlingen = serializers.IntegerField( - source="leerlingen.count", read_only=True - ) + aantal_leerlingen = serializers.IntegerField(source="leerlingen.count", read_only=True) class Meta: model = Groep - fields = [ - "id", "naam", "omschrijving", "actief", - "aantal_leerlingen", "aangemaakt_op", - ] + fields = ["id", "naam", "omschrijving", "actief", "aantal_leerlingen", "aangemaakt_op"] read_only_fields = ["aangemaakt_op"] class PersoonSerializer(serializers.ModelSerializer): volledige_naam = serializers.CharField(read_only=True) rol_label = serializers.CharField(source="get_rol_display", read_only=True) - groep_naam = serializers.CharField( - source="groep.naam", read_only=True, default=None + groep_naam = serializers.CharField(source="groep.naam", read_only=True, default=None) + functies = serializers.PrimaryKeyRelatedField( + many=True, queryset=Functie.objects.all(), required=False ) + functies_namen = serializers.SerializerMethodField() class Meta: model = Persoon fields = [ "id", "voornaam", "achternaam", "volledige_naam", "rol", "rol_label", "groep", "groep_naam", + "functies", "functies_namen", "actief", "opmerkingen", "aangemaakt_op", ] read_only_fields = ["aangemaakt_op"] + def get_functies_namen(self, obj): + return [{"id": f.id, "naam": f.naam} for f in obj.functies.all()] + + def validate(self, data): + # Functies horen alleen bij medewerkers; een leerling krijgt er geen. + # (De M2M wordt door DRF ná save() gezet, dus hier afdwingen.) + rol = data.get("rol", getattr(self.instance, "rol", None)) + if rol == "leerling": + data["functies"] = [] + return data + class SubgroepSerializer(serializers.ModelSerializer): - # Lezen: namen tonen. Schrijven: lijst met leerling-id's. leden = serializers.PrimaryKeyRelatedField( many=True, queryset=Persoon.objects.filter(rol="leerling"), required=False ) @@ -52,10 +72,88 @@ class SubgroepSerializer(serializers.ModelSerializer): read_only_fields = ["aangemaakt_op"] def get_leden_namen(self, obj): - return [ - {"id": p.id, "naam": p.volledige_naam} - for p in obj.leden.all() - ] + return [{"id": p.id, "naam": p.volledige_naam} for p in obj.leden.all()] def get_aantal_leden(self, obj): return obj.leden.count() + + +# --- Fase 2 --------------------------------------------------------------- + +class SchooljaarSerializer(serializers.ModelSerializer): + class Meta: + model = Schooljaar + fields = ["id", "naam", "start_datum", "eind_datum", "actief"] + + +class KalenderDagSerializer(serializers.ModelSerializer): + type_label = serializers.CharField(source="get_type_display", read_only=True) + + class Meta: + model = KalenderDag + fields = ["id", "schooljaar", "datum", "type", "type_label", "omschrijving"] + + +class TijdslotSerializer(serializers.ModelSerializer): + dag_label = serializers.CharField(source="get_dag_display", read_only=True) + + class Meta: + model = Tijdslot + fields = ["id", "naam", "dag", "dag_label", "start_tijd", "eind_tijd", "volgorde", "actief"] + + +class ActiviteitSerializer(serializers.ModelSerializer): + class Meta: + model = Activiteit + fields = ["id", "naam", "kleur", "omschrijving", "actief"] + + +class LocatieSerializer(serializers.ModelSerializer): + class Meta: + model = Locatie + fields = ["id", "naam", "omschrijving", "actief"] + + +class RoosterblokSerializer(serializers.ModelSerializer): + activiteit_naam = serializers.CharField(source="activiteit.naam", read_only=True) + activiteit_kleur = serializers.CharField(source="activiteit.kleur", read_only=True) + tijdslot_label = serializers.CharField(source="tijdslot.__str__", read_only=True) + locatie_naam = serializers.CharField(source="locatie.naam", read_only=True, default=None) + doel_type = serializers.CharField(read_only=True) + doel_naam = serializers.SerializerMethodField() + begeleiders_namen = serializers.SerializerMethodField() + + class Meta: + model = Roosterblok + fields = [ + "id", "schooljaar", "tijdslot", "tijdslot_label", + "activiteit", "activiteit_naam", "activiteit_kleur", + "locatie", "locatie_naam", + "groep", "subgroep", "leerling", "doel_type", "doel_naam", + "begeleiders", "begeleiders_namen", "opmerkingen", "aangemaakt_op", + ] + read_only_fields = ["aangemaakt_op"] + + def get_doel_naam(self, obj): + if obj.groep_id: + return obj.groep.naam + if obj.subgroep_id: + return obj.subgroep.naam + if obj.leerling_id: + return obj.leerling.volledige_naam + return None + + def get_begeleiders_namen(self, obj): + return [{"id": p.id, "naam": p.volledige_naam} for p in obj.begeleiders.all()] + + def validate(self, data): + # Precies één doelgroep (groep / subgroep / leerling). + groep = data.get("groep", getattr(self.instance, "groep", None)) + subgroep = data.get("subgroep", getattr(self.instance, "subgroep", None)) + leerling = data.get("leerling", getattr(self.instance, "leerling", None)) + gevuld = [x for x in (groep, subgroep, leerling) if x] + if len(gevuld) != 1: + raise serializers.ValidationError( + "Kies precies één doelgroep: groep, subgroep of leerling." + ) + return data diff --git a/backend/core/urls.py b/backend/core/urls.py index 57fe8d9..9775d1e 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -7,8 +7,19 @@ router = DefaultRouter() router.register("personen", views.PersoonViewSet, basename="persoon") router.register("groepen", views.GroepViewSet, basename="groep") router.register("subgroepen", views.SubgroepViewSet, basename="subgroep") +router.register("functies", views.FunctieViewSet, basename="functie") +router.register("schooljaren", views.SchooljaarViewSet, basename="schooljaar") +router.register("kalenderdagen", views.KalenderDagViewSet, basename="kalenderdag") +router.register("tijdsloten", views.TijdslotViewSet, basename="tijdslot") +router.register("activiteiten", views.ActiviteitViewSet, basename="activiteit") +router.register("locaties", views.LocatieViewSet, basename="locatie") +router.register("roosterblokken", views.RoosterblokViewSet, basename="roosterblok") urlpatterns = [ + path("health/", views.health, name="health"), path("info/", views.info, name="info"), + path("auth/me/", views.auth_me, name="auth-me"), + path("auth/login/", views.auth_login_view, name="auth-login"), + path("auth/logout/", views.auth_logout_view, name="auth-logout"), path("", include(router.urls)), ] diff --git a/backend/core/views.py b/backend/core/views.py index c0e10f4..4a57da9 100644 --- a/backend/core/views.py +++ b/backend/core/views.py @@ -1,16 +1,38 @@ -from rest_framework import viewsets -from rest_framework.decorators import api_view +import logging + +from django.contrib.auth import authenticate, login as auth_login, logout as auth_logout +from django.views.decorators.csrf import ensure_csrf_cookie +from rest_framework import status, viewsets +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import AllowAny from rest_framework.response import Response from plugins import services -from .models import Groep, Persoon, Subgroep -from .serializers import GroepSerializer, PersoonSerializer, SubgroepSerializer +from .models import ( + Activiteit, Functie, Groep, KalenderDag, Locatie, Persoon, + Roosterblok, Schooljaar, Subgroep, Tijdslot, +) +from .serializers import ( + ActiviteitSerializer, FunctieSerializer, GroepSerializer, + KalenderDagSerializer, LocatieSerializer, PersoonSerializer, + RoosterblokSerializer, SchooljaarSerializer, SubgroepSerializer, + TijdslotSerializer, +) + +logger = logging.getLogger("roosterwijs.auth") + + +@api_view(["GET"]) +@permission_classes([AllowAny]) +def health(request): + """Anonieme gezondheidscheck: bewust zonder inhoudelijke informatie.""" + return Response({"naam": "Roosterwijs", "status": "ok"}) @api_view(["GET"]) def info(request): - """Korte systeeminfo + welke modules actief zijn. Handig als gezondheidscheck.""" + """Systeeminfo + welke modules actief zijn (alleen voor ingelogde gebruikers).""" services.sync_states() modules = services.describe_all() return Response( @@ -23,6 +45,57 @@ def info(request): ) +# --- Authenticatie ---------------------------------------------------------- + +def _user_payload(user): + return { + "authenticated": True, + "username": user.get_username(), + "is_staff": user.is_staff, + } + + +@api_view(["GET"]) +@permission_classes([AllowAny]) +@ensure_csrf_cookie +def auth_me(request): + """Wie ben ik? Zet tegelijk de CSRF-cookie voor de frontend.""" + if request.user.is_authenticated: + return Response(_user_payload(request.user)) + return Response({"authenticated": False}) + + +@api_view(["POST"]) +@permission_classes([AllowAny]) +def auth_login_view(request): + """Inloggen met gebruikersnaam + wachtwoord (sessie-cookie).""" + username = str(request.data.get("username", "")).strip() + password = str(request.data.get("password", "")) + if not username or not password: + return Response( + {"detail": "Vul gebruikersnaam en wachtwoord in."}, + status=status.HTTP_400_BAD_REQUEST, + ) + user = authenticate(request, username=username, password=password) + if user is None: + logger.warning("Mislukte inlogpoging voor gebruiker %r", username) + return Response( + {"detail": "Onjuiste gebruikersnaam of wachtwoord."}, + status=status.HTTP_401_UNAUTHORIZED, + ) + auth_login(request, user) + logger.info("Gebruiker %r ingelogd", username) + return Response(_user_payload(user)) + + +@api_view(["POST"]) +def auth_logout_view(request): + """Uitloggen: sessie beëindigen.""" + logger.info("Gebruiker %r uitgelogd", request.user.get_username()) + auth_logout(request) + return Response({"authenticated": False}) + + class GroepViewSet(viewsets.ModelViewSet): queryset = Groep.objects.all() serializer_class = GroepSerializer @@ -32,7 +105,7 @@ class PersoonViewSet(viewsets.ModelViewSet): serializer_class = PersoonSerializer def get_queryset(self): - qs = Persoon.objects.select_related("groep").all() + qs = Persoon.objects.select_related("groep").prefetch_related("functies").all() rol = self.request.query_params.get("rol") if rol: qs = qs.filter(rol=rol) @@ -42,3 +115,58 @@ class PersoonViewSet(viewsets.ModelViewSet): class SubgroepViewSet(viewsets.ModelViewSet): queryset = Subgroep.objects.prefetch_related("leden").all() serializer_class = SubgroepSerializer + + +class FunctieViewSet(viewsets.ModelViewSet): + queryset = Functie.objects.all() + serializer_class = FunctieSerializer + + +# --- Fase 2 --------------------------------------------------------------- + +class SchooljaarViewSet(viewsets.ModelViewSet): + queryset = Schooljaar.objects.all() + serializer_class = SchooljaarSerializer + + +class KalenderDagViewSet(viewsets.ModelViewSet): + serializer_class = KalenderDagSerializer + + def get_queryset(self): + qs = KalenderDag.objects.all() + schooljaar = self.request.query_params.get("schooljaar") + if schooljaar: + qs = qs.filter(schooljaar_id=schooljaar) + return qs + + +class TijdslotViewSet(viewsets.ModelViewSet): + queryset = Tijdslot.objects.all() + serializer_class = TijdslotSerializer + + +class ActiviteitViewSet(viewsets.ModelViewSet): + queryset = Activiteit.objects.all() + serializer_class = ActiviteitSerializer + + +class LocatieViewSet(viewsets.ModelViewSet): + queryset = Locatie.objects.all() + serializer_class = LocatieSerializer + + +class RoosterblokViewSet(viewsets.ModelViewSet): + serializer_class = RoosterblokSerializer + + def get_queryset(self): + qs = ( + Roosterblok.objects + .select_related("tijdslot", "activiteit", "locatie", "groep", "subgroep", "leerling") + .prefetch_related("begeleiders") + .all() + ) + for veld in ("schooljaar", "groep", "subgroep", "leerling"): + waarde = self.request.query_params.get(veld) + if waarde: + qs = qs.filter(**{f"{veld}_id": waarde}) + return qs diff --git a/backend/plugins/services.py b/backend/plugins/services.py index 7ecb975..5c4bd45 100644 --- a/backend/plugins/services.py +++ b/backend/plugins/services.py @@ -27,13 +27,19 @@ def is_enabled(key: str) -> bool: def sync_states() -> None: - """Zorg dat elke geregistreerde module een rij in de database heeft.""" + """Zorg dat elke geregistreerde module een rij in de database heeft. + + get_or_create is race-bestendig: meerdere gunicorn-workers kunnen dit + tegelijk aanroepen zonder een unique-constraint-fout te veroorzaken. + """ existing = set(ModuleState.objects.values_list("key", flat=True)) for spec in registry.all(): if spec.core: continue if spec.key not in existing: - ModuleState.objects.create(key=spec.key, enabled=spec.default_enabled) + ModuleState.objects.get_or_create( + key=spec.key, defaults={"enabled": spec.default_enabled} + ) def describe_all() -> list[dict]: diff --git a/backend/plugins/views.py b/backend/plugins/views.py index 2fe79a0..3dfd3f3 100644 --- a/backend/plugins/views.py +++ b/backend/plugins/views.py @@ -1,6 +1,7 @@ """API voor het plugin-framework: modules opvragen en aan/uit zetten.""" from rest_framework import status -from rest_framework.decorators import api_view +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAdminUser from rest_framework.response import Response from . import services @@ -14,9 +15,16 @@ def module_list(request): @api_view(["POST"]) +@permission_classes([IsAdminUser]) def module_set_state(request, key: str): - """Zet één module aan of uit. Body: {"enabled": true|false}.""" - enabled = bool(request.data.get("enabled", False)) + """Zet één module aan of uit (alleen beheerders). Body: {"enabled": true|false}.""" + enabled = request.data.get("enabled") + # Expliciet een echte boolean eisen: bool("false") zou True zijn. + if not isinstance(enabled, bool): + return Response( + {"detail": "Veld 'enabled' moet true of false zijn."}, + status=status.HTTP_400_BAD_REQUEST, + ) try: services.set_enabled(key, enabled) except services.ModuleError as exc: diff --git a/docker-compose.yml b/docker-compose.yml index 26ad1f0..188c86c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,7 @@ services: environment: DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY} DJANGO_DEBUG: ${DJANGO_DEBUG:-0} + DJANGO_SECURE: ${DJANGO_SECURE:-0} DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS} DJANGO_CSRF_TRUSTED_ORIGINS: ${DJANGO_CSRF_TRUSTED_ORIGINS} POSTGRES_DB: ${POSTGRES_DB} diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 2a4d9fb..988e764 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -6,7 +6,9 @@ FROM node:20-alpine AS build WORKDIR /app COPY package*.json ./ -RUN npm install +# npm ci (reproduceerbaar) zodra er een package-lock.json in git staat; +# genereer die eenmalig lokaal met `npm install` en commit hem. +RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi COPY . . RUN npm run build diff --git a/frontend/nginx.conf b/frontend/nginx.conf index eb01b4d..070cefe 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -6,6 +6,12 @@ server { # Upload-/postlimiet wat ruimer (bv. importbestanden later). client_max_body_size 20M; + # Beveiligingsheaders voor alles wat nginx zelf serveert (de SPA). + # Django zet vergelijkbare headers al op /api en /admin. + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options DENY always; + add_header Referrer-Policy same-origin always; + # Door Django verzamelde statische bestanden (o.a. admin-CSS). # Deze map komt uit het gedeelde volume 'static_volume'. location /static/ { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 441ad29..771173d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,22 +1,104 @@ import { useEffect, useMemo, useState } from "react"; -import { getModules, setModuleState } from "./api.js"; +import { getMe, login, logout, getModules, setModuleState } from "./api.js"; import Logo from "./Logo.jsx"; -import { GroepenPage, PersonenPage, SubgroepenPage } from "./pages.jsx"; +import { + GroepenPage, PersonenPage, SubgroepenPage, FunctiesPage, + ActiviteitenPage, LocatiesPage, TijdslotenPage, SchooljaarPage, RoosterPage, +} from "./pages.jsx"; const ICONS = { calendar: "📅", users: "👥", layers: "🗂️", printer: "🖨️", - key: "🔑", puzzle: "🧩", + key: "🔑", puzzle: "🧩", badge: "🪪", book: "📚", pin: "📍", clock: "🕘", }; // Koppel een menu-pad aan een echt scherm. Onbekende paden tonen een // placeholder (worden in een latere fase ingevuld). const PAGES = { + "/rooster": RoosterPage, "/personen": PersonenPage, + "/functies": FunctiesPage, "/groepen": GroepenPage, "/subgroepen": SubgroepenPage, + "/activiteiten": ActiviteitenPage, + "/locaties": LocatiesPage, + "/tijdsloten": TijdslotenPage, + "/schooljaar": SchooljaarPage, }; export default function App() { + // null = nog aan het controleren; {authenticated:false} = login tonen. + const [user, setUser] = useState(null); + const [checking, setChecking] = useState(true); + + useEffect(() => { + getMe() + .then(setUser) + .catch(() => setUser({ authenticated: false })) + .finally(() => setChecking(false)); + }, []); + + if (checking) return

Laden…

; + if (!user || !user.authenticated) { + return ; + } + return setUser({ authenticated: false })} />; +} + +/* --------------------------------- Login ---------------------------------- */ +function LoginPage({ onLogin }) { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + async function submit(e) { + e.preventDefault(); + setBusy(true); + setError(null); + try { + onLogin(await login(username, password)); + } catch (err) { + setError(err.message); + } finally { + setBusy(false); + } + } + + return ( +
+
+
+ + Roosterwijs +
+

Log in om verder te gaan.

+ {error &&
⚠ {error}
} + setUsername(e.target.value)} + /> + setPassword(e.target.value)} + /> + +
+
+ ); +} + +/* ---------------------------------- Shell --------------------------------- */ +function Shell({ user, onLogout }) { const [modules, setModules] = useState([]); const [active, setActive] = useState("/personen"); const [error, setError] = useState(null); @@ -36,6 +118,14 @@ export default function App() { useEffect(() => { load(); }, []); + async function doLogout() { + try { + await logout(); + } finally { + onLogout(); + } + } + // Bouw de navigatie op uit de menu-items die ACTIEVE modules aanbieden. const menu = useMemo(() => { const items = []; @@ -43,9 +133,12 @@ export default function App() { if (!m.enabled) continue; for (const it of m.menu_items) items.push({ ...it, module: m.key }); } - items.push({ label: "Modulebeheer", path: "/modules", icon: "puzzle", order: 9999 }); + // Modulebeheer is een beheerdersactie. + if (user.is_staff) { + items.push({ label: "Modulebeheer", path: "/modules", icon: "puzzle", order: 9999 }); + } return items.sort((a, b) => a.order - b.order); - }, [modules]); + }, [modules, user.is_staff]); const Page = PAGES[active]; @@ -68,6 +161,10 @@ export default function App() { ))} +
+ 👤 {user.username} + +
Menu's verschijnen alleen voor ingeschakelde modules.
@@ -75,7 +172,7 @@ export default function App() { {error &&
⚠ {error}
} {loading ? (

Laden…

- ) : active === "/modules" ? ( + ) : active === "/modules" && user.is_staff ? ( ) : Page ? ( diff --git a/frontend/src/api.js b/frontend/src/api.js index 12b2a5c..70cca28 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1,8 +1,19 @@ // Dunne API-laag richting de Django-backend. const BASE = "/api"; +// Lees een cookie (nodig voor het CSRF-token dat Django zet). +function getCookie(name) { + const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`)); + return match ? decodeURIComponent(match[1]) : null; +} + async function request(method, url, body) { - const opts = { method, headers: {} }; + const opts = { method, headers: {}, credentials: "same-origin" }; + // Schrijfacties beveiligt Django met een CSRF-token; stuur het mee. + if (method !== "GET") { + const token = getCookie("csrftoken"); + if (token) opts.headers["X-CSRFToken"] = token; + } if (body !== undefined) { opts.headers["Content-Type"] = "application/json"; opts.body = JSON.stringify(body); @@ -16,13 +27,22 @@ async function request(method, url, body) { data = null; } if (!res.ok) { - const msg = - (data && (data.detail || JSON.stringify(data))) || "Er ging iets mis"; - throw new Error(msg); + const err = new Error( + (data && (data.detail || JSON.stringify(data))) || "Er ging iets mis" + ); + err.status = res.status; + throw err; } return data; } +// --- Authenticatie ---------------------------------------------------------- +// getMe zet meteen de CSRF-cookie; daarom altijd eerst aanroepen. +export const getMe = () => request("GET", "/auth/me/"); +export const login = (username, password) => + request("POST", "/auth/login/", { username, password }); +export const logout = () => request("POST", "/auth/logout/"); + // --- Plugin-framework ----------------------------------------------------- export const getModules = () => request("GET", "/modules/"); export const setModuleState = (key, enabled) => diff --git a/frontend/src/pages.jsx b/frontend/src/pages.jsx index c3fd073..b71f0c2 100644 --- a/frontend/src/pages.jsx +++ b/frontend/src/pages.jsx @@ -13,16 +13,28 @@ const ROLLEN = [ { value: "klassenondersteuner", label: "Klassenondersteuner" }, ]; -// Kleine helper voor laden + foutmelding. -function useList(resource) { +const WEEKDAGEN = [ + { value: 0, label: "Maandag" }, + { value: 1, label: "Dinsdag" }, + { value: 2, label: "Woensdag" }, + { value: 3, label: "Donderdag" }, + { value: 4, label: "Vrijdag" }, +]; + +const DAGTYPES = [ + { value: "studiedag", label: "Studiedag" }, + { value: "vrije_dag", label: "Vrije dag" }, + { value: "vakantie", label: "Vakantie" }, +]; + +function useList(resource, query = "") { const [items, setItems] = useState([]); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); - async function reload() { setLoading(true); try { - setItems(await listResource(resource)); + setItems(await listResource(resource, query)); setError(null); } catch (e) { setError(e.message); @@ -32,7 +44,7 @@ function useList(resource) { } useEffect(() => { reload(); - }, [resource]); + }, [resource, query]); return { items, error, loading, reload, setError }; } @@ -41,6 +53,159 @@ function Foutmelding({ msg }) { return
⚠ {msg}
; } +/* ----------------------- Generieke eenvoudige catalogus ------------------- */ +// Voor lijsten met alleen naam + omschrijving (Functies, Activiteiten, Locaties). +function CatalogusPage({ titel, uitleg, resource, kleur = false, telKolom }) { + const { items, error, loading, reload, setError } = useList(resource); + const leeg = kleur + ? { naam: "", omschrijving: "", kleur: "#2563eb" } + : { naam: "", omschrijving: "" }; + const [form, setForm] = useState(leeg); + const [editId, setEditId] = useState(null); + + async function opslaan(e) { + e.preventDefault(); + try { + if (editId) await updateResource(resource, editId, form); + else await createResource(resource, form); + setForm(leeg); + setEditId(null); + await reload(); + } catch (err) { + setError(err.message); + } + } + async function verwijder(id) { + if (!confirm("Verwijderen?")) return; + try { + await deleteResource(resource, id); + await reload(); + } catch (err) { + setError(err.message); + } + } + + return ( +
+

{titel}

+ {uitleg &&

{uitleg}

} + +
+ setForm({ ...form, naam: e.target.value })} + /> + setForm({ ...form, omschrijving: e.target.value })} + /> + {kleur && ( + setForm({ ...form, kleur: e.target.value })} + /> + )} + + {editId && ( + + )} +
+ {loading ? ( +

Laden…

+ ) : ( + + + + + + {telKolom && } + + + + + {items.map((it) => ( + + + + {telKolom && } + + + ))} + {items.length === 0 && ( + + + + )} + +
NaamOmschrijving{telKolom}
+ {kleur && ( + + )} + {it.naam} + {it.omschrijving || "—"}{it.aantal_personen ?? "—"} + + +
Nog niets.
+ )} +
+ ); +} + +export function FunctiesPage() { + return ( + + ); +} + +export function ActiviteitenPage() { + return ( + + ); +} + +export function LocatiesPage() { + return ( + + ); +} + /* --------------------------------- Groepen -------------------------------- */ export function GroepenPage() { const { items, error, loading, reload, setError } = useList("groepen"); @@ -75,48 +240,15 @@ export function GroepenPage() {

Groepen

Klassen waar leerlingen bij horen.

-
- setForm({ ...form, naam: e.target.value })} - /> - setForm({ ...form, omschrijving: e.target.value })} - /> - - {editId && ( - - )} + setForm({ ...form, naam: e.target.value })} /> + setForm({ ...form, omschrijving: e.target.value })} /> + + {editId && }
- - {loading ? ( -

Laden…

- ) : ( + {loading ?

Laden…

: ( - - - - - - - - + {items.map((g) => ( @@ -124,28 +256,12 @@ export function GroepenPage() { ))} - {items.length === 0 && ( - - - - )} + {items.length === 0 && }
NaamOmschrijvingLeerlingen
NaamOmschrijvingLeerlingen
{g.omschrijving || "—"} {g.aantal_leerlingen} - - + +
- Nog geen groepen. -
Nog geen groepen.
)} @@ -157,18 +273,36 @@ export function GroepenPage() { export function PersonenPage() { const { items, error, loading, reload, setError } = useList("personen"); const [groepen, setGroepen] = useState([]); - const leeg = { voornaam: "", achternaam: "", rol: "leerling", groep: "" }; + const [functies, setFuncties] = useState([]); + const leeg = { voornaam: "", achternaam: "", rol: "leerling", groep: "", functies: [] }; const [form, setForm] = useState(leeg); const [editId, setEditId] = useState(null); const [filter, setFilter] = useState(""); useEffect(() => { listResource("groepen").then(setGroepen).catch(() => {}); + listResource("functies").then(setFuncties).catch(() => {}); }, []); + const isLeerling = form.rol === "leerling"; + + function toggleFunctie(id) { + setForm((f) => + f.functies.includes(id) + ? { ...f, functies: f.functies.filter((x) => x !== id) } + : { ...f, functies: [...f.functies, id] } + ); + } + async function opslaan(e) { e.preventDefault(); - const payload = { ...form, groep: form.rol === "leerling" && form.groep ? form.groep : null }; + const payload = { + voornaam: form.voornaam, + achternaam: form.achternaam, + rol: form.rol, + groep: isLeerling && form.groep ? form.groep : null, + functies: isLeerling ? [] : form.functies, + }; try { if (editId) await updateResource("personen", editId, payload); else await createResource("personen", payload); @@ -194,96 +328,63 @@ export function PersonenPage() { return (

Personen

-

Leerlingen, leerkrachten en ondersteuners.

+

Leerlingen en medewerkers. Medewerkers kunnen meerdere functies hebben.

-
- setForm({ ...form, voornaam: e.target.value })} - /> - setForm({ ...form, achternaam: e.target.value })} - /> - - {form.rol === "leerling" && ( - setForm({ ...form, voornaam: e.target.value })} /> + setForm({ ...form, achternaam: e.target.value })} /> + + {isLeerling && ( + + )} + + {!isLeerling && ( +
+
Functies (meerdere mogelijk):
+
+ {functies.map((f) => ( + + ))} + {functies.length === 0 && Nog geen functies — voeg ze toe bij Functies.} +
+
)} - - {editId && ( - - )} +
+ + {editId && } +
Filter: - + {ROLLEN.map((r) => ( - + ))}
- {loading ? ( -

Laden…

- ) : ( + {loading ?

Laden…

: ( - - - - - - - - + {zichtbaar.map((p) => ( - + ))} - {zichtbaar.length === 0 && ( - - - - )} + {zichtbaar.length === 0 && }
NaamRolGroep
NaamRolGroep / Functies
{p.volledige_naam} {p.rol_label}{p.groep_naam || "—"} + {p.rol === "leerling" + ? (p.groep_naam || "—") + : (p.functies_namen.length ? p.functies_namen.map((x) => x.naam).join(", ") : "—")} + - +
- Geen personen. -
Geen personen.
)} @@ -364,118 +458,421 @@ export function SubgroepenPage() { return (

Subgroepen

-

- Vrije, groep-overstijgende groepjes leerlingen (bv. niveaugroep rekenen). -

+

Vrije, groep-overstijgende groepjes leerlingen (bv. niveaugroep rekenen).

-
- setForm({ ...form, naam: e.target.value })} - /> - setForm({ ...form, omschrijving: e.target.value })} - /> + setForm({ ...form, naam: e.target.value })} /> + setForm({ ...form, omschrijving: e.target.value })} />
Leden (leerlingen):
{leerlingen.map((l) => ( - + ))} - {leerlingen.length === 0 && ( - Nog geen leerlingen ingevoerd. - )} + {leerlingen.length === 0 && Nog geen leerlingen ingevoerd.}
- - {editId && ( - - )} + + {editId && }
- - {loading ? ( -

Laden…

- ) : ( + {loading ?

Laden…

: ( - - - - - - - + {items.map((s) => ( - - + + ))} - {items.length === 0 && ( - - - - )} + {items.length === 0 && }
NaamLeden
NaamLeden
- {s.naam} - {s.omschrijving && ( -
{s.omschrijving}
- )} -
- {s.aantal_leden}{" "} - - {s.leden_namen.map((x) => x.naam).join(", ")} - - {s.naam}{s.omschrijving &&
{s.omschrijving}
}
{s.aantal_leden} {s.leden_namen.map((x) => x.naam).join(", ")} - - + +
- Nog geen subgroepen. -
Nog geen subgroepen.
)}
); } + +/* ------------------------------- Tijdsloten ------------------------------- */ +export function TijdslotenPage() { + const { items, error, loading, reload, setError } = useList("tijdsloten"); + const leeg = { naam: "", dag: 0, start_tijd: "08:30", eind_tijd: "10:00", volgorde: 1 }; + const [form, setForm] = useState(leeg); + const [editId, setEditId] = useState(null); + + async function opslaan(e) { + e.preventDefault(); + try { + if (editId) await updateResource("tijdsloten", editId, form); + else await createResource("tijdsloten", form); + setForm(leeg); + setEditId(null); + await reload(); + } catch (err) { + setError(err.message); + } + } + async function verwijder(id) { + if (!confirm("Dit tijdslot verwijderen?")) return; + try { + await deleteResource("tijdsloten", id); + await reload(); + } catch (err) { + setError(err.message); + } + } + + return ( +
+

Tijdsloten

+

Vaste tijdblokken per schooldag (bv. Blok 1, maandag 08:30–10:00).

+ +
+ setForm({ ...form, naam: e.target.value })} /> + + setForm({ ...form, start_tijd: e.target.value })} /> + setForm({ ...form, eind_tijd: e.target.value })} /> + setForm({ ...form, volgorde: Number(e.target.value) })} /> + + {editId && } +
+ {loading ?

Laden…

: ( + + + + {items.map((t) => ( + + + + + + + ))} + {items.length === 0 && } + +
DagTijdNaam
{t.dag_label}{t.start_tijd?.slice(0, 5)}–{t.eind_tijd?.slice(0, 5)}{t.naam} + + +
Nog geen tijdsloten.
+ )} +
+ ); +} + +/* --------------------------- Schooljaar & kalender ------------------------ */ +export function SchooljaarPage() { + const { items: jaren, error, loading, reload, setError } = useList("schooljaren"); + const leeg = { naam: "", start_datum: "", eind_datum: "" }; + const [form, setForm] = useState(leeg); + const [gekozen, setGekozen] = useState(null); + + async function opslaan(e) { + e.preventDefault(); + try { + await createResource("schooljaren", form); + setForm(leeg); + await reload(); + } catch (err) { + setError(err.message); + } + } + async function verwijder(id) { + if (!confirm("Dit schooljaar (incl. kalender) verwijderen?")) return; + try { + await deleteResource("schooljaren", id); + if (gekozen === id) setGekozen(null); + await reload(); + } catch (err) { + setError(err.message); + } + } + + return ( +
+

Schooljaar & kalender

+

Schooljaren met studiedagen, vrije dagen en vakanties. Op die dagen vervallen de roosterblokken.

+ +
+ setForm({ ...form, naam: e.target.value })} /> + + + +
+ {loading ?

Laden…

: ( + + + + {jaren.map((j) => ( + + + + + + ))} + {jaren.length === 0 && } + +
SchooljaarPeriode
{j.naam}{j.start_datum} t/m {j.eind_datum} + + +
Nog geen schooljaren.
+ )} + {gekozen && } +
+ ); +} + +function Kalender({ schooljaarId }) { + const { items, error, reload, setError } = useList("kalenderdagen", `?schooljaar=${schooljaarId}`); + const leeg = { datum: "", type: "vrije_dag", omschrijving: "" }; + const [form, setForm] = useState(leeg); + + async function opslaan(e) { + e.preventDefault(); + try { + await createResource("kalenderdagen", { ...form, schooljaar: schooljaarId }); + setForm(leeg); + await reload(); + } catch (err) { + setError(err.message); + } + } + async function verwijder(id) { + try { + await deleteResource("kalenderdagen", id); + await reload(); + } catch (err) { + setError(err.message); + } + } + + return ( +
+

Kalender

+ +
+ setForm({ ...form, datum: e.target.value })} /> + + setForm({ ...form, omschrijving: e.target.value })} /> + +
+ + + + {items.map((d) => ( + + + + + + + ))} + {items.length === 0 && } + +
DatumTypeOmschrijving
{d.datum}{d.type_label}{d.omschrijving || "—"}
Nog geen bijzondere dagen.
+
+ ); +} + +/* ---------------------------------- Rooster ------------------------------- */ +export function RoosterPage() { + const [schooljaren, setSchooljaren] = useState([]); + const [schooljaar, setSchooljaar] = useState(""); + const [tijdsloten, setTijdsloten] = useState([]); + const [activiteiten, setActiviteiten] = useState([]); + const [locaties, setLocaties] = useState([]); + const [groepen, setGroepen] = useState([]); + const [subgroepen, setSubgroepen] = useState([]); + const [leerlingen, setLeerlingen] = useState([]); + const [medewerkers, setMedewerkers] = useState([]); + const [blokken, setBlokken] = useState([]); + const [error, setError] = useState(null); + + const leeg = { + tijdslot: "", activiteit: "", locatie: "", + doel_type: "groep", doel: "", begeleiders: [], opmerkingen: "", + }; + const [form, setForm] = useState(leeg); + + useEffect(() => { + listResource("schooljaren").then((j) => { + setSchooljaren(j); + if (j.length && !schooljaar) setSchooljaar(String(j[0].id)); + }).catch((e) => setError(e.message)); + listResource("tijdsloten").then(setTijdsloten).catch(() => {}); + listResource("activiteiten").then(setActiviteiten).catch(() => {}); + listResource("locaties").then(setLocaties).catch(() => {}); + listResource("groepen").then(setGroepen).catch(() => {}); + listResource("subgroepen").then(setSubgroepen).catch(() => {}); + listResource("personen", "?rol=leerling").then(setLeerlingen).catch(() => {}); + listResource("personen").then((ps) => setMedewerkers(ps.filter((p) => p.rol !== "leerling"))).catch(() => {}); + }, []); + + async function laadBlokken() { + if (!schooljaar) return; + try { + setBlokken(await listResource("roosterblokken", `?schooljaar=${schooljaar}`)); + setError(null); + } catch (e) { + setError(e.message); + } + } + useEffect(() => { laadBlokken(); }, [schooljaar]); + + const doelOpties = form.doel_type === "groep" ? groepen + : form.doel_type === "subgroep" ? subgroepen + : leerlingen; + + function toggleBegeleider(id) { + setForm((f) => + f.begeleiders.includes(id) + ? { ...f, begeleiders: f.begeleiders.filter((x) => x !== id) } + : { ...f, begeleiders: [...f.begeleiders, id] } + ); + } + + async function opslaan(e) { + e.preventDefault(); + const payload = { + schooljaar, + tijdslot: form.tijdslot, + activiteit: form.activiteit, + locatie: form.locatie || null, + groep: form.doel_type === "groep" ? form.doel : null, + subgroep: form.doel_type === "subgroep" ? form.doel : null, + leerling: form.doel_type === "leerling" ? form.doel : null, + begeleiders: form.begeleiders, + opmerkingen: form.opmerkingen, + }; + try { + await createResource("roosterblokken", payload); + setForm({ ...leeg, doel_type: form.doel_type }); + await laadBlokken(); + } catch (err) { + setError(err.message); + } + } + async function verwijder(id) { + if (!confirm("Dit roosterblok verwijderen?")) return; + try { + await deleteResource("roosterblokken", id); + await laadBlokken(); + } catch (err) { + setError(err.message); + } + } + + // Groepeer blokken per weekdag voor een overzichtelijk rooster. + const perDag = WEEKDAGEN.map((d) => ({ + dag: d, + blokken: blokken + .filter((b) => { + const t = tijdsloten.find((x) => x.id === b.tijdslot); + return t && t.dag === d.value; + }) + .sort((a, b) => { + const ta = tijdsloten.find((x) => x.id === a.tijdslot); + const tb = tijdsloten.find((x) => x.id === b.tijdslot); + return (ta?.volgorde ?? 0) - (tb?.volgorde ?? 0); + }), + })); + + return ( +
+

Rooster

+

Roosterblokken: wie doet wat, wanneer, waar en met wie. Koppel aan een groep, subgroep of individuele leerling.

+ + +
+ +
+ + {schooljaar && ( +
+
+ + + +
+
+ + +
+
+
Begeleiders:
+
+ {medewerkers.map((m) => ( + + ))} + {medewerkers.length === 0 && Nog geen medewerkers.} +
+
+
+ setForm({ ...form, opmerkingen: e.target.value })} /> + +
+
+ )} + + {schooljaar && ( +
+ {perDag.map(({ dag, blokken }) => ( +
+
{dag.label}
+ {blokken.length === 0 &&
} + {blokken.map((b) => ( +
+
+ {b.activiteit_naam} + +
+
{b.tijdslot_label}
+
🎯 {b.doel_naam} ({b.doel_type})
+ {b.locatie_naam &&
📍 {b.locatie_naam}
} + {b.begeleiders_namen.length > 0 && ( +
👤 {b.begeleiders_namen.map((x) => x.naam).join(", ")}
+ )} + {b.opmerkingen &&
📝 {b.opmerkingen}
} +
+ ))} +
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 7e1fbe4..ecdd478 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -99,3 +99,43 @@ input:focus, select:focus { outline: 2px solid var(--brand-soft); border-color: .leden-keuze { margin: 4px 0 12px; } .badge { display: inline-block; min-width: 22px; text-align: center; padding: 1px 7px; border-radius: 999px; background: var(--brand-soft); color: var(--brand); font-size: 12px; font-weight: 600; } + +/* --- Fase 2 --- */ +.kleur-stip { display: inline-block; width: 12px; height: 12px; border-radius: 3px; margin-right: 8px; vertical-align: middle; border: 1px solid rgba(0,0,0,.1); } +.veld { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--muted); } +.veld input, .veld select { min-width: auto; } +.rij-actief { background: var(--brand-soft); } +input[type="color"] { padding: 2px; width: 44px; min-width: 44px; height: 38px; } + +.subpaneel { margin-top: 24px; padding: 18px; border: 1px solid var(--line); border-radius: 12px; background: #fafbff; } +.subpaneel h2 { margin: 0 0 12px; font-size: 18px; } + +.tag.dag-studiedag { background: #e0e7ff; color: #3730a3; } +.tag.dag-vrije_dag { background: #fee2e2; color: #b91c1c; } +.tag.dag-vakantie { background: #dcfce7; color: #166534; } + +.rooster-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-top: 20px; } +.rooster-kolom { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 8px; min-height: 80px; } +.rooster-dag { font-weight: 600; font-size: 13px; text-align: center; padding: 6px 0; border-bottom: 1px solid var(--line); margin-bottom: 8px; } +.rooster-blok { border: 1px solid var(--line); border-left: 4px solid var(--brand); border-radius: 8px; padding: 8px; margin-bottom: 8px; background: #fff; } +.rb-top { display: flex; justify-content: space-between; align-items: center; gap: 6px; } +.link.small { font-size: 16px; line-height: 1; padding: 0 4px; } +@media (max-width: 900px) { .rooster-grid { grid-template-columns: 1fr; } } + +/* --- Login --- */ +.center-msg { text-align: center; margin-top: 20vh; color: var(--muted); } +.login-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; } +.login-card { + background: var(--panel); border: 1px solid var(--line); border-radius: 14px; + padding: 28px; width: 320px; display: flex; flex-direction: column; gap: 12px; + box-shadow: 0 8px 30px rgba(31, 41, 55, .06); +} +.login-brand { margin: 0 0 4px; justify-content: center; } +.login-card input { width: 100%; } +.login-card .btn { width: 100%; } + +/* --- Gebruiker in zijbalk --- */ +.sidebar-user { + display: flex; align-items: center; justify-content: space-between; + gap: 8px; padding: 10px 8px 0; border-top: 1px solid var(--line); margin-top: 12px; +}