e-mailconfig + bulk-import gebruikers met wachtwoorden en persoonvoorstel
This commit is contained in:
parent
e38eacefcb
commit
f40918c077
8 changed files with 282 additions and 0 deletions
14
.env.example
14
.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 <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=
|
||||
|
|
|
|||
23
DEPLOY.md
23
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 <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:
|
||||
|
|
|
|||
|
|
@ -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/)
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue