From f40918c077aa3692cf2b27c3deda27216edcc7fa Mon Sep 17 00:00:00 2001 From: bes-r <27369668+bes-r@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:25:55 +0200 Subject: [PATCH] e-mailconfig + bulk-import gebruikers met wachtwoorden en persoonvoorstel --- .env.example | 14 +++ DEPLOY.md | 23 +++++ ROADMAP.md | 9 ++ backend/modules/accounts/urls.py | 1 + backend/modules/accounts/views.py | 75 +++++++++++++++ docker-compose.yml | 8 ++ frontend/src/api.js | 1 + frontend/src/pages.jsx | 151 ++++++++++++++++++++++++++++++ 8 files changed, 282 insertions(+) diff --git a/.env.example b/.env.example index af6391e..6dd5427 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,17 @@ DJANGO_SECURE=0 POSTGRES_DB=rooster POSTGRES_USER=rooster POSTGRES_PASSWORD=vervang-mij-door-een-sterk-wachtwoord + +# --- E-mail (o.a. wachtwoordreset) --------------------------------------- +# Laat DJANGO_EMAIL_BACKEND leeg/weg om e-mail naar de console-log te sturen +# (handig om te testen). Zet onderstaande regel aan voor échte e-mail via SMTP: +# DJANGO_EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend +EMAIL_HOST= +EMAIL_PORT=587 +EMAIL_HOST_USER= +EMAIL_HOST_PASSWORD= +EMAIL_USE_TLS=1 +DEFAULT_FROM_EMAIL=Roosterwijs +# Basis-URL van de frontend voor resetlinks in e-mails (zonder slash op het eind), +# bv. http://rooster-test.example.nl:8080 (leeg = afleiden uit het verzoek). +FRONTEND_BASE_URL= diff --git a/DEPLOY.md b/DEPLOY.md index 8acdcee..dd45466 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -31,6 +31,29 @@ Nieuwe **modules** verschijnen daarna pas in de app nadat je ze aanzet via --- +# E-mail aanzetten (wachtwoordreset-links) + +Zonder configuratie gaan e-mails naar de **console-log** van de backend-container +(zichtbaar met `docker compose logs backend`) — prima om te testen. Voor échte +e-mail via SMTP zet je in `.env` op de server: + +``` +DJANGO_EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend +EMAIL_HOST=smtp.jouwprovider.nl +EMAIL_PORT=587 +EMAIL_HOST_USER=postvak@jouwschool.nl +EMAIL_HOST_PASSWORD=... +EMAIL_USE_TLS=1 +DEFAULT_FROM_EMAIL=Roosterwijs +FRONTEND_BASE_URL=http://rooster-test.example.nl:8080 +``` + +`FRONTEND_BASE_URL` bepaalt de basis van de resetlink in de e-mail (zonder +`FRONTEND_BASE_URL` wordt die afgeleid uit het verzoek). Daarna +`docker compose up -d` (of `./scripts/deploy.sh`) om de nieuwe waarden te laden. + +--- + # Draaien op de test-server (Docker) De stack bestaat uit drie containers: diff --git a/ROADMAP.md b/ROADMAP.md index aa484d9..3fdeaf8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -87,6 +87,15 @@ volgt. - [x] **Hiërarchie vastgelegd:** subgroep → groep (Subgroep.groep) en groep → leerplein (Leerplein.groepen). +## Toegang & gebruikers + +- [x] Login + gebruikersbeheer gebundeld in de accounts-module; inloggen ook met + e-mailadres. +- [x] **Wachtwoordreset**: beheerder genereert/zet wachtwoord én self-service via + e-maillink (SMTP via .env; standaard console-log). +- [x] **Bulk-import van gebruikers** met automatisch gegenereerde wachtwoorden, + persoon koppelen/aanmaken en een voorstel op naamgelijkenis. + ## Module: Urenoverzicht - [x] **Module die berekent hoeveel uur een groep per vak krijgt.** (module `uren`, default uit; endpoint /api/urenoverzicht/) diff --git a/backend/modules/accounts/urls.py b/backend/modules/accounts/urls.py index ab30b5d..defdad6 100644 --- a/backend/modules/accounts/urls.py +++ b/backend/modules/accounts/urls.py @@ -13,5 +13,6 @@ urlpatterns = [ path("auth/wachtwoord-vergeten/", views.wachtwoord_vergeten, name="wachtwoord-vergeten"), path("auth/wachtwoord-reset/", views.wachtwoord_reset, name="wachtwoord-reset"), path("gebruikers//wachtwoord/", views.wachtwoord_zet, name="wachtwoord-zet"), + path("gebruikers-import/", views.gebruikers_import, name="gebruikers-import"), path("", include(router.urls)), ] diff --git a/backend/modules/accounts/views.py b/backend/modules/accounts/views.py index b24282a..38a419f 100644 --- a/backend/modules/accounts/views.py +++ b/backend/modules/accounts/views.py @@ -3,7 +3,9 @@ De login-endpoints horen hier thuis (de module is in de praktijk altijd aan). """ import logging +import re import secrets +import unicodedata from django.contrib.auth import authenticate, get_user_model from django.contrib.auth import login as auth_login @@ -19,6 +21,7 @@ from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import AllowAny, IsAdminUser from rest_framework.response import Response +from .models import Gebruikersprofiel, Gebruikersrol from .serializers import GebruikerSerializer logger = logging.getLogger("roosterwijs.auth") @@ -145,3 +148,75 @@ def wachtwoord_zet(request, pk): user.save() logger.info("Beheerder %r zette nieuw wachtwoord voor %r", request.user.get_username(), user.get_username()) return Response({"wachtwoord": nieuw, "gebruiker": user.get_username()}) + + +_WW_ALFABET = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789" + + +def _genereer_wachtwoord(lengte: int = 10) -> str: + return "".join(secrets.choice(_WW_ALFABET) for _ in range(lengte)) + + +def _slug_gebruikersnaam(voornaam: str, achternaam: str) -> str: + basis = f"{voornaam}.{achternaam}".strip(".").lower() + basis = unicodedata.normalize("NFKD", basis).encode("ascii", "ignore").decode() + basis = re.sub(r"[^a-z0-9.]+", "", basis).strip(".") + return basis or "gebruiker" + + +def _uniek(basis: str, gebruikt: set) -> str: + naam = basis + i = 1 + while naam in gebruikt or User.objects.filter(username__iexact=naam).exists(): + i += 1 + naam = f"{basis}{i}" + gebruikt.add(naam) + return naam + + +@api_view(["POST"]) +@permission_classes([IsAdminUser]) +def gebruikers_import(request): + """Bulk: maak meerdere gebruikers met automatisch gegenereerd wachtwoord. + + Per rij optioneel een bestaande persoon koppelen (persoon-id) of een nieuwe + persoon aanmaken (maak_persoon). Geeft de gegenereerde wachtwoorden terug. + """ + from core.models import Persoon, Rol + + rijen = request.data.get("gebruikers") or [] + if not isinstance(rijen, list): + return Response({"detail": "gebruikers moet een lijst zijn."}, status=400) + geldige_rollen = {r.value for r in Gebruikersrol} + resultaat = [] + gebruikt = set() + for r in rijen: + vn = str(r.get("voornaam") or "").strip() + an = str(r.get("achternaam") or "").strip() + if not vn and not an: + continue + email = str(r.get("email") or "").strip() + rol = r.get("rol") if r.get("rol") in geldige_rollen else Gebruikersrol.MEDEWERKER + gn = str(r.get("gebruikersnaam") or "").strip() or _slug_gebruikersnaam(vn, an) + gn = _uniek(gn, gebruikt) + wachtwoord = _genereer_wachtwoord() + user = User(username=gn, email=email, first_name=vn, last_name=an) + user.set_password(wachtwoord) + user.save() + persoon = None + pid = r.get("persoon") + if pid: + persoon = Persoon.objects.filter(pk=pid).first() + elif r.get("maak_persoon"): + prol = Rol.LEERLING if rol == Gebruikersrol.LEERLING else Rol.LEERKRACHT + persoon = Persoon.objects.create(voornaam=vn, achternaam=an, rol=prol) + Gebruikersprofiel.objects.create(user=user, rol=rol, persoon=persoon) + resultaat.append({ + "gebruikersnaam": gn, + "wachtwoord": wachtwoord, + "naam": f"{vn} {an}".strip(), + "email": email, + "rol": rol, + "persoon_naam": persoon.volledige_naam if persoon else None, + }) + return Response({"aangemaakt": len(resultaat), "gebruikers": resultaat}) diff --git a/docker-compose.yml b/docker-compose.yml index 702f0c7..377614a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,6 +37,14 @@ services: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_HOST: db POSTGRES_PORT: 5432 + DJANGO_EMAIL_BACKEND: ${DJANGO_EMAIL_BACKEND:-django.core.mail.backends.console.EmailBackend} + EMAIL_HOST: ${EMAIL_HOST:-} + EMAIL_PORT: ${EMAIL_PORT:-587} + EMAIL_HOST_USER: ${EMAIL_HOST_USER:-} + EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-} + EMAIL_USE_TLS: ${EMAIL_USE_TLS:-1} + DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL:-Roosterwijs } + FRONTEND_BASE_URL: ${FRONTEND_BASE_URL:-} volumes: # Verzamelde statics delen met nginx. - static_volume:/app/staticfiles diff --git a/frontend/src/api.js b/frontend/src/api.js index 22413a6..044ebcc 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -87,3 +87,4 @@ export async function uploadBestand(resource, id, veld, file) { export const wachtwoordVergeten = (identifier) => request("POST", "/auth/wachtwoord-vergeten/", { identifier }); export const wachtwoordReset = (uid, token, wachtwoord) => request("POST", "/auth/wachtwoord-reset/", { uid, token, wachtwoord }); export const adminWachtwoordReset = (id, wachtwoord) => request("POST", `/gebruikers/${id}/wachtwoord/`, wachtwoord ? { wachtwoord } : {}); +export const gebruikersImport = (gebruikers) => request("POST", "/gebruikers-import/", { gebruikers }); diff --git a/frontend/src/pages.jsx b/frontend/src/pages.jsx index daafe3c..65a0211 100644 --- a/frontend/src/pages.jsx +++ b/frontend/src/pages.jsx @@ -10,6 +10,7 @@ import { uploadBestand, wachtwoordReset, adminWachtwoordReset, + gebruikersImport, } from "./api.js"; const ROLLEN = [ @@ -1401,6 +1402,7 @@ export function GebruikersPage() { const [form, setForm] = useState(leeg); const [editId, setEditId] = useState(null); const [gegenereerd, setGegenereerd] = useState(null); + const [view, setView] = useState("beheer"); useEffect(() => { listResource("personen").then(setPersonen).catch(() => {}); }, []); @@ -1462,6 +1464,11 @@ export function GebruikersPage() {

