commit ac634d343fbe339a94a1b3c6c6ccea2f40998c4e Author: bes-r <27369668+bes-r@users.noreply.github.com> Date: Tue Jun 9 22:46:23 2026 +0200 Fase 0 + Docker-stack voor test-server diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6495914 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# Kopieer dit bestand naar .env en vul echte waarden in. +# cp .env.example .env +# Het .env-bestand bevat geheimen en hoort NIET in git (staat in .gitignore). + +# --- Django --------------------------------------------------------------- +# Genereer een sterke sleutel, bv.: +# python -c "import secrets; print(secrets.token_urlsafe(50))" +DJANGO_SECRET_KEY=vervang-mij-door-een-lange-willekeurige-string + +# 0 = uit (zo hoort het op de test-/productieserver). 1 alleen lokaal debuggen. +DJANGO_DEBUG=0 + +# Hostnaam(en) waarop de server bereikbaar is, komma-gescheiden. +# Vervang door de echte naam/IP van je test-server. +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 + +# --- PostgreSQL ----------------------------------------------------------- +POSTGRES_DB=rooster +POSTGRES_USER=rooster +POSTGRES_PASSWORD=vervang-mij-door-een-sterk-wachtwoord diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5222ccd --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Geheimen / omgeving +.env + +# Python +__pycache__/ +*.pyc +backend/db.sqlite3 +backend/staticfiles/ +.venv/ +venv/ + +# Node +frontend/node_modules/ +frontend/dist/ + +# Editor / OS +.vscode/ +.idea/ +.DS_Store +Thumbs.db diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..09187dc --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,97 @@ +# Draaien op de test-server (Docker) + +De stack bestaat uit drie containers: + +| Container | Rol | +|-----------|-----| +| `db` | PostgreSQL (data in een Docker-volume) | +| `backend` | Django via gunicorn | +| `nginx` | serveert de React-frontend en stuurt `/api` + `/admin` door naar de backend | + +Je hoeft op de server **niets** te installeren behalve Docker zelf (met de +`docker compose`-plugin). Python, Node, Postgres enz. zitten in de images. + +## Eenmalig: server klaarzetten + +1. Zorg dat Docker + Compose op de test-server staan: + ```bash + docker --version + docker compose version + ``` +2. Haal de code op: + ```bash + git clone /Roostersoftware.git + cd Roostersoftware + ``` +3. Maak het `.env`-bestand aan vanuit het voorbeeld en vul echte waarden in: + ```bash + cp .env.example .env + nano .env + ``` + Belangrijk om aan te passen: + - `DJANGO_SECRET_KEY` — genereer met + `python3 -c "import secrets; print(secrets.token_urlsafe(50))"` + - `DJANGO_ALLOWED_HOSTS` — de hostnaam/IP van de test-server + - `DJANGO_CSRF_TRUSTED_ORIGINS` — `http://:8080` (of je echte URL) + - `POSTGRES_PASSWORD` — een sterk wachtwoord + + `.env` staat in `.gitignore` en komt dus **niet** in git — dat hoort zo. + +## Starten + +```bash +docker compose up -d --build +``` + +Wat er dan gebeurt: Postgres start, de backend wacht tot de database er is, +voert automatisch de migraties en `collectstatic` uit, en gunicorn + nginx +komen op. De app is bereikbaar op: + +``` +http://:8080 +``` + +(Poort `8080` staat ingesteld in `docker-compose.yml` bij de `nginx`-service; +pas hem daar aan als die poort bezet is.) + +### Eerste keer: beheerder aanmaken + +```bash +docker compose exec backend python manage.py createsuperuser +``` + +Daarna kun je inloggen op `http://:8080/admin/`. + +## Updaten na nieuwe code + +```bash +git pull +docker compose up -d --build +``` + +Migraties draaien automatisch mee bij het opstarten van de backend. + +## Handige commando's + +| Doel | Commando | +|------|----------| +| Logs volgen | `docker compose logs -f` | +| Alleen backend-logs | `docker compose logs -f backend` | +| Status containers | `docker compose ps` | +| Stoppen | `docker compose down` | +| Stoppen **incl. database wissen** | `docker compose down -v` (let op: gegevens weg!) | +| Migratie handmatig | `docker compose exec backend python manage.py migrate` | +| Django-shell | `docker compose exec backend python manage.py shell` | + +## Aandachtspunten + +- **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. +- **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. +- **Test eerst hier, dan productie:** bouw het image één keer, test op deze + server, en draai exact dezelfde code/compose op de productieserver met een + eigen `.env`. diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..c7a9873 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,118 @@ +# Plan – Roostersoftware Speciaal Onderwijs + +Modulair, flexibel roostersysteem voor het speciaal onderwijs. Kleine aantallen, veel flexibiliteit per leerling, groep, subgroep, leerkracht en ondersteuner. + +## 1. Uitgangspunten + +- **Type**: webapplicatie (browser, werkt op pc en tablet). +- **Bouw**: Claude bouwt het leeuwendeel; jij stuurt, test en geeft richting. +- **MVP-focus**: (1) basisroostering, (2) flexibiliteit & uitzonderingen, (3) conflictdetectie. +- **Schaal**: één school, kleine aantallen — dus eenvoud boven complexe infrastructuur, maar het datamodel moet de flexibiliteit echt aankunnen. + +## 2. Kernidee: het rooster als bouwstenen + +De kracht voor speciaal onderwijs zit in *modulariteit*. In plaats van één vast groepsrooster bouwen we het rooster op uit losse **roosterblokken** die je aan wie dan ook kunt koppelen. Een blok = "wie doet wat, wanneer, waar, met wie". + +Een blok kan toegewezen worden aan: + +- een hele **groep**, +- een **subgroep** (deel van een groep), +- een **individuele leerling** (afwijking van het groepsrooster), +- met daaraan gekoppeld één of meer **leerkrachten** en/of **ondersteuners**. + +Het rooster van een individuele leerling is dan: het groepsrooster **plus/min** zijn persoonlijke uitzonderingen. Zo houd je kleine aantallen overzichtelijk én volledig flexibel. + +## 3. Datamodel (de modules) + +Onafhankelijke, herbruikbare entiteiten: + +- **Persoon** – basis voor leerling, leerkracht, ondersteuner (één tabel, met rol/type). + - Leerling: hoort bij een groep, kan in subgroepen zitten. + - Leerkracht / Praktijkondersteuner / Klassenondersteuner: inzetbaarheid, beschikbaarheid. +- **Groep** – klas; bevat leerlingen. +- **Subgroep** – flexibele deelverzameling van leerlingen (bv. niveaugroepje rekenen), kan groep-overstijgend zijn. +- **Activiteit/Vak** – wat er gedaan wordt (rekenen, gym, therapie, pauze…). +- **Locatie** – lokaal/ruimte (optioneel maar handig voor conflictdetectie). +- **Tijdslot** – dag + begin/eindtijd. We werken met **vaste schooltijden** (een instelbaar dagritme), maar tijdslots blijven aanpasbaar. +- **Roosterblok** – de spil: koppelt activiteit + tijdslot + doelgroep (groep/subgroep/leerling) + begeleiders + locatie. +- **Uitzondering** – afwijking voor een individu of dag (afwezigheid, vervanging, eenmalige wijziging). +- **Schooljaar / Kalender** – jaaroverzicht met studiedagen, vrije dagen en vakanties. Een vrije/vakantiedag schakelt automatisch de roosterblokken op die dag uit, zodat het weekrooster en het jaaroverzicht altijd kloppen. + +Modulair betekent: elke entiteit staat los en is later uitbreidbaar (bv. ouders, vervoer, doelen/handelingsplan) zonder de kern te herbouwen. + +## 4. Conflictdetectie + +Bij het plaatsen/wijzigen van een blok controleert het systeem automatisch op: + +- **Dubbele inzet leerkracht/ondersteuner** – staat iemand op twee plekken tegelijk. +- **Dubbele inzet leerling** – zit een leerling in twee blokken op hetzelfde moment. +- **Locatiebotsing** – twee blokken in dezelfde ruimte tegelijk. +- **Onderbezetting** – blok zonder begeleider (waarschuwing, niet blokkerend). + +Conflicten tonen we als duidelijke waarschuwingen; jij beslist of het mag. + +## 5. Technische opzet + +- **Backend**: Django (Python) met Django REST Framework. Django's **"apps"** vormen ons modulesysteem: elke functie is een losse, in-/uitschakelbare app. +- **Frontend**: React – het roosterraster en de schermen. +- **Database**: SQLite om te starten (geen serverbeheer); later 1-op-1 te upgraden naar PostgreSQL. +- **Auth/rechten**: Django's ingebouwde gebruikers- en rechtensysteem (basis aanwezig, volledig uitgerold in de multi-user-module). +- **Draaien**: lokaal te starten; later te hosten op een eenvoudige server of clouddienst. + +## 5b. Modulaire architectuur — een echt plugin-framework (verkoopargument) + +Modulariteit is een **verkoopargument**: een school koopt de kern en schakelt naar wens modules bij. Modules moeten daarom écht aan/uit te zetten zijn — niet alleen nette code, maar instelbaar per school via een beheerscherm. + +**Het plugin-framework (onderdeel van de kern):** + +- **Moduleregister** – elke module meldt zichzelf aan met naam, beschrijving, versie en afhankelijkheden. +- **Aan/uit per school** – een beheerder zet modules in een **beheerscherm** in of uit; uitgeschakelde modules zijn onzichtbaar (geen menu's, geen schermen, geen API). +- **Vaste aanhechtpunten (hooks)** – de kern biedt nette uitbreidpunten (menu, schermen, rooster-acties, exports) waar een module zich op aanhaakt, zónder de kern te wijzigen. +- **Afhankelijkheden & licenties** – het register bewaakt afhankelijkheden tussen modules en biedt een haak voor licenties/feature-flags, zodat modules per school verkocht en geactiveerd kunnen worden. + +Zo kun je het product gelaagd aanbieden: iedereen krijgt de kern, en modules als printen, ouderportaal of multi-user zijn losse, activeerbare uitbreidingen. + +**Kern (altijd aan):** personen, groepen, subgroepen, activiteiten, locaties, tijdslots, roosterblokken, schooljaar/kalender, conflictdetectie + het plugin-framework zelf. + +**Modules (in-/uitschakelbaar per school):** + +- **Printen/Exporteren** – roosters als PDF/print, per groep/leerling/leerkracht. +- **Multi-user & toegang** – inloggen met rollen: leerling, ouder, leerkracht, ondersteuner en diverse lagen collega's (bv. directie, administratie), elk met eigen rechten en weergave. +- *(later mogelijk)* ouderportaal, vervoer, doelen/handelingsplan, meldingen. + +## 6. Gefaseerde aanpak + +**Fase 0 – Fundament + plugin-framework** +Django-project, React-frontend, database, basis-UI-skelet én het plugin-framework: moduleregister, aan/uit-beheerscherm en uitbreidpunten. Dit framework is de ruggengraat van het verkoopargument, dus het komt meteen in fase 0. + +**Fase 1 – Basisgegevens (CRUD)** +Personen (leerlingen, leerkrachten, ondersteuners), groepen, subgroepen, activiteiten, locaties invoeren en beheren. + +**Fase 2 – Schooljaar & roostering** +Schooljaarkalender (studiedagen/vrije dagen/vakanties) + roosterblokken toewijzen aan groep/subgroep/leerling met begeleiders. Weekrooster tonen per groep, per leerling en per leerkracht. + +**Fase 3 – Flexibiliteit & uitzonderingen** +Individuele afwijkingen, subgroep-roosters, afwezigheid/vervanging. Leerlingrooster = groepsrooster + uitzonderingen. Vrije/vakantiedagen schakelen blokken automatisch uit. + +**Fase 4 – Conflictdetectie** +Automatische controle bij plaatsen/wijzigen, met heldere waarschuwingen. + +**Fase 5 – Module: Printen/Exporteren** +Eerste echte losse module — roosters als PDF/print, per groep/leerling/leerkracht. + +**Fase 6 – Module: Multi-user & toegang** +Inloggen met rollen (leerling, ouder, leerkracht, ondersteuner, collega-lagen), elk met eigen rechten en weergave. + +Na elke fase heb je een werkende versie die je kunt uitproberen; we passen aan op basis van wat je ziet. De modules (fase 5–6) bewijzen meteen dat de modulaire opzet werkt. + +## 7. Beslissingen (vastgelegd) + +- **Techstack**: Django (backend, modules via apps) + React (frontend) + SQLite → later PostgreSQL. +- **Tijden**: vaste schooltijden, plus een jaaroverzicht met studiedagen, vrije dagen en vakanties. +- **Locaties**: meenemen in de MVP (ook voor conflictdetectie). +- **Multi-user**: niet in de MVP, maar als losse module gebouwd (leerling-, ouder- en gelaagde collega-logins). +- **Modulariteit**: een echt plugin-framework als verkoopargument — modules per school in-/uitschakelbaar via een beheerscherm, met haak voor licenties. Komt al in fase 0. + +## 8. Volgende stap + +Ik start met **Fase 0**: Django-project met modulestructuur, React-frontend en database opzetten — zodat we snel een werkend skelet hebben om op verder te bouwen. Zeg het woord en ik begin. diff --git a/README.md b/README.md new file mode 100644 index 0000000..dec961c --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# Roostersoftware + +Modulair, flexibel roostersysteem voor het speciaal onderwijs. Zie `PLAN.md` +voor de visie en de fasering. + +Deze map bevat het resultaat van **Fase 0**: het fundament en een echt +plugin-framework waarmee modules per school in- en uitgeschakeld kunnen worden. + +## 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. + - `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. + +## Hoe modulariteit werkt (het verkoopargument) + +1. Een module is een gewone Django-app met een `apps.py` die in `ready()` een + `ModuleSpec` registreert (naam, beschrijving, versie, afhankelijkheden en + menu-items). +2. Het register weet wélke modules bestaan; de database onthoudt of ze **aan** + staan. Een beheerder schakelt ze per school in/uit via Modulebeheer. +3. Uitgeschakelde modules leveren geen menu's of schermen — de kern blijft + overal gelijk, modules zijn los bij te schakelen. + +Een nieuwe module toevoegen = nieuwe app maken, één regel in +`MODULE_APPS` (in `backend/config/settings.py`), klaar. + +## Starten + +### Backend (Django, poort 8000) + +```bash +cd backend +pip install -r requirements.txt +python manage.py migrate +python manage.py createsuperuser # optioneel, voor /admin/ +python manage.py runserver +``` + +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). + +> Draait SQLite niet op je projectmap (bv. op een netwerkschijf)? Zet dan +> `ROOSTER_DB_PATH` naar een lokaal pad, of pas `DATABASES` aan. + +### Frontend (React, poort 5173) + +```bash +cd frontend +npm install +npm run dev +``` + +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) + +Kerndomein bouwen: personen (leerlingen, leerkrachten, ondersteuners), +groepen en subgroepen — met beheerschermen. diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..5074f00 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,8 @@ +__pycache__/ +*.pyc +db.sqlite3 +db.sqlite3-journal +staticfiles/ +.venv/ +venv/ +*.sqlite3 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..a60e76e --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,27 @@ +# Backend: Django achter gunicorn. +FROM python:3.12-slim + +# Geen .pyc-bestanden, ongebufferde logs (handig in Docker). +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +# Systeempakketten die psycopg/gunicorn soms nodig hebben. +RUN apt-get update \ + && apt-get install -y --no-install-recommends libpq5 \ + && rm -rf /var/lib/apt/lists/* + +# Eerst requirements (betere build-cache), dan de rest. +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +# entrypoint wacht op de database, migreert en verzamelt statics. +RUN chmod +x /app/entrypoint.sh + +EXPOSE 8000 +ENTRYPOINT ["/app/entrypoint.sh"] +# 3 workers is ruim voldoende voor de kleine aantallen in het SO. +CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"] diff --git a/backend/config/__init__.py b/backend/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/config/asgi.py b/backend/config/asgi.py new file mode 100644 index 0000000..bc24bd4 --- /dev/null +++ b/backend/config/asgi.py @@ -0,0 +1,6 @@ +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") +application = get_asgi_application() diff --git a/backend/config/settings.py b/backend/config/settings.py new file mode 100644 index 0000000..51cd185 --- /dev/null +++ b/backend/config/settings.py @@ -0,0 +1,153 @@ +""" +Django-instellingen voor de Roostersoftware. + +De architectuur draait om een kleine KERN plus losse MODULES. +Modules zijn gewone Django-apps die zich bij opstarten aanmelden bij het +plugin-register (zie de app 'plugins'). Of een module zichtbaar/actief is, +bepaalt een beheerder per school via een aan/uit-vlag in de database. +""" +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent + +# --- Beveiliging ---------------------------------------------------------- +# Instellingen komen uit omgevingsvariabelen, met veilige dev-defaults. +# In productie/op de test-server zet je deze via het .env-bestand. + +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) + +# 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" +).split(",") if h.strip()] + +# Nodig achter nginx voor POST-verzoeken naar /admin en de API. +CSRF_TRUSTED_ORIGINS = [o.strip() for o in os.environ.get( + "DJANGO_CSRF_TRUSTED_ORIGINS", "" +).split(",") if o.strip()] + +# --- Applicaties ---------------------------------------------------------- +DJANGO_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", +] + +THIRD_PARTY_APPS = [ + "rest_framework", + "corsheaders", +] + +# De kern van het roostersysteem + het plugin-framework. +CORE_APPS = [ + "plugins", # het plugin-framework zelf (moduleregister + beheer-API) + "core", # kerndomein (altijd aan) +] + +# Losse modules. Eén regel toevoegen = module beschikbaar maken. +# Aan/uit zetten gebeurt daarna per school via het beheerscherm. +MODULE_APPS = [ + "modules.printing", # Printen/Exporteren + "modules.accounts", # Multi-user & toegang +] + +INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + CORE_APPS + MODULE_APPS + +MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "config.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "config.wsgi.application" + +# --- Database ------------------------------------------------------------- +# Als POSTGRES_DB gezet is (test-server/productie) gebruiken we PostgreSQL; +# anders valt het terug op SQLite voor lokaal ontwikkelen. +if os.environ.get("POSTGRES_DB"): + DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.environ["POSTGRES_DB"], + "USER": os.environ.get("POSTGRES_USER", "rooster"), + "PASSWORD": os.environ.get("POSTGRES_PASSWORD", ""), + "HOST": os.environ.get("POSTGRES_HOST", "db"), + "PORT": os.environ.get("POSTGRES_PORT", "5432"), + } + } +else: + DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": os.environ.get("ROOSTER_DB_PATH", str(BASE_DIR / "db.sqlite3")), + } + } + +# --- Wachtwoordvalidatie -------------------------------------------------- +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] + +# --- Internationalisatie -------------------------------------------------- +LANGUAGE_CODE = "nl-nl" +TIME_ZONE = "Europe/Amsterdam" +USE_I18N = True +USE_TZ = True + +# --- Statische bestanden -------------------------------------------------- +# collectstatic verzamelt hier; nginx serveert deze map op /static/ +# (o.a. de CSS van de Django-admin). +STATIC_URL = "/static/" +STATIC_ROOT = os.environ.get("DJANGO_STATIC_ROOT", str(BASE_DIR / "staticfiles")) +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# --- API ------------------------------------------------------------------ +REST_FRAMEWORK = { + "DEFAULT_RENDERER_CLASSES": [ + "rest_framework.renderers.JSONRenderer", + "rest_framework.renderers.BrowsableAPIRenderer", + ], +} + +# Frontend (React dev-server) mag de API benaderen. +CORS_ALLOWED_ORIGINS = [ + "http://localhost:5173", + "http://127.0.0.1:5173", +] diff --git a/backend/config/settings_test.py b/backend/config/settings_test.py new file mode 100644 index 0000000..9f1d577 --- /dev/null +++ b/backend/config/settings_test.py @@ -0,0 +1,14 @@ +"""Alleen voor geautomatiseerd testen in de sandbox. + +SQLite kan in de testomgeving niet op de gekoppelde projectmap draaien, +daarom wijst deze test-config de database naar een lokaal pad. In productie +gebruik je gewoon config.settings. +""" +from config.settings import * # noqa: F401,F403 + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": "/tmp/rooster_test.sqlite3", + } +} diff --git a/backend/config/urls.py b/backend/config/urls.py new file mode 100644 index 0000000..8005185 --- /dev/null +++ b/backend/config/urls.py @@ -0,0 +1,11 @@ +"""Hoofd-URL-configuratie.""" +from django.contrib import admin +from django.urls import include, path + +urlpatterns = [ + path("admin/", admin.site.urls), + # Plugin-framework: lijst + aan/uit zetten van modules. + path("api/", include("plugins.urls")), + # Kern-API. + path("api/", include("core.urls")), +] diff --git a/backend/config/wsgi.py b/backend/config/wsgi.py new file mode 100644 index 0000000..ccf67ba --- /dev/null +++ b/backend/config/wsgi.py @@ -0,0 +1,6 @@ +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") +application = get_wsgi_application() diff --git a/backend/core/__init__.py b/backend/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/apps.py b/backend/core/apps.py new file mode 100644 index 0000000..fe607a9 --- /dev/null +++ b/backend/core/apps.py @@ -0,0 +1,35 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "core" + verbose_name = "Kern (roostering)" + + def ready(self): + """Meld de kern aan bij het plugin-register. + + De kern is altijd actief (core=True) en levert de vaste navigatie: + rooster, personen en groepen. In latere fases vullen we deze schermen. + """ + from plugins.registry import MenuItem, ModuleSpec, registry + + registry.register( + ModuleSpec( + key="core", + name="Roosterkern", + description=( + "Het hart van het systeem: personen, groepen, subgroepen, " + "activiteiten, locaties, tijdslots, roosterblokken, " + "schooljaarkalender en conflictdetectie." + ), + version="0.1.0", + category="Kern", + core=True, + menu_items=( + MenuItem("Rooster", "/rooster", icon="calendar", order=10), + MenuItem("Personen", "/personen", icon="users", order=20), + MenuItem("Groepen", "/groepen", icon="layers", order=30), + ), + ) + ) diff --git a/backend/core/migrations/__init__.py b/backend/core/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/models.py b/backend/core/models.py new file mode 100644 index 0000000..ce9449b --- /dev/null +++ b/backend/core/models.py @@ -0,0 +1,3 @@ +# 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. diff --git a/backend/core/urls.py b/backend/core/urls.py new file mode 100644 index 0000000..2e0cf19 --- /dev/null +++ b/backend/core/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("info/", views.info, name="info"), +] diff --git a/backend/core/views.py b/backend/core/views.py new file mode 100644 index 0000000..65667fe --- /dev/null +++ b/backend/core/views.py @@ -0,0 +1,19 @@ +from rest_framework.decorators import api_view +from rest_framework.response import Response + +from plugins import services + + +@api_view(["GET"]) +def info(request): + """Korte systeeminfo + welke modules actief zijn. Handig als gezondheidscheck.""" + services.sync_states() + modules = services.describe_all() + return Response( + { + "naam": "Roostersoftware", + "fase": "0 - fundament + plugin-framework", + "modules_totaal": len(modules), + "modules_actief": sum(1 for m in modules if m["enabled"]), + } + ) diff --git a/backend/db.sqlite3-journal b/backend/db.sqlite3-journal new file mode 100644 index 0000000..965fdf3 Binary files /dev/null and b/backend/db.sqlite3-journal differ diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh new file mode 100644 index 0000000..a5d474e --- /dev/null +++ b/backend/entrypoint.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Wordt bij elke containerstart uitgevoerd, vóór gunicorn. +set -e + +# Wacht tot PostgreSQL bereikbaar is (alleen als we Postgres gebruiken). +if [ -n "$POSTGRES_DB" ]; then + echo "Wachten op PostgreSQL op ${POSTGRES_HOST:-db}:${POSTGRES_PORT:-5432}..." + python <<'PY' +import os, time, socket +host = os.environ.get("POSTGRES_HOST", "db") +port = int(os.environ.get("POSTGRES_PORT", "5432")) +for _ in range(60): + try: + with socket.create_connection((host, port), timeout=2): + print("Database is bereikbaar.") + break + except OSError: + time.sleep(1) +else: + raise SystemExit("Database niet bereikbaar na 60s.") +PY +fi + +# Migraties uitvoeren en statische bestanden verzamelen. +echo "Migraties uitvoeren..." +python manage.py migrate --noinput + +echo "Statische bestanden verzamelen..." +python manage.py collectstatic --noinput + +# Geef door aan het CMD (gunicorn). +exec "$@" diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 0000000..f1ea41b --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python +"""Django command-line utility voor administratieve taken.""" +import os +import sys + + +def main(): + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Kon Django niet importeren. Staat de virtual environment aan " + "en is Django geïnstalleerd? (pip install -r requirements.txt)" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/backend/modules/__init__.py b/backend/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/accounts/__init__.py b/backend/modules/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/accounts/apps.py b/backend/modules/accounts/apps.py new file mode 100644 index 0000000..50651b4 --- /dev/null +++ b/backend/modules/accounts/apps.py @@ -0,0 +1,30 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "modules.accounts" + label = "module_accounts" + verbose_name = "Module: Multi-user & toegang" + + def ready(self): + from plugins.registry import MenuItem, ModuleSpec, registry + + registry.register( + ModuleSpec( + key="accounts", + name="Multi-user & toegang", + description=( + "Inloggen met rollen: leerling, ouder, leerkracht, " + "ondersteuner en collega-lagen (directie, administratie), " + "elk met eigen rechten en weergave." + ), + version="0.1.0", + category="Toegang", + core=False, + default_enabled=False, + menu_items=( + MenuItem("Gebruikers", "/gebruikers", icon="key", order=300), + ), + ) + ) diff --git a/backend/modules/accounts/migrations/__init__.py b/backend/modules/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/printing/__init__.py b/backend/modules/printing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/printing/apps.py b/backend/modules/printing/apps.py new file mode 100644 index 0000000..845575d --- /dev/null +++ b/backend/modules/printing/apps.py @@ -0,0 +1,29 @@ +from django.apps import AppConfig + + +class PrintingConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "modules.printing" + label = "module_printing" + verbose_name = "Module: Printen/Exporteren" + + def ready(self): + from plugins.registry import MenuItem, ModuleSpec, registry + + registry.register( + ModuleSpec( + key="printing", + name="Printen / Exporteren", + description=( + "Roosters afdrukken of als PDF exporteren, per groep, " + "leerling of leerkracht." + ), + version="0.1.0", + category="Uitvoer", + core=False, + default_enabled=True, + menu_items=( + MenuItem("Afdrukken", "/afdrukken", icon="printer", order=200), + ), + ) + ) diff --git a/backend/modules/printing/migrations/__init__.py b/backend/modules/printing/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/plugins/__init__.py b/backend/plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/plugins/admin.py b/backend/plugins/admin.py new file mode 100644 index 0000000..2156ced --- /dev/null +++ b/backend/plugins/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin + +from .models import ModuleState + + +@admin.register(ModuleState) +class ModuleStateAdmin(admin.ModelAdmin): + list_display = ("key", "enabled", "updated_at") + list_filter = ("enabled",) + list_editable = ("enabled",) diff --git a/backend/plugins/apps.py b/backend/plugins/apps.py new file mode 100644 index 0000000..5571ffd --- /dev/null +++ b/backend/plugins/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class PluginsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "plugins" + verbose_name = "Plugin-framework" diff --git a/backend/plugins/migrations/0001_initial.py b/backend/plugins/migrations/0001_initial.py new file mode 100644 index 0000000..06bcf52 --- /dev/null +++ b/backend/plugins/migrations/0001_initial.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.15 on 2026-06-09 20:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='ModuleState', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key', models.CharField(max_length=64, unique=True, verbose_name='modulesleutel')), + ('enabled', models.BooleanField(default=False, verbose_name='ingeschakeld')), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'modulestatus', + 'verbose_name_plural': 'modulestatussen', + 'ordering': ['key'], + }, + ), + ] diff --git a/backend/plugins/migrations/__init__.py b/backend/plugins/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/plugins/models.py b/backend/plugins/models.py new file mode 100644 index 0000000..d56c6bf --- /dev/null +++ b/backend/plugins/models.py @@ -0,0 +1,25 @@ +""" +Opslag van de aan/uit-status van modules per installatie (school). + +Het register (registry.py) weet wélke modules bestaan; dit model onthoudt +of ze aan staan. Zo blijft de instelling bewaard en kan een beheerder modules +via het beheerscherm in- en uitschakelen. +""" +from django.db import models + + +class ModuleState(models.Model): + """De aan/uit-status van één module.""" + + key = models.CharField("modulesleutel", max_length=64, unique=True) + enabled = models.BooleanField("ingeschakeld", default=False) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "modulestatus" + verbose_name_plural = "modulestatussen" + ordering = ["key"] + + def __str__(self) -> str: + status = "aan" if self.enabled else "uit" + return f"{self.key} ({status})" diff --git a/backend/plugins/registry.py b/backend/plugins/registry.py new file mode 100644 index 0000000..49e0257 --- /dev/null +++ b/backend/plugins/registry.py @@ -0,0 +1,66 @@ +""" +Het plugin-register: het hart van de modulaire architectuur. + +Elke module (een Django-app) maakt zichzelf bij opstarten bekend door een +`ModuleSpec` te registreren. Het register houdt bij wélke modules er bestaan, +wat ze aanbieden (menu-items / uitbreidpunten) en hoe ze van elkaar afhangen. + +Of een module daadwerkelijk AAN staat, wordt per school bewaard in de database +(zie models.ModuleState) en is via het beheerscherm in te stellen. Zo is +modulariteit een echt verkoopargument: de kern is overal gelijk, en een school +schakelt naar wens modules bij. +""" +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class MenuItem: + """Een navigatie-item dat een module toevoegt aan de app.""" + + label: str + path: str + icon: str = "puzzle" + order: int = 100 + + +@dataclass(frozen=True) +class ModuleSpec: + """Beschrijving waarmee een module zich aanmeldt bij het register.""" + + key: str # unieke sleutel, bv. "printing" + name: str # leesbare naam + description: str = "" + version: str = "0.1.0" + category: str = "Overig" + core: bool = False # kern-module: altijd aan, niet uitschakelbaar + default_enabled: bool = False # standaard aan na installatie? + depends_on: tuple[str, ...] = field(default_factory=tuple) + menu_items: tuple[MenuItem, ...] = field(default_factory=tuple) + + +class ModuleRegistry: + """Centrale verzameling van alle bekende modules (in-memory).""" + + def __init__(self) -> None: + self._specs: dict[str, ModuleSpec] = {} + + def register(self, spec: ModuleSpec) -> None: + if spec.key in self._specs: + raise ValueError(f"Module '{spec.key}' is al geregistreerd.") + self._specs[spec.key] = spec + + def all(self) -> list[ModuleSpec]: + return sorted(self._specs.values(), key=lambda s: (s.category, s.name)) + + def get(self, key: str) -> ModuleSpec | None: + return self._specs.get(key) + + def dependents_of(self, key: str) -> list[ModuleSpec]: + """Modules die deze module nodig hebben.""" + return [s for s in self._specs.values() if key in s.depends_on] + + +# Eén gedeeld register voor de hele applicatie. +registry = ModuleRegistry() diff --git a/backend/plugins/services.py b/backend/plugins/services.py new file mode 100644 index 0000000..7ecb975 --- /dev/null +++ b/backend/plugins/services.py @@ -0,0 +1,100 @@ +""" +Bedrijfslogica rond modules: status uitlezen en veilig aan/uit zetten, +met respect voor onderlinge afhankelijkheden. +""" +from __future__ import annotations + +from .models import ModuleState +from .registry import ModuleSpec, registry + + +class ModuleError(Exception): + """Een aan/uit-actie is niet toegestaan.""" + + +def _state_map() -> dict[str, bool]: + return {s.key: s.enabled for s in ModuleState.objects.all()} + + +def is_enabled(key: str) -> bool: + spec = registry.get(key) + if spec is None: + return False + if spec.core: + return True + state = ModuleState.objects.filter(key=key).first() + return bool(state and state.enabled) + + +def sync_states() -> None: + """Zorg dat elke geregistreerde module een rij in de database heeft.""" + 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) + + +def describe_all() -> list[dict]: + """Alle modules met metadata + actuele status, voor de API/het beheerscherm.""" + states = _state_map() + result: list[dict] = [] + for spec in registry.all(): + enabled = True if spec.core else states.get(spec.key, spec.default_enabled) + result.append( + { + "key": spec.key, + "name": spec.name, + "description": spec.description, + "version": spec.version, + "category": spec.category, + "core": spec.core, + "enabled": enabled, + "depends_on": list(spec.depends_on), + "menu_items": [ + { + "label": m.label, + "path": m.path, + "icon": m.icon, + "order": m.order, + } + for m in spec.menu_items + ] + if enabled + else [], + } + ) + return result + + +def set_enabled(key: str, enabled: bool) -> ModuleSpec: + """Zet een module aan of uit, met controle op afhankelijkheden.""" + spec = registry.get(key) + if spec is None: + raise ModuleError(f"Onbekende module: {key}") + if spec.core: + raise ModuleError(f"Kernmodule '{key}' kan niet worden uitgeschakeld.") + + if enabled: + # Vereiste modules moeten ook aan staan. + for dep in spec.depends_on: + if not is_enabled(dep): + dep_spec = registry.get(dep) + dep_name = dep_spec.name if dep_spec else dep + raise ModuleError( + f"Module '{spec.name}' vereist eerst '{dep_name}'." + ) + else: + # Modules die hiervan afhangen mogen niet meer aan staan. + for dependent in registry.dependents_of(key): + if is_enabled(dependent.key): + raise ModuleError( + f"'{dependent.name}' heeft '{spec.name}' nodig; " + f"schakel die eerst uit." + ) + + state, _ = ModuleState.objects.get_or_create(key=key) + state.enabled = enabled + state.save(update_fields=["enabled", "updated_at"]) + return spec diff --git a/backend/plugins/urls.py b/backend/plugins/urls.py new file mode 100644 index 0000000..a0b1889 --- /dev/null +++ b/backend/plugins/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("modules/", views.module_list, name="module-list"), + path("modules//state/", views.module_set_state, name="module-set-state"), +] diff --git a/backend/plugins/views.py b/backend/plugins/views.py new file mode 100644 index 0000000..2fe79a0 --- /dev/null +++ b/backend/plugins/views.py @@ -0,0 +1,24 @@ +"""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.response import Response + +from . import services + + +@api_view(["GET"]) +def module_list(request): + """Alle modules met metadata en actuele aan/uit-status.""" + services.sync_states() + return Response(services.describe_all()) + + +@api_view(["POST"]) +def module_set_state(request, key: str): + """Zet één module aan of uit. Body: {"enabled": true|false}.""" + enabled = bool(request.data.get("enabled", False)) + try: + services.set_enabled(key, enabled) + except services.ModuleError as exc: + return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + return Response(services.describe_all()) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..2108e13 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,7 @@ +Django>=5.2,<6.0 +djangorestframework>=3.15 +django-cors-headers>=4.4 + +# --- Productie / test-server --------------------------------------------- +gunicorn>=22.0 # WSGI-server die Django draait +psycopg[binary]>=3.2 # PostgreSQL-driver diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..26ad1f0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,57 @@ +# Stack voor de test-server (en later productie): +# db - PostgreSQL +# backend - Django via gunicorn +# nginx - serveert de frontend en proxyt /api + /admin naar de backend +# +# Instellingen komen uit het .env-bestand (zie .env.example). +services: + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 10 + + backend: + build: ./backend + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY} + DJANGO_DEBUG: ${DJANGO_DEBUG:-0} + DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS} + DJANGO_CSRF_TRUSTED_ORIGINS: ${DJANGO_CSRF_TRUSTED_ORIGINS} + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_HOST: db + POSTGRES_PORT: 5432 + volumes: + # Verzamelde statics delen met nginx. + - static_volume:/app/staticfiles + + nginx: + build: ./frontend + restart: unless-stopped + depends_on: + - backend + ports: + # Linkerkant is de poort op de server; pas aan indien bezet. + - "8080:80" + volumes: + # Lees de door Django verzamelde statics (alleen-lezen). + - static_volume:/static:ro + +volumes: + pgdata: + static_volume: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..3ff38cc --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.vite/ diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..2a4d9fb --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,17 @@ +# Frontend in twee fases: +# 1) Node bouwt de React-app tot statische bestanden. +# 2) Nginx serveert die bestanden en proxyt /api en /admin naar de backend. + +# --- Fase 1: build -------------------------------------------------------- +FROM node:20-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY . . +RUN npm run build + +# --- Fase 2: nginx -------------------------------------------------------- +FROM nginx:1.27-alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 80 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..c95c9c3 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Roostersoftware + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..eb01b4d --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,41 @@ +# Nginx: serveert de React-app en stuurt API/admin door naar gunicorn. +server { + listen 80; + server_name _; + + # Upload-/postlimiet wat ruimer (bv. importbestanden later). + client_max_body_size 20M; + + # Door Django verzamelde statische bestanden (o.a. admin-CSS). + # Deze map komt uit het gedeelde volume 'static_volume'. + location /static/ { + alias /static/; + access_log off; + expires 30d; + } + + # API-verzoeken naar de Django-backend (gunicorn). + location /api/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Django-admin loopt ook via de backend. + location /admin/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Alle overige paden: de single-page React-app. + # try_files zorgt dat client-side routes terugvallen op index.html. + location / { + root /usr/share/nginx/html; + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..2bd7c02 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,19 @@ +{ + "name": "roostersoftware-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.4.2" + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..ac57fbe --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,155 @@ +import { useEffect, useMemo, useState } from "react"; +import { getModules, setModuleState } from "./api.js"; + +const ICONS = { + calendar: "📅", users: "👥", layers: "🗂️", printer: "🖨️", + key: "🔑", puzzle: "🧩", +}; + +export default function App() { + const [modules, setModules] = useState([]); + const [active, setActive] = useState("/rooster"); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + async function load() { + setLoading(true); + try { + setModules(await getModules()); + setError(null); + } catch (e) { + setError(e.message); + } finally { + setLoading(false); + } + } + + useEffect(() => { load(); }, []); + + // Bouw de navigatie op uit de menu-items die ACTIEVE modules aanbieden. + const menu = useMemo(() => { + const items = []; + for (const m of modules) { + 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 }); + return items.sort((a, b) => a.order - b.order); + }, [modules]); + + return ( +
+ + +
+ {error &&
⚠ {error}
} + {loading ? ( +

Laden…

+ ) : active === "/modules" ? ( + + ) : ( + + )} +
+
+ ); +} + +function Placeholder({ path, menu }) { + const item = menu.find((m) => m.path === path); + return ( +
+

{item ? item.label : "Welkom"}

+

+ Dit scherm wordt in een volgende fase ingevuld. Fase 0 levert het + fundament en het plugin-framework. +

+
+ ); +} + +function ModuleManager({ modules, onChange }) { + const [busy, setBusy] = useState(null); + const [msg, setMsg] = useState(null); + + async function toggle(m) { + setBusy(m.key); + setMsg(null); + try { + await setModuleState(m.key, !m.enabled); + await onChange(); + } catch (e) { + setMsg(e.message); + } finally { + setBusy(null); + } + } + + return ( +
+

Modulebeheer

+

+ Schakel modules per school in of uit. De kern staat altijd aan. +

+ {msg &&
⚠ {msg}
} +
+ {modules.map((m) => ( +
+
+
+
{m.name}
+
{m.category} · v{m.version}
+
+ toggle(m)} + /> +
+

{m.description}

+
+ {m.core ? ( + Kern · altijd aan + ) : ( + + {m.enabled ? "Ingeschakeld" : "Uitgeschakeld"} + + )} + {m.depends_on.length > 0 && ( + vereist: {m.depends_on.join(", ")} + )} +
+
+ ))} +
+
+ ); +} + +function Toggle({ checked, disabled, onChange }) { + return ( + + ); +} diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..3d6c1e4 --- /dev/null +++ b/frontend/src/api.js @@ -0,0 +1,19 @@ +// Dunne API-laag richting de Django-backend. +const BASE = "/api"; + +export async function getModules() { + const res = await fetch(`${BASE}/modules/`); + if (!res.ok) throw new Error("Kon modules niet laden"); + return res.json(); +} + +export async function setModuleState(key, enabled) { + const res = await fetch(`${BASE}/modules/${key}/state/`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.detail || "Wijzigen mislukt"); + return data; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..4ecd9cf --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,10 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App.jsx"; +import "./styles.css"; + +createRoot(document.getElementById("root")).render( + + + +); diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..ababfb0 --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,62 @@ +:root { + --bg: #f4f6fb; + --panel: #ffffff; + --ink: #1f2937; + --muted: #6b7280; + --line: #e5e7eb; + --brand: #2563eb; + --brand-soft: #eef2ff; + --on: #16a34a; +} + +* { box-sizing: border-box; } +body { margin: 0; font-family: system-ui, "Segoe UI", Roboto, sans-serif; color: var(--ink); background: var(--bg); } + +.app { display: grid; grid-template-columns: 250px 1fr; min-height: 100vh; } + +.sidebar { + background: var(--panel); + border-right: 1px solid var(--line); + padding: 20px 14px; + display: flex; + flex-direction: column; + gap: 6px; +} +.brand { font-weight: 700; font-size: 18px; margin: 4px 8px 18px; color: var(--brand); } +.nav-item { + display: flex; align-items: center; gap: 10px; + width: 100%; text-align: left; + background: transparent; border: 0; border-radius: 8px; + padding: 10px 10px; font-size: 14px; color: var(--ink); cursor: pointer; +} +.nav-item:hover { background: var(--bg); } +.nav-item.active { background: var(--brand-soft); color: var(--brand); font-weight: 600; } +.ico { width: 18px; text-align: center; } +.hint { margin-top: auto; font-size: 12px; color: var(--muted); padding: 12px 8px 0; } + +.content { padding: 32px 40px; } +h1 { margin: 0 0 6px; font-size: 24px; } +.muted { color: var(--muted); } + +.banner { padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; } +.banner.error { background: #fef2f2; color: #b91c1c; border: 1px solid #fecaca; } + +.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; margin-top: 20px; } +.card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 16px; } +.card.on { border-color: #bbf7d0; } +.card-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; } +.card-title { font-weight: 600; } +.card-cat { font-size: 12px; color: var(--muted); margin-top: 2px; } +.card-desc { font-size: 14px; color: #374151; margin: 10px 0 12px; } +.card-foot { display: flex; flex-wrap: wrap; gap: 6px; } + +.tag { font-size: 12px; padding: 3px 8px; border-radius: 999px; background: #f3f4f6; color: var(--muted); } +.tag.on { background: #dcfce7; color: var(--on); } +.tag.core { background: var(--brand-soft); color: var(--brand); } +.tag.dep { background: #fef9c3; color: #854d0e; } + +.toggle { width: 44px; height: 26px; border-radius: 999px; border: 0; background: #d1d5db; position: relative; cursor: pointer; transition: background .15s; flex: none; } +.toggle.on { background: var(--on); } +.toggle:disabled { opacity: .5; cursor: not-allowed; } +.knob { position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; background: #fff; border-radius: 50%; transition: left .15s; } +.toggle.on .knob { left: 21px; } diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..3628615 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,13 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// Dev-server draait op poort 5173 en stuurt /api door naar Django (poort 8000). +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + "/api": "http://localhost:8000", + }, + }, +});