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_DB=rooster
POSTGRES_USER=rooster POSTGRES_USER=rooster
POSTGRES_PASSWORD=vervang-mij-door-een-sterk-wachtwoord 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) # Draaien op de test-server (Docker)
De stack bestaat uit drie containers: De stack bestaat uit drie containers:

View file

@ -87,6 +87,15 @@ volgt.
- [x] **Hiërarchie vastgelegd:** subgroep → groep (Subgroep.groep) en - [x] **Hiërarchie vastgelegd:** subgroep → groep (Subgroep.groep) en
groep → leerplein (Leerplein.groepen). 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 ## Module: Urenoverzicht
- [x] **Module die berekent hoeveel uur een groep per vak krijgt.** (module `uren`, default uit; endpoint /api/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-vergeten/", views.wachtwoord_vergeten, name="wachtwoord-vergeten"),
path("auth/wachtwoord-reset/", views.wachtwoord_reset, name="wachtwoord-reset"), path("auth/wachtwoord-reset/", views.wachtwoord_reset, name="wachtwoord-reset"),
path("gebruikers/<int:pk>/wachtwoord/", views.wachtwoord_zet, name="wachtwoord-zet"), path("gebruikers/<int:pk>/wachtwoord/", views.wachtwoord_zet, name="wachtwoord-zet"),
path("gebruikers-import/", views.gebruikers_import, name="gebruikers-import"),
path("", include(router.urls)), path("", include(router.urls)),
] ]

View file

@ -3,7 +3,9 @@
De login-endpoints horen hier thuis (de module is in de praktijk altijd aan). De login-endpoints horen hier thuis (de module is in de praktijk altijd aan).
""" """
import logging import logging
import re
import secrets import secrets
import unicodedata
from django.contrib.auth import authenticate, get_user_model from django.contrib.auth import authenticate, get_user_model
from django.contrib.auth import login as auth_login 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.permissions import AllowAny, IsAdminUser
from rest_framework.response import Response from rest_framework.response import Response
from .models import Gebruikersprofiel, Gebruikersrol
from .serializers import GebruikerSerializer from .serializers import GebruikerSerializer
logger = logging.getLogger("roosterwijs.auth") logger = logging.getLogger("roosterwijs.auth")
@ -145,3 +148,75 @@ def wachtwoord_zet(request, pk):
user.save() user.save()
logger.info("Beheerder %r zette nieuw wachtwoord voor %r", request.user.get_username(), user.get_username()) logger.info("Beheerder %r zette nieuw wachtwoord voor %r", request.user.get_username(), user.get_username())
return Response({"wachtwoord": nieuw, "gebruiker": 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_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_HOST: db POSTGRES_HOST: db
POSTGRES_PORT: 5432 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: volumes:
# Verzamelde statics delen met nginx. # Verzamelde statics delen met nginx.
- static_volume:/app/staticfiles - 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 wachtwoordVergeten = (identifier) => request("POST", "/auth/wachtwoord-vergeten/", { identifier });
export const wachtwoordReset = (uid, token, wachtwoord) => request("POST", "/auth/wachtwoord-reset/", { uid, token, wachtwoord }); 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 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, uploadBestand,
wachtwoordReset, wachtwoordReset,
adminWachtwoordReset, adminWachtwoordReset,
gebruikersImport,
} from "./api.js"; } from "./api.js";
const ROLLEN = [ const ROLLEN = [
@ -1401,6 +1402,7 @@ export function GebruikersPage() {
const [form, setForm] = useState(leeg); const [form, setForm] = useState(leeg);
const [editId, setEditId] = useState(null); const [editId, setEditId] = useState(null);
const [gegenereerd, setGegenereerd] = useState(null); const [gegenereerd, setGegenereerd] = useState(null);
const [view, setView] = useState("beheer");
useEffect(() => { listResource("personen").then(setPersonen).catch(() => {}); }, []); 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> <p className="muted">Beheer inloggers en hun rol; koppel een login aan een persoon. Alleen beheerders kunnen dit scherm gebruiken.</p>
<Foutmelding msg={error} /> <Foutmelding msg={error} />
{gegenereerd && <div className="banner ok">Tijdelijk wachtwoord: <code>{gegenereerd}</code> geef dit door aan de gebruiker; laat het daarna wijzigen.</div>} {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}> <form className="form-card" onSubmit={opslaan}>
<div className="form-row"> <div className="form-row">
<input placeholder="Gebruikersnaam" value={form.username} required onChange={(e) => setForm({ ...form, username: e.target.value })} /> <input placeholder="Gebruikersnaam" value={form.username} required onChange={(e) => setForm({ ...form, username: e.target.value })} />
@ -1509,6 +1516,7 @@ export function GebruikersPage() {
</tbody> </tbody>
</table> </table>
)} )}
</>)}
</section> </section>
); );
} }
@ -3409,3 +3417,146 @@ export function WachtwoordResetPage({ uid, token }) {
</div> </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>
);
}