e-mailconfig + bulk-import gebruikers met wachtwoorden en persoonvoorstel

This commit is contained in:
bes-r 2026-06-17 15:25:55 +02:00
parent e38eacefcb
commit f40918c077
8 changed files with 282 additions and 0 deletions

View file

@ -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 <noreply@jouwschool.nl>
# 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=

View file

@ -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 <noreply@jouwschool.nl>
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:

View file

@ -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/)

View file

@ -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/<int:pk>/wachtwoord/", views.wachtwoord_zet, name="wachtwoord-zet"),
path("gebruikers-import/", views.gebruikers_import, name="gebruikers-import"),
path("", include(router.urls)),
]

View file

@ -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})

View file

@ -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 <noreply@roosterwijs.local>}
FRONTEND_BASE_URL: ${FRONTEND_BASE_URL:-}
volumes:
# Verzamelde statics delen met nginx.
- static_volume:/app/staticfiles

View file

@ -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 });

View file

@ -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() {
<p className="muted">Beheer inloggers en hun rol; koppel een login aan een persoon. Alleen beheerders kunnen dit scherm gebruiken.</p>
<Foutmelding msg={error} />
{gegenereerd && <div className="banner ok">Tijdelijk wachtwoord: <code>{gegenereerd}</code> geef dit door aan de gebruiker; laat het daarna wijzigen.</div>}
<div className="tabbar">
<button className={view === "beheer" ? "tab on" : "tab"} onClick={() => setView("beheer")}>Beheer</button>
<button className={view === "import" ? "tab on" : "tab"} onClick={() => setView("import")}>📋 Bulk import</button>
</div>
{view === "import" ? <GebruikersImportWizard personen={personen} onKlaar={reload} /> : (<>
<form className="form-card" onSubmit={opslaan}>
<div className="form-row">
<input placeholder="Gebruikersnaam" value={form.username} required onChange={(e) => setForm({ ...form, username: e.target.value })} />
@ -1509,6 +1516,7 @@ export function GebruikersPage() {
</tbody>
</table>
)}
</>)}
</section>
);
}
@ -3409,3 +3417,146 @@ export function WachtwoordResetPage({ uid, token }) {
</div>
);
}
/* -------------------- 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 (
<div>
<div className="banner ok">{resultaat.length} gebruiker(s) aangemaakt. Noteer of kopieer de wachtwoorden nu ze worden hierna niet meer getoond.</div>
<table className="grid">
<thead><tr><th>Gebruikersnaam</th><th>Wachtwoord</th><th>Persoon</th><th></th></tr></thead>
<tbody>
{resultaat.map((g, i) => (
<tr key={i}>
<td>{g.gebruikersnaam}</td>
<td><code>{g.wachtwoord}</code></td>
<td className="muted">{g.persoon_naam || "—"}</td>
<td className="row-actions"><button type="button" className="link small" onClick={() => kopieer(`${g.gebruikersnaam}\t${g.wachtwoord}`)}>kopieer</button></td>
</tr>
))}
</tbody>
</table>
<div className="form-row">
<button type="button" className="btn" onClick={() => kopieer(resultaat.map((g) => `${g.gebruikersnaam}\t${g.wachtwoord}`).join("\n"))}>Alles kopiëren</button>
<button type="button" className="btn primary" onClick={() => setResultaat(null)}>Nog een import</button>
</div>
</div>
);
}
return (
<div className="form-card">
<Foutmelding msg={error} />
<p className="muted small">Plak rijen of upload een CSV. Kolommen: <b>voornaam, achternaam, e-mail</b> (komma, puntkomma of tab; koprij optioneel). Wachtwoorden worden automatisch gegenereerd; per persoon wordt een gelijkende bestaande persoon voorgesteld.</p>
<div className="form-row">
<label className="veld-kol"><span className="veld-kop">Rol voor iedereen</span>
<select value={rol} onChange={(e) => setRol(e.target.value)}>
{GEBRUIKERSROLLEN.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)}
</select>
</label>
<label className="link small">CSV-bestand kiezen<input type="file" accept=".csv,.txt,text/csv" style={{ display: "none" }} onChange={(e) => { const f = e.target.files[0]; if (f) { const rd = new FileReader(); rd.onload = () => setTekst(String(rd.result || "")); rd.readAsText(f); } e.target.value = ""; }} /></label>
</div>
<textarea className="bulk-invoer" rows={5} placeholder={"Jan;Jansen;jan@school.nl\nPiet;Pietersen;piet@school.nl"} value={tekst} onChange={(e) => setTekst(e.target.value)} />
{rijen.length > 0 && (
<table className="grid">
<thead><tr><th>Naam</th><th>E-mail</th><th>Koppelen aan persoon</th></tr></thead>
<tbody>
{rijen.map((rij, i) => {
const sug = suggesties(rij);
return (
<tr key={i}>
<td>{rij.voornaam} {rij.achternaam}</td>
<td className="muted small">{rij.email || "—"}</td>
<td>
<select value={koppelVan(i, rij)} onChange={(e) => setKoppel((k) => ({ ...k, [i]: e.target.value }))}>
<option value="_new">+ nieuwe persoon aanmaken</option>
<option value=""> niet koppelen </option>
{sug.map(({ p }) => <option key={p.id} value={p.id}>{p.volledige_naam} lijkt hierop</option>)}
{personen.filter((p) => !sug.some((x) => x.p.id === p.id)).map((p) => <option key={p.id} value={p.id}>{p.volledige_naam}</option>)}
</select>
</td>
</tr>
);
})}
</tbody>
</table>
)}
<div className="form-row">
<button type="button" className="btn primary" disabled={rijen.length === 0 || bezig} onClick={importeer}>{bezig ? "Bezig…" : `Importeer ${rijen.length} gebruiker(s)`}</button>
</div>
</div>
);
}