Beheer inloggers en hun rol; koppel een login aan een persoon. Alleen beheerders kunnen dit scherm gebruiken.

{gegenereerd &&
Tijdelijk wachtwoord: {gegenereerd} — geef dit door aan de gebruiker; laat het daarna wijzigen.
} +
+ + +
+ {view === "import" ? : (<>
setForm({ ...form, username: e.target.value })} /> @@ -1509,6 +1516,7 @@ export function GebruikersPage() { )} + )} ); } @@ -3409,3 +3417,146 @@ export function WachtwoordResetPage({ uid, token }) {
); } + + +/* -------------------- Gebruikers: bulk-import-wizard ---------------------- */ +function parseGebruikers(tekst) { + const regels = tekst.split(/\r?\n/).map((r) => r.trim()).filter(Boolean); + if (regels.length === 0) return []; + const sep = detecteerScheiding(regels[0]); + const splits = (r) => r.split(sep).map((c) => c.trim()); + const eerste = splits(regels[0]).map((c) => c.toLowerCase()); + const header = eerste.some((c) => ["voornaam", "achternaam", "naam", "email", "e-mail", "rol"].includes(c)); + const kol = header ? eerste : null; + const rows = []; + for (let i = header ? 1 : 0; i < regels.length; i++) { + const c = splits(regels[i]); + let voornaam = "", achternaam = "", email = ""; + if (kol) { + const idx = (n) => kol.indexOf(n); + if (idx("voornaam") >= 0) voornaam = c[idx("voornaam")] || ""; + if (idx("achternaam") >= 0) achternaam = c[idx("achternaam")] || ""; + const em = idx("email") >= 0 ? idx("email") : idx("e-mail"); + if (em >= 0) email = c[em] || ""; + if (!voornaam && !achternaam && idx("naam") >= 0) { const d = (c[idx("naam")] || "").split(" "); voornaam = d.shift() || ""; achternaam = d.join(" "); } + } else if (c.length === 1) { + const d = c[0].split(" "); voornaam = d.shift() || ""; achternaam = d.join(" "); + } else { + voornaam = c[0] || ""; achternaam = c[1] || ""; if (c[2] && c[2].includes("@")) email = c[2]; + } + if (voornaam || achternaam) rows.push({ voornaam, achternaam, email }); + } + return rows; +} +function naamScore(rij, persoonNaam) { + const a = `${rij.voornaam} ${rij.achternaam}`.trim().toLowerCase(); + const b = (persoonNaam || "").toLowerCase(); + if (!a || !b) return 0; + if (a === b) return 100; + const ta = a.split(/\s+/).filter(Boolean), tb = b.split(/\s+/).filter(Boolean); + let overlap = 0; ta.forEach((t) => { if (tb.includes(t)) overlap++; }); + let score = overlap * 10; + if (b.includes(a) || a.includes(b)) score += 3; + if (ta.length && tb.length && ta[ta.length - 1] === tb[tb.length - 1]) score += 5; + return score; +} + +export function GebruikersImportWizard({ personen, onKlaar }) { + const [tekst, setTekst] = useState(""); + const [rol, setRol] = useState("medewerker"); + const [koppel, setKoppel] = useState({}); + const [resultaat, setResultaat] = useState(null); + const [bezig, setBezig] = useState(false); + const [error, setError] = useState(null); + + const rijen = parseGebruikers(tekst); + const suggesties = (rij) => personen + .map((p) => ({ p, s: naamScore(rij, p.volledige_naam) })) + .filter((x) => x.s > 0).sort((a, b) => b.s - a.s).slice(0, 5); + const defaultKoppel = (rij) => { const top = suggesties(rij)[0]; return top && top.s >= 10 ? String(top.p.id) : "_new"; }; + const koppelVan = (i, rij) => (koppel[i] !== undefined ? koppel[i] : defaultKoppel(rij)); + + async function importeer() { + setBezig(true); setError(null); + try { + const payload = rijen.map((rij, i) => { + const k = koppelVan(i, rij); + return { voornaam: rij.voornaam, achternaam: rij.achternaam, email: rij.email, rol, + persoon: k && k !== "_new" ? Number(k) : null, maak_persoon: k === "_new" }; + }); + const r = await gebruikersImport(payload); + setResultaat(r.gebruikers || []); + setTekst(""); setKoppel({}); + if (onKlaar) onKlaar(); + } catch (e) { setError(e.message); } + finally { setBezig(false); } + } + const kopieer = (t) => { try { navigator.clipboard.writeText(t); } catch (e) { /* niet beschikbaar */ } }; + + if (resultaat) { + return ( +
+
{resultaat.length} gebruiker(s) aangemaakt. Noteer of kopieer de wachtwoorden nu — ze worden hierna niet meer getoond.
+ + + + {resultaat.map((g, i) => ( + + + + + + + ))} + +
GebruikersnaamWachtwoordPersoon
{g.gebruikersnaam}{g.wachtwoord}{g.persoon_naam || "—"}
+
+ + +
+
+ ); + } + return ( +
+ +

Plak rijen of upload een CSV. Kolommen: voornaam, achternaam, e-mail (komma, puntkomma of tab; koprij optioneel). Wachtwoorden worden automatisch gegenereerd; per persoon wordt een gelijkende bestaande persoon voorgesteld.

+
+ + +
+