Fase 0 + Docker-stack voor test-server
This commit is contained in:
commit
ac634d343f
51 changed files with 1412 additions and 0 deletions
23
.env.example
Normal file
23
.env.example
Normal file
|
|
@ -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
|
||||
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal file
|
|
@ -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
|
||||
97
DEPLOY.md
Normal file
97
DEPLOY.md
Normal file
|
|
@ -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 <jouw-forgejo-url>/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://<host>: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://<server-host>: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://<server-host>: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`.
|
||||
118
PLAN.md
Normal file
118
PLAN.md
Normal file
|
|
@ -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.
|
||||
71
README.md
Normal file
71
README.md
Normal file
|
|
@ -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/<key>/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.
|
||||
8
backend/.dockerignore
Normal file
8
backend/.dockerignore
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
staticfiles/
|
||||
.venv/
|
||||
venv/
|
||||
*.sqlite3
|
||||
27
backend/Dockerfile
Normal file
27
backend/Dockerfile
Normal file
|
|
@ -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"]
|
||||
0
backend/config/__init__.py
Normal file
0
backend/config/__init__.py
Normal file
6
backend/config/asgi.py
Normal file
6
backend/config/asgi.py
Normal file
|
|
@ -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()
|
||||
153
backend/config/settings.py
Normal file
153
backend/config/settings.py
Normal file
|
|
@ -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",
|
||||
]
|
||||
14
backend/config/settings_test.py
Normal file
14
backend/config/settings_test.py
Normal file
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
11
backend/config/urls.py
Normal file
11
backend/config/urls.py
Normal file
|
|
@ -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")),
|
||||
]
|
||||
6
backend/config/wsgi.py
Normal file
6
backend/config/wsgi.py
Normal file
|
|
@ -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()
|
||||
0
backend/core/__init__.py
Normal file
0
backend/core/__init__.py
Normal file
35
backend/core/apps.py
Normal file
35
backend/core/apps.py
Normal file
|
|
@ -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),
|
||||
),
|
||||
)
|
||||
)
|
||||
0
backend/core/migrations/__init__.py
Normal file
0
backend/core/migrations/__init__.py
Normal file
3
backend/core/models.py
Normal file
3
backend/core/models.py
Normal file
|
|
@ -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.
|
||||
7
backend/core/urls.py
Normal file
7
backend/core/urls.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path("info/", views.info, name="info"),
|
||||
]
|
||||
19
backend/core/views.py
Normal file
19
backend/core/views.py
Normal file
|
|
@ -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"]),
|
||||
}
|
||||
)
|
||||
BIN
backend/db.sqlite3-journal
Normal file
BIN
backend/db.sqlite3-journal
Normal file
Binary file not shown.
32
backend/entrypoint.sh
Normal file
32
backend/entrypoint.sh
Normal file
|
|
@ -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 "$@"
|
||||
20
backend/manage.py
Normal file
20
backend/manage.py
Normal file
|
|
@ -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()
|
||||
0
backend/modules/__init__.py
Normal file
0
backend/modules/__init__.py
Normal file
0
backend/modules/accounts/__init__.py
Normal file
0
backend/modules/accounts/__init__.py
Normal file
30
backend/modules/accounts/apps.py
Normal file
30
backend/modules/accounts/apps.py
Normal file
|
|
@ -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),
|
||||
),
|
||||
)
|
||||
)
|
||||
0
backend/modules/accounts/migrations/__init__.py
Normal file
0
backend/modules/accounts/migrations/__init__.py
Normal file
0
backend/modules/printing/__init__.py
Normal file
0
backend/modules/printing/__init__.py
Normal file
29
backend/modules/printing/apps.py
Normal file
29
backend/modules/printing/apps.py
Normal file
|
|
@ -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),
|
||||
),
|
||||
)
|
||||
)
|
||||
0
backend/modules/printing/migrations/__init__.py
Normal file
0
backend/modules/printing/migrations/__init__.py
Normal file
0
backend/plugins/__init__.py
Normal file
0
backend/plugins/__init__.py
Normal file
10
backend/plugins/admin.py
Normal file
10
backend/plugins/admin.py
Normal file
|
|
@ -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",)
|
||||
7
backend/plugins/apps.py
Normal file
7
backend/plugins/apps.py
Normal file
|
|
@ -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"
|
||||
28
backend/plugins/migrations/0001_initial.py
Normal file
28
backend/plugins/migrations/0001_initial.py
Normal file
|
|
@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
backend/plugins/migrations/__init__.py
Normal file
0
backend/plugins/migrations/__init__.py
Normal file
25
backend/plugins/models.py
Normal file
25
backend/plugins/models.py
Normal file
|
|
@ -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})"
|
||||
66
backend/plugins/registry.py
Normal file
66
backend/plugins/registry.py
Normal file
|
|
@ -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()
|
||||
100
backend/plugins/services.py
Normal file
100
backend/plugins/services.py
Normal file
|
|
@ -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
|
||||
8
backend/plugins/urls.py
Normal file
8
backend/plugins/urls.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path("modules/", views.module_list, name="module-list"),
|
||||
path("modules/<str:key>/state/", views.module_set_state, name="module-set-state"),
|
||||
]
|
||||
24
backend/plugins/views.py
Normal file
24
backend/plugins/views.py
Normal file
|
|
@ -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())
|
||||
7
backend/requirements.txt
Normal file
7
backend/requirements.txt
Normal file
|
|
@ -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
|
||||
57
docker-compose.yml
Normal file
57
docker-compose.yml
Normal file
|
|
@ -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:
|
||||
3
frontend/.dockerignore
Normal file
3
frontend/.dockerignore
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
17
frontend/Dockerfile
Normal file
17
frontend/Dockerfile
Normal file
|
|
@ -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
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Roostersoftware</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
41
frontend/nginx.conf
Normal file
41
frontend/nginx.conf
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
19
frontend/package.json
Normal file
19
frontend/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
155
frontend/src/App.jsx
Normal file
155
frontend/src/App.jsx
Normal file
|
|
@ -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 (
|
||||
<div className="app">
|
||||
<aside className="sidebar">
|
||||
<div className="brand">Roostersoftware</div>
|
||||
<nav>
|
||||
{menu.map((it) => (
|
||||
<button
|
||||
key={it.path}
|
||||
className={active === it.path ? "nav-item active" : "nav-item"}
|
||||
onClick={() => setActive(it.path)}
|
||||
>
|
||||
<span className="ico">{ICONS[it.icon] || "•"}</span>
|
||||
{it.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="hint">Menu's verschijnen alleen voor ingeschakelde modules.</div>
|
||||
</aside>
|
||||
|
||||
<main className="content">
|
||||
{error && <div className="banner error">⚠ {error}</div>}
|
||||
{loading ? (
|
||||
<p>Laden…</p>
|
||||
) : active === "/modules" ? (
|
||||
<ModuleManager modules={modules} onChange={load} />
|
||||
) : (
|
||||
<Placeholder path={active} menu={menu} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Placeholder({ path, menu }) {
|
||||
const item = menu.find((m) => m.path === path);
|
||||
return (
|
||||
<section>
|
||||
<h1>{item ? item.label : "Welkom"}</h1>
|
||||
<p className="muted">
|
||||
Dit scherm wordt in een volgende fase ingevuld. Fase 0 levert het
|
||||
fundament en het plugin-framework.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section>
|
||||
<h1>Modulebeheer</h1>
|
||||
<p className="muted">
|
||||
Schakel modules per school in of uit. De kern staat altijd aan.
|
||||
</p>
|
||||
{msg && <div className="banner error">⚠ {msg}</div>}
|
||||
<div className="cards">
|
||||
{modules.map((m) => (
|
||||
<div className={m.enabled ? "card on" : "card"} key={m.key}>
|
||||
<div className="card-head">
|
||||
<div>
|
||||
<div className="card-title">{m.name}</div>
|
||||
<div className="card-cat">{m.category} · v{m.version}</div>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={m.enabled}
|
||||
disabled={m.core || busy === m.key}
|
||||
onChange={() => toggle(m)}
|
||||
/>
|
||||
</div>
|
||||
<p className="card-desc">{m.description}</p>
|
||||
<div className="card-foot">
|
||||
{m.core ? (
|
||||
<span className="tag core">Kern · altijd aan</span>
|
||||
) : (
|
||||
<span className={m.enabled ? "tag on" : "tag"}>
|
||||
{m.enabled ? "Ingeschakeld" : "Uitgeschakeld"}
|
||||
</span>
|
||||
)}
|
||||
{m.depends_on.length > 0 && (
|
||||
<span className="tag dep">vereist: {m.depends_on.join(", ")}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({ checked, disabled, onChange }) {
|
||||
return (
|
||||
<button
|
||||
className={`toggle ${checked ? "on" : ""}`}
|
||||
disabled={disabled}
|
||||
onClick={onChange}
|
||||
aria-pressed={checked}
|
||||
>
|
||||
<span className="knob" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
19
frontend/src/api.js
Normal file
19
frontend/src/api.js
Normal file
|
|
@ -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;
|
||||
}
|
||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
|
|
@ -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(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
62
frontend/src/styles.css
Normal file
62
frontend/src/styles.css
Normal file
|
|
@ -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; }
|
||||
13
frontend/vite.config.js
Normal file
13
frontend/vite.config.js
Normal file
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue