From e51b94159b1b6970a7b3dfb444494149c5e2cee7 Mon Sep 17 00:00:00 2001 From: bes-r <27369668+bes-r@users.noreply.github.com> Date: Tue, 9 Jun 2026 23:17:47 +0200 Subject: [PATCH] Rebrand naar Roosterwijs + Fase 1: kerndomein (personen, groepen, subgroepen) --- README.md | 33 +- backend/core/admin.py | 26 ++ backend/core/apps.py | 1 + backend/core/migrations/0001_initial.py | 64 ++++ backend/core/models.py | 107 +++++- backend/core/serializers.py | 61 +++ backend/core/urls.py | 9 +- backend/core/views.py | 29 +- frontend/index.html | 3 +- frontend/package.json | 2 +- frontend/public/owl.svg | 11 + frontend/src/App.jsx | 25 +- frontend/src/Logo.jsx | 36 ++ frontend/src/api.js | 48 ++- frontend/src/pages.jsx | 481 ++++++++++++++++++++++++ frontend/src/styles.css | 41 +- 16 files changed, 936 insertions(+), 41 deletions(-) create mode 100644 backend/core/admin.py create mode 100644 backend/core/migrations/0001_initial.py create mode 100644 backend/core/serializers.py create mode 100644 frontend/public/owl.svg create mode 100644 frontend/src/Logo.jsx create mode 100644 frontend/src/pages.jsx diff --git a/README.md b/README.md index dec961c..b54ef0d 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,25 @@ -# Roostersoftware +# Roosterwijs -Modulair, flexibel roostersysteem voor het speciaal onderwijs. Zie `PLAN.md` -voor de visie en de fasering. +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. -Deze map bevat het resultaat van **Fase 0**: het fundament en een echt -plugin-framework waarmee modules per school in- en uitgeschakeld kunnen worden. +Status: **Fase 0** (fundament + plugin-framework) en **Fase 1** (kerndomein: +personen, groepen, subgroepen) zijn klaar. ## Wat er nu staat - **Backend** (`backend/`) — Django + Django REST Framework. - `plugins/` — het plugin-framework: een moduleregister, opslag van de aan/uit-status per module en een beheer-API. - - `core/` — de kern (altijd actief). Domeinmodellen (personen, groepen, - roosterblokken, kalender) volgen in fase 1–2. + - `core/` — de kern (altijd actief). **Fase 1**: modellen Persoon, Groep en + Subgroep met een volledige CRUD-API. Roosterblokken en kalender volgen in + fase 2. - `modules/printing/` en `modules/accounts/` — twee voorbeeldmodules die zich bij opstarten aanmelden bij het register. -- **Frontend** (`frontend/`) — React (Vite) met een zijbalk die alleen menu's - toont van ingeschakelde modules, plus een scherm **Modulebeheer** om modules - aan/uit te zetten. +- **Frontend** (`frontend/`) — React (Vite). Zijbalk met Roosterwijs-logo die + alleen menu's toont van ingeschakelde modules, beheerschermen voor + **Personen**, **Groepen** en **Subgroepen**, en een scherm **Modulebeheer**. ## Hoe modulariteit werkt (het verkoopargument) @@ -48,7 +50,9 @@ API-eindpunten: - `GET /api/modules/` — alle modules met status. - `POST /api/modules//state/` — body `{"enabled": true|false}`. - `GET /api/info/` — korte systeem-/gezondheidsinfo. -- `/admin/` — Django-admin (modulestatus ook hier instelbaar). +- `GET/POST /api/personen/`, `/api/groepen/`, `/api/subgroepen/` — CRUD + (plus `PATCH`/`DELETE` op `//`). Personen filteren kan met `?rol=leerling`. +- `/admin/` — Django-admin (personen, groepen, subgroepen en modulestatus). > Draait SQLite niet op je projectmap (bv. op een netwerkschijf)? Zet dan > `ROOSTER_DB_PATH` naar een lokaal pad, of pas `DATABASES` aan. @@ -65,7 +69,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 1) +## Volgende stap (Fase 2) -Kerndomein bouwen: personen (leerlingen, leerkrachten, ondersteuners), -groepen en subgroepen — met beheerschermen. +Schooljaar & roostering: een jaarkalender (studiedagen, vrije dagen, vakanties) +en roosterblokken die je toewijst aan een groep, subgroep of individuele +leerling, met begeleiders en locatie. diff --git a/backend/core/admin.py b/backend/core/admin.py new file mode 100644 index 0000000..207bea1 --- /dev/null +++ b/backend/core/admin.py @@ -0,0 +1,26 @@ +from django.contrib import admin + +from .models import Groep, Persoon, Subgroep + + +@admin.register(Groep) +class GroepAdmin(admin.ModelAdmin): + list_display = ("naam", "omschrijving", "actief") + list_filter = ("actief",) + search_fields = ("naam",) + + +@admin.register(Persoon) +class PersoonAdmin(admin.ModelAdmin): + list_display = ("achternaam", "voornaam", "rol", "groep", "actief") + list_filter = ("rol", "actief", "groep") + search_fields = ("voornaam", "achternaam") + autocomplete_fields = ("groep",) + + +@admin.register(Subgroep) +class SubgroepAdmin(admin.ModelAdmin): + list_display = ("naam", "omschrijving", "actief") + list_filter = ("actief",) + search_fields = ("naam",) + filter_horizontal = ("leden",) diff --git a/backend/core/apps.py b/backend/core/apps.py index fe607a9..d3af019 100644 --- a/backend/core/apps.py +++ b/backend/core/apps.py @@ -30,6 +30,7 @@ class CoreConfig(AppConfig): MenuItem("Rooster", "/rooster", icon="calendar", order=10), MenuItem("Personen", "/personen", icon="users", order=20), MenuItem("Groepen", "/groepen", icon="layers", order=30), + MenuItem("Subgroepen", "/subgroepen", icon="layers", order=40), ), ) ) diff --git a/backend/core/migrations/0001_initial.py b/backend/core/migrations/0001_initial.py new file mode 100644 index 0000000..7db1aac --- /dev/null +++ b/backend/core/migrations/0001_initial.py @@ -0,0 +1,64 @@ +# Generated by Django 5.2.15 on 2026-06-09 21:10 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Groep', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('naam', models.CharField(max_length=100, 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': 'groep', + 'verbose_name_plural': 'groepen', + 'ordering': ['naam'], + }, + ), + migrations.CreateModel( + name='Persoon', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('voornaam', models.CharField(max_length=80, verbose_name='voornaam')), + ('achternaam', models.CharField(max_length=120, verbose_name='achternaam')), + ('rol', models.CharField(choices=[('leerling', 'Leerling'), ('leerkracht', 'Leerkracht'), ('praktijkondersteuner', 'Praktijkondersteuner'), ('klassenondersteuner', 'Klassenondersteuner')], max_length=32, verbose_name='rol')), + ('actief', models.BooleanField(default=True, verbose_name='actief')), + ('opmerkingen', models.TextField(blank=True, verbose_name='opmerkingen')), + ('aangemaakt_op', models.DateTimeField(auto_now_add=True)), + ('groep', models.ForeignKey(blank=True, help_text='Alleen van toepassing op leerlingen.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='leerlingen', to='core.groep', verbose_name='groep')), + ], + options={ + 'verbose_name': 'persoon', + 'verbose_name_plural': 'personen', + 'ordering': ['achternaam', 'voornaam'], + }, + ), + migrations.CreateModel( + name='Subgroep', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('naam', models.CharField(max_length=100, 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)), + ('leden', models.ManyToManyField(blank=True, limit_choices_to={'rol': 'leerling'}, related_name='subgroepen', to='core.persoon', verbose_name='leden')), + ], + options={ + 'verbose_name': 'subgroep', + 'verbose_name_plural': 'subgroepen', + 'ordering': ['naam'], + }, + ), + ] diff --git a/backend/core/models.py b/backend/core/models.py index ce9449b..7056c11 100644 --- a/backend/core/models.py +++ b/backend/core/models.py @@ -1,3 +1,104 @@ -# Kerndomeinmodellen (personen, groepen, roosterblokken, kalender ...) -# worden in Fase 1 en 2 toegevoegd. In Fase 0 ligt de nadruk op het -# fundament en het plugin-framework. +""" +Kerndomein van Roosterwijs (Fase 1). + +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. + +Roosterblokken, tijdslots en de kalender volgen in Fase 2. +""" +from django.db import models + + +class Rol(models.TextChoices): + LEERLING = "leerling", "Leerling" + LEERKRACHT = "leerkracht", "Leerkracht" + PRAKTIJKONDERSTEUNER = "praktijkondersteuner", "Praktijkondersteuner" + KLASSENONDERSTEUNER = "klassenondersteuner", "Klassenondersteuner" + + +class Groep(models.Model): + """Een klas/groep waar leerlingen bij horen.""" + + naam = models.CharField("naam", max_length=100, unique=True) + omschrijving = models.CharField("omschrijving", max_length=255, blank=True) + actief = models.BooleanField("actief", default=True) + aangemaakt_op = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = "groep" + verbose_name_plural = "groepen" + ordering = ["naam"] + + def __str__(self) -> str: + return self.naam + + +class Persoon(models.Model): + """Leerling, leerkracht of ondersteuner - een tabel met een rol.""" + + voornaam = models.CharField("voornaam", max_length=80) + achternaam = models.CharField("achternaam", max_length=120) + rol = models.CharField("rol", max_length=32, choices=Rol.choices) + groep = models.ForeignKey( + Groep, + verbose_name="groep", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="leerlingen", + help_text="Alleen van toepassing op leerlingen.", + ) + actief = models.BooleanField("actief", default=True) + opmerkingen = models.TextField("opmerkingen", blank=True) + aangemaakt_op = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = "persoon" + verbose_name_plural = "personen" + ordering = ["achternaam", "voornaam"] + + def __str__(self) -> str: + return f"{self.volledige_naam} ({self.get_rol_display()})" + + @property + def volledige_naam(self) -> str: + return f"{self.voornaam} {self.achternaam}".strip() + + @property + def is_leerling(self) -> bool: + return self.rol == Rol.LEERLING + + def save(self, *args, **kwargs): + # Een groepskoppeling hoort alleen bij leerlingen. + if self.rol != Rol.LEERLING: + self.groep = None + super().save(*args, **kwargs) + + +class Subgroep(models.Model): + """Een vrije, groep-overstijgende verzameling leerlingen. + + Bijvoorbeeld een niveaugroepje rekenen met leerlingen uit meerdere groepen. + """ + + naam = models.CharField("naam", max_length=100) + omschrijving = models.CharField("omschrijving", max_length=255, blank=True) + leden = models.ManyToManyField( + Persoon, + verbose_name="leden", + related_name="subgroepen", + blank=True, + limit_choices_to={"rol": Rol.LEERLING}, + ) + actief = models.BooleanField("actief", default=True) + aangemaakt_op = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = "subgroep" + verbose_name_plural = "subgroepen" + ordering = ["naam"] + + def __str__(self) -> str: + return self.naam diff --git a/backend/core/serializers.py b/backend/core/serializers.py new file mode 100644 index 0000000..405a1b1 --- /dev/null +++ b/backend/core/serializers.py @@ -0,0 +1,61 @@ +"""DRF-serializers voor het kerndomein.""" +from rest_framework import serializers + +from .models import Groep, Persoon, Subgroep + + +class GroepSerializer(serializers.ModelSerializer): + aantal_leerlingen = serializers.IntegerField( + source="leerlingen.count", read_only=True + ) + + class Meta: + model = Groep + 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 + ) + + class Meta: + model = Persoon + fields = [ + "id", "voornaam", "achternaam", "volledige_naam", + "rol", "rol_label", "groep", "groep_naam", + "actief", "opmerkingen", "aangemaakt_op", + ] + read_only_fields = ["aangemaakt_op"] + + +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 + ) + leden_namen = serializers.SerializerMethodField() + aantal_leden = serializers.SerializerMethodField() + + class Meta: + model = Subgroep + fields = [ + "id", "naam", "omschrijving", "actief", + "leden", "leden_namen", "aantal_leden", "aangemaakt_op", + ] + 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() + ] + + def get_aantal_leden(self, obj): + return obj.leden.count() diff --git a/backend/core/urls.py b/backend/core/urls.py index 2e0cf19..57fe8d9 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -1,7 +1,14 @@ -from django.urls import path +from django.urls import include, path +from rest_framework.routers import DefaultRouter from . import views +router = DefaultRouter() +router.register("personen", views.PersoonViewSet, basename="persoon") +router.register("groepen", views.GroepViewSet, basename="groep") +router.register("subgroepen", views.SubgroepViewSet, basename="subgroep") + urlpatterns = [ path("info/", views.info, name="info"), + path("", include(router.urls)), ] diff --git a/backend/core/views.py b/backend/core/views.py index 65667fe..c0e10f4 100644 --- a/backend/core/views.py +++ b/backend/core/views.py @@ -1,8 +1,12 @@ +from rest_framework import viewsets from rest_framework.decorators import api_view from rest_framework.response import Response from plugins import services +from .models import Groep, Persoon, Subgroep +from .serializers import GroepSerializer, PersoonSerializer, SubgroepSerializer + @api_view(["GET"]) def info(request): @@ -11,9 +15,30 @@ def info(request): modules = services.describe_all() return Response( { - "naam": "Roostersoftware", - "fase": "0 - fundament + plugin-framework", + "naam": "Roosterwijs", + "fase": "1 - kerndomein (personen, groepen, subgroepen)", "modules_totaal": len(modules), "modules_actief": sum(1 for m in modules if m["enabled"]), } ) + + +class GroepViewSet(viewsets.ModelViewSet): + queryset = Groep.objects.all() + serializer_class = GroepSerializer + + +class PersoonViewSet(viewsets.ModelViewSet): + serializer_class = PersoonSerializer + + def get_queryset(self): + qs = Persoon.objects.select_related("groep").all() + rol = self.request.query_params.get("rol") + if rol: + qs = qs.filter(rol=rol) + return qs + + +class SubgroepViewSet(viewsets.ModelViewSet): + queryset = Subgroep.objects.prefetch_related("leden").all() + serializer_class = SubgroepSerializer diff --git a/frontend/index.html b/frontend/index.html index c95c9c3..67be027 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,8 @@ - Roostersoftware + + Roosterwijs
diff --git a/frontend/package.json b/frontend/package.json index 2bd7c02..8c046e0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,5 +1,5 @@ { - "name": "roostersoftware-frontend", + "name": "roosterwijs-frontend", "private": true, "version": "0.1.0", "type": "module", diff --git a/frontend/public/owl.svg b/frontend/public/owl.svg new file mode 100644 index 0000000..fbc06a3 --- /dev/null +++ b/frontend/public/owl.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ac57fbe..441ad29 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,14 +1,24 @@ import { useEffect, useMemo, useState } from "react"; import { getModules, setModuleState } from "./api.js"; +import Logo from "./Logo.jsx"; +import { GroepenPage, PersonenPage, SubgroepenPage } from "./pages.jsx"; const ICONS = { calendar: "📅", users: "👥", layers: "🗂️", printer: "🖨️", key: "🔑", puzzle: "🧩", }; +// Koppel een menu-pad aan een echt scherm. Onbekende paden tonen een +// placeholder (worden in een latere fase ingevuld). +const PAGES = { + "/personen": PersonenPage, + "/groepen": GroepenPage, + "/subgroepen": SubgroepenPage, +}; + export default function App() { const [modules, setModules] = useState([]); - const [active, setActive] = useState("/rooster"); + const [active, setActive] = useState("/personen"); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); @@ -37,10 +47,15 @@ export default function App() { return items.sort((a, b) => a.order - b.order); }, [modules]); + const Page = PAGES[active]; + return (