schulsync 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
schulsync/planner.py ADDED
@@ -0,0 +1,172 @@
1
+ """Der Planner: Soll/Ist-Vergleich nach dem Terraform-Prinzip.
2
+
3
+ Eingaben:
4
+ * Soll-Zustand – Personen aus dem Schulverwaltungs-Export
5
+ * Ist-Zustand – von SchulSync verwaltete Konten aus dem AD
6
+
7
+ Ausgabe: ein :class:`~schulsync.models.Plan` mit fünf Aktionsarten.
8
+ Der Planner **verändert nichts** – er rechnet nur. Erst ``apply``
9
+ führt den Plan aus. Dadurch ist jeder Lauf per Diff-Report
10
+ nachvollziehbar und revisionssicher dokumentierbar.
11
+
12
+ Sicherheitsnetz: Wenn der Export auffällig leer ist (würde mehr als
13
+ ``max_deaktivierungs_anteil`` der aktiven Konten deaktivieren), wird
14
+ der Plan als Konflikt blockiert. Ein halber Export – falsche
15
+ Berichtsvorlage, abgebrochener Download – soll nicht still eine
16
+ halbe Schule deaktivieren.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from datetime import date, timedelta
22
+
23
+ from .models import (
24
+ Anlegen,
25
+ Deaktivieren,
26
+ IstKonto,
27
+ Konflikt,
28
+ Loeschen,
29
+ Plan,
30
+ Reaktivieren,
31
+ SollPerson,
32
+ Umbenennen,
33
+ Verschieben,
34
+ )
35
+ from .usernames import erzeuge_benutzername
36
+
37
+ MAX_DEAKTIVIERUNGS_ANTEIL = 0.5 # Notbremse gegen unvollständige Exporte
38
+
39
+
40
+ def berechne_plan(
41
+ soll: list[SollPerson],
42
+ ist: list[IstKonto],
43
+ vergebene_benutzernamen: set[str],
44
+ benutzername_schema: str = "{vorname}.{nachname}",
45
+ notbremse: bool = True,
46
+ ) -> Plan:
47
+ """Berechnet den Plan. Deterministisch: gleiche Eingaben ⇒ gleicher Plan."""
48
+ plan = Plan()
49
+ ist_nach_id: dict[str, IstKonto] = {}
50
+
51
+ for konto in ist:
52
+ if not konto.quell_id:
53
+ plan.konflikte.append(
54
+ Konflikt(
55
+ betrifft=konto.benutzername,
56
+ grund=(
57
+ "Konto liegt im SchulSync-Bereich, hat aber keine Quell-ID "
58
+ "(employeeNumber). Manuell zuordnen oder herausverschieben."
59
+ ),
60
+ )
61
+ )
62
+ continue
63
+ if konto.quell_id in ist_nach_id:
64
+ plan.konflikte.append(
65
+ Konflikt(
66
+ betrifft=f"{konto.benutzername} / {ist_nach_id[konto.quell_id].benutzername}",
67
+ grund=f"Quell-ID {konto.quell_id} ist im AD doppelt vergeben.",
68
+ )
69
+ )
70
+ continue
71
+ ist_nach_id[konto.quell_id] = konto
72
+
73
+ # Namen, die dieser Plan selbst vergibt, zählen sofort als belegt:
74
+ belegt = {b.lower() for b in vergebene_benutzernamen}
75
+ soll_ids: set[str] = set()
76
+
77
+ for person in sorted(soll, key=lambda p: (p.klasse or "", p.nachname, p.vorname, p.quell_id)):
78
+ soll_ids.add(person.quell_id)
79
+ konto = ist_nach_id.get(person.quell_id)
80
+
81
+ if konto is None:
82
+ # ---------------------------------------------------- ANLEGEN
83
+ try:
84
+ benutzername = erzeuge_benutzername(
85
+ person.vorname, person.nachname, benutzername_schema, belegt
86
+ )
87
+ except ValueError as e:
88
+ plan.konflikte.append(Konflikt(betrifft=person.anzeigename, grund=str(e)))
89
+ continue
90
+ belegt.add(benutzername.lower())
91
+ plan.anlegen.append(Anlegen(person=person, benutzername=benutzername))
92
+ continue
93
+
94
+ veraendert = False
95
+
96
+ # ------------------------------------------------- REAKTIVIEREN
97
+ if not konto.aktiv:
98
+ plan.reaktivieren.append(Reaktivieren(konto=konto, nach_klasse=person.klasse))
99
+ veraendert = True
100
+ # -------------------------------------------------- VERSCHIEBEN
101
+ elif person.klasse != konto.klasse:
102
+ plan.verschieben.append(
103
+ Verschieben(konto=konto, von_klasse=konto.klasse, nach_klasse=person.klasse or "")
104
+ )
105
+ veraendert = True
106
+
107
+ # --------------------------------------------------- UMBENENNEN
108
+ if (person.vorname, person.nachname) != (konto.vorname, konto.nachname):
109
+ plan.umbenennen.append(
110
+ Umbenennen(
111
+ konto=konto,
112
+ neuer_vorname=person.vorname,
113
+ neuer_nachname=person.nachname,
114
+ )
115
+ )
116
+ veraendert = True
117
+
118
+ if not veraendert:
119
+ plan.unveraendert += 1
120
+
121
+ # ------------------------------------------------------ DEAKTIVIEREN
122
+ aktive = [k for k in ist_nach_id.values() if k.aktiv]
123
+ abgaenger = sorted(
124
+ (k for k in aktive if k.quell_id not in soll_ids),
125
+ key=lambda k: (k.klasse or "", k.nachname, k.vorname),
126
+ )
127
+ plan.deaktivieren.extend(Deaktivieren(konto=k) for k in abgaenger)
128
+
129
+ # Notbremse: unvollständiger Export?
130
+ if (
131
+ notbremse
132
+ and aktive
133
+ and len(abgaenger) / len(aktive) > MAX_DEAKTIVIERUNGS_ANTEIL
134
+ and len(abgaenger) > 5
135
+ ):
136
+ plan.konflikte.append(
137
+ Konflikt(
138
+ betrifft=f"{len(abgaenger)} von {len(aktive)} aktiven Konten",
139
+ grund=(
140
+ "Der Export würde mehr als die Hälfte aller aktiven Konten "
141
+ "deaktivieren. Das deutet auf einen unvollständigen Export hin "
142
+ "(falsche Berichtsvorlage? nur eine Klasse exportiert?). "
143
+ "Bitte Export prüfen. Wenn es wirklich stimmt: --ohne-notbremse."
144
+ ),
145
+ )
146
+ )
147
+
148
+ return plan
149
+
150
+
151
+ def berechne_cleanup(
152
+ ist: list[IstKonto],
153
+ heute: date,
154
+ frist_tage: int,
155
+ ) -> tuple[list[Loeschen], list[IstKonto]]:
156
+ """Findet Abgänger-Konten, deren Aufbewahrungsfrist abgelaufen ist.
157
+
158
+ Rückgabe: (endgültig zu löschen, noch in der Frist).
159
+ Konten ohne lesbaren Deaktivierungs-Stempel werden **nie** gelöscht.
160
+ """
161
+ loeschen: list[Loeschen] = []
162
+ wartend: list[IstKonto] = []
163
+ for konto in sorted(ist, key=lambda k: (k.nachname, k.vorname)):
164
+ if konto.aktiv or konto.deaktiviert_am is None:
165
+ continue
166
+ if heute >= konto.deaktiviert_am + timedelta(days=frist_tage):
167
+ loeschen.append(
168
+ Loeschen(konto=konto, deaktiviert_am=konto.deaktiviert_am, frist_tage=frist_tage)
169
+ )
170
+ else:
171
+ wartend.append(konto)
172
+ return loeschen, wartend
schulsync/report.py ADDED
@@ -0,0 +1,94 @@
1
+ """HTML-Diff-Report und Zugangsdaten-Briefe (Jinja2)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+
9
+ from jinja2 import Environment, PackageLoader, select_autoescape
10
+
11
+ from . import __version__
12
+ from .applier import NeuesKonto
13
+ from .config import Config
14
+ from .models import Plan, Rolle
15
+
16
+ _env = Environment(
17
+ loader=PackageLoader("schulsync", "templates"),
18
+ autoescape=select_autoescape(["html"]),
19
+ )
20
+
21
+
22
+ def schreibe_report(
23
+ plan: Plan,
24
+ config: Config,
25
+ quelle: str,
26
+ pfad: Path,
27
+ zeitpunkt: datetime,
28
+ modus: str,
29
+ ) -> Path:
30
+ html = _env.get_template("report.html.j2").render(
31
+ plan=plan,
32
+ schule=config.schule.name,
33
+ quelle=quelle,
34
+ ziel=config.ldap.base_dn,
35
+ zeitpunkt=zeitpunkt.strftime("%d.%m.%Y %H:%M"),
36
+ modus=modus,
37
+ frist_tage=config.aufbewahrung.abgaenger_tage,
38
+ version=__version__,
39
+ )
40
+ pfad = Path(pfad)
41
+ pfad.write_text(html, encoding="utf-8")
42
+ return pfad
43
+
44
+
45
+ def lese_zugangsdaten(pfad: Path) -> list[NeuesKonto]:
46
+ """Liest die von ``apply`` geschriebene Zugangsdaten-CSV wieder ein."""
47
+ zeilen = [
48
+ z for z in Path(pfad).read_text(encoding="utf-8").splitlines()
49
+ if z and not z.startswith("#")
50
+ ]
51
+ leser = csv.DictReader(zeilen, delimiter=";")
52
+ konten = []
53
+ for z in leser:
54
+ konten.append(
55
+ NeuesKonto(
56
+ benutzername=z["Benutzername"],
57
+ anzeigename=z["Name"],
58
+ klasse=z["Klasse"] or None,
59
+ rolle=Rolle(z["Rolle"]),
60
+ passwort=z["Initialpasswort"],
61
+ )
62
+ )
63
+ return konten
64
+
65
+
66
+ def schreibe_briefe(
67
+ konten: list[NeuesKonto],
68
+ config: Config,
69
+ ziel_ordner: Path,
70
+ datum: str,
71
+ ) -> list[Path]:
72
+ """Erzeugt pro Klasse eine druckfertige HTML-Datei mit einem Brief
73
+ pro Konto (Seitenumbruch je Brief)."""
74
+ ziel_ordner = Path(ziel_ordner)
75
+ ziel_ordner.mkdir(parents=True, exist_ok=True)
76
+ vorlage = _env.get_template("briefe.html.j2")
77
+
78
+ nach_klasse: dict[str, list[NeuesKonto]] = {}
79
+ for k in sorted(konten, key=lambda k: (k.klasse or "", k.anzeigename)):
80
+ nach_klasse.setdefault(k.klasse or "ohne-Klasse", []).append(k)
81
+
82
+ dateien: list[Path] = []
83
+ for klasse, klassen_konten in nach_klasse.items():
84
+ html = vorlage.render(
85
+ konten=klassen_konten,
86
+ klasse=klasse,
87
+ schule=config.schule.name,
88
+ it_kontakt=config.schule.it_kontakt,
89
+ datum=datum,
90
+ )
91
+ datei = ziel_ordner / f"zugangsdaten-{klasse}.html"
92
+ datei.write_text(html, encoding="utf-8")
93
+ dateien.append(datei)
94
+ return dateien
@@ -0,0 +1,56 @@
1
+ <!DOCTYPE html>
2
+ <html lang="de">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>Zugangsdaten {{ klasse }} · {{ schule }}</title>
6
+ <style>
7
+ * { box-sizing: border-box; }
8
+ body { font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
9
+ color: #1a2332; margin: 0; line-height: 1.55; }
10
+ .brief { padding: 2.2cm 2cm; page-break-after: always; max-width: 21cm; margin: 0 auto; }
11
+ .brief:last-child { page-break-after: auto; }
12
+ .kopf { display: flex; justify-content: space-between; border-bottom: 2px solid #1a2332;
13
+ padding-bottom: .4rem; margin-bottom: 1.6rem; font-size: .9rem; color: #5c6675; }
14
+ h1 { font-size: 1.25rem; margin: 0 0 1rem; }
15
+ .zugang { border: 2px solid #1a2332; border-radius: .6rem; padding: 1rem 1.4rem;
16
+ margin: 1.2rem 0; display: grid; grid-template-columns: auto 1fr; gap: .35rem 1.5rem; }
17
+ .zugang dt { color: #5c6675; font-size: .85rem; align-self: center; }
18
+ .zugang dd { margin: 0; font-family: ui-monospace, Consolas, monospace;
19
+ font-size: 1.15rem; font-weight: 600; }
20
+ ol { padding-left: 1.2rem; } li { margin-bottom: .3rem; }
21
+ .fuss { margin-top: 1.6rem; font-size: .8rem; color: #5c6675;
22
+ border-top: 1px solid #e3e7ee; padding-top: .6rem; }
23
+ @media screen { .brief { border: 1px solid #e3e7ee; margin: 1rem auto; border-radius: .5rem; } }
24
+ </style>
25
+ </head>
26
+ <body>
27
+ {% for k in konten %}
28
+ <div class="brief">
29
+ <div class="kopf">
30
+ <span><strong>{{ schule }}</strong> · IT</span>
31
+ <span>Klasse {{ k.klasse or "–" }} · {{ datum }}</span>
32
+ </div>
33
+ <h1>Dein Schul-Account, {{ k.anzeigename.split(" ")[0] }}!</h1>
34
+ <p>Hier sind deine Zugangsdaten für die Anmeldung an den Schulcomputern
35
+ und am WLAN. Bewahre dieses Blatt gut auf und zeige es niemandem.</p>
36
+ <dl class="zugang">
37
+ <dt>Name</dt><dd style="font-family: inherit; font-weight: 400;">{{ k.anzeigename }}</dd>
38
+ <dt>Benutzername</dt><dd>{{ k.benutzername }}</dd>
39
+ <dt>Startpasswort</dt><dd>{{ k.passwort }}</dd>
40
+ </dl>
41
+ <p><strong>Wichtig – deine ersten Schritte:</strong></p>
42
+ <ol>
43
+ <li>Melde dich mit Benutzername und Startpasswort an einem Schulcomputer an.</li>
44
+ <li>Du wirst sofort aufgefordert, ein <strong>eigenes neues Passwort</strong> zu wählen.
45
+ Das Startpasswort funktioniert danach nicht mehr.</li>
46
+ <li>Merke dir dein neues Passwort gut – bei Problemen hilft: {{ it_kontakt }}.</li>
47
+ </ol>
48
+ <div class="fuss">
49
+ Hinweis zum Datenschutz: Dein Konto wird automatisch aus den Daten der
50
+ Schulverwaltung erstellt und beim Verlassen der Schule wieder deaktiviert
51
+ und gelöscht. Fragen dazu beantwortet {{ it_kontakt }}.
52
+ </div>
53
+ </div>
54
+ {% endfor %}
55
+ </body>
56
+ </html>
@@ -0,0 +1,206 @@
1
+ <!DOCTYPE html>
2
+ <html lang="de">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>SchulSync-Plan · {{ schule }} · {{ zeitpunkt }}</title>
7
+ <style>
8
+ :root {
9
+ --tinte: #1a2332; --grau: #5c6675; --linie: #e3e7ee; --papier: #f6f8fb;
10
+ --gruen: #1a7f4e; --gruen-bg: #e7f6ee;
11
+ --blau: #1f5fbf; --blau-bg: #e9f1fd;
12
+ --gelb: #9a6700; --gelb-bg: #fdf3d7;
13
+ --rot: #b3362c; --rot-bg: #fdecea;
14
+ --lila: #6f42c1; --lila-bg: #f1ebfb;
15
+ }
16
+ * { box-sizing: border-box; }
17
+ body {
18
+ font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
19
+ color: var(--tinte); background: var(--papier);
20
+ margin: 0; padding: 2.5rem 1.5rem; line-height: 1.5;
21
+ }
22
+ .blatt { max-width: 60rem; margin: 0 auto; }
23
+ header { display: flex; justify-content: space-between; align-items: baseline;
24
+ flex-wrap: wrap; gap: .5rem; border-bottom: 3px solid var(--tinte);
25
+ padding-bottom: .75rem; margin-bottom: 1.5rem; }
26
+ h1 { font-size: 1.5rem; margin: 0; }
27
+ h1 small { color: var(--grau); font-weight: 400; }
28
+ .meta { color: var(--grau); font-size: .9rem; text-align: right; }
29
+ .karten { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr));
30
+ gap: .75rem; margin-bottom: 2rem; }
31
+ .karte { background: #fff; border: 1px solid var(--linie); border-radius: .6rem;
32
+ padding: .9rem 1rem; }
33
+ .karte .zahl { font-size: 1.9rem; font-weight: 700; line-height: 1.1; }
34
+ .karte .label { font-size: .8rem; color: var(--grau); text-transform: uppercase;
35
+ letter-spacing: .04em; }
36
+ .karte.gruen .zahl { color: var(--gruen); } .karte.blau .zahl { color: var(--blau); }
37
+ .karte.gelb .zahl { color: var(--gelb); } .karte.rot .zahl { color: var(--rot); }
38
+ .karte.lila .zahl { color: var(--lila); }
39
+ section { background: #fff; border: 1px solid var(--linie); border-radius: .6rem;
40
+ margin-bottom: 1.25rem; overflow: hidden; }
41
+ section > h2 { font-size: 1rem; margin: 0; padding: .7rem 1rem; display: flex;
42
+ align-items: center; gap: .5rem; }
43
+ .marke { display: inline-block; min-width: 1.6rem; text-align: center; padding: .1rem .45rem;
44
+ border-radius: 99px; font-size: .8rem; font-weight: 700; }
45
+ section.gruen > h2 { background: var(--gruen-bg); } section.gruen .marke { background: var(--gruen); color:#fff; }
46
+ section.blau > h2 { background: var(--blau-bg); } section.blau .marke { background: var(--blau); color:#fff; }
47
+ section.gelb > h2 { background: var(--gelb-bg); } section.gelb .marke { background: var(--gelb); color:#fff; }
48
+ section.rot > h2 { background: var(--rot-bg); } section.rot .marke { background: var(--rot); color:#fff; }
49
+ section.lila > h2 { background: var(--lila-bg); } section.lila .marke { background: var(--lila); color:#fff; }
50
+ table { width: 100%; border-collapse: collapse; font-size: .92rem; }
51
+ th { text-align: left; color: var(--grau); font-weight: 600; font-size: .8rem;
52
+ text-transform: uppercase; letter-spacing: .03em; }
53
+ th, td { padding: .5rem 1rem; border-top: 1px solid var(--linie); }
54
+ tbody tr:hover { background: var(--papier); }
55
+ code { background: var(--papier); border: 1px solid var(--linie); border-radius: .3rem;
56
+ padding: .05rem .35rem; font-size: .88em; }
57
+ .pfeil { color: var(--grau); padding: 0 .3rem; }
58
+ .hinweis { background: #fff; border: 1px dashed var(--linie); border-radius: .6rem;
59
+ padding: .8rem 1rem; color: var(--grau); font-size: .88rem; }
60
+ .konflikt td:last-child { color: var(--rot); }
61
+ footer { margin-top: 2rem; color: var(--grau); font-size: .8rem;
62
+ border-top: 1px solid var(--linie); padding-top: .75rem; }
63
+ @media print {
64
+ body { background: #fff; padding: 0; }
65
+ section, .karte { border-color: #bbb; break-inside: avoid; }
66
+ }
67
+ </style>
68
+ </head>
69
+ <body>
70
+ <div class="blatt">
71
+ <header>
72
+ <h1>SchulSync-Plan <small>· {{ schule }}</small></h1>
73
+ <div class="meta">
74
+ {{ zeitpunkt }}<br>
75
+ Quelle: <code>{{ quelle }}</code> · Ziel: <code>{{ ziel }}</code><br>
76
+ Modus: <strong>{{ modus }}</strong>
77
+ </div>
78
+ </header>
79
+
80
+ <div class="karten">
81
+ <div class="karte gruen"><div class="zahl">{{ plan.anlegen | length }}</div><div class="label">Anlegen</div></div>
82
+ <div class="karte blau"><div class="zahl">{{ plan.verschieben | length }}</div><div class="label">Klassenwechsel</div></div>
83
+ <div class="karte gelb"><div class="zahl">{{ plan.umbenennen | length }}</div><div class="label">Umbenennen</div></div>
84
+ <div class="karte lila"><div class="zahl">{{ plan.reaktivieren | length }}</div><div class="label">Reaktivieren</div></div>
85
+ <div class="karte rot"><div class="zahl">{{ plan.deaktivieren | length }}</div><div class="label">Deaktivieren</div></div>
86
+ <div class="karte"><div class="zahl">{{ plan.unveraendert }}</div><div class="label">Unverändert</div></div>
87
+ </div>
88
+
89
+ {% if plan.konflikte %}
90
+ <section class="rot">
91
+ <h2><span class="marke">{{ plan.konflikte | length }}</span> Konflikte – bitte zuerst klären</h2>
92
+ <table>
93
+ <thead><tr><th>Betrifft</th><th>Grund</th></tr></thead>
94
+ <tbody>
95
+ {% for k in plan.konflikte %}
96
+ <tr class="konflikt"><td>{{ k.betrifft }}</td><td>{{ k.grund }}</td></tr>
97
+ {% endfor %}
98
+ </tbody>
99
+ </table>
100
+ </section>
101
+ {% endif %}
102
+
103
+ {% if plan.anlegen %}
104
+ <section class="gruen">
105
+ <h2><span class="marke">+</span> Anlegen ({{ plan.anlegen | length }})</h2>
106
+ <table>
107
+ <thead><tr><th>Name</th><th>Klasse</th><th>Benutzername</th><th>Rolle</th></tr></thead>
108
+ <tbody>
109
+ {% for a in plan.anlegen %}
110
+ <tr>
111
+ <td>{{ a.person.anzeigename }}</td>
112
+ <td>{{ a.person.klasse or "–" }}</td>
113
+ <td><code>{{ a.benutzername }}</code></td>
114
+ <td>{{ "Lehrkraft" if a.person.rolle.value == "lehrkraft" else "Schüler:in" }}</td>
115
+ </tr>
116
+ {% endfor %}
117
+ </tbody>
118
+ </table>
119
+ </section>
120
+ {% endif %}
121
+
122
+ {% if plan.verschieben %}
123
+ <section class="blau">
124
+ <h2><span class="marke">⇄</span> Klassenwechsel ({{ plan.verschieben | length }})</h2>
125
+ <table>
126
+ <thead><tr><th>Name</th><th>Benutzername</th><th>Wechsel</th></tr></thead>
127
+ <tbody>
128
+ {% for v in plan.verschieben %}
129
+ <tr>
130
+ <td>{{ v.konto.anzeigename }}</td>
131
+ <td><code>{{ v.konto.benutzername }}</code></td>
132
+ <td>{{ v.von_klasse or "–" }}<span class="pfeil">→</span><strong>{{ v.nach_klasse }}</strong></td>
133
+ </tr>
134
+ {% endfor %}
135
+ </tbody>
136
+ </table>
137
+ </section>
138
+ {% endif %}
139
+
140
+ {% if plan.umbenennen %}
141
+ <section class="gelb">
142
+ <h2><span class="marke">✎</span> Umbenennen ({{ plan.umbenennen | length }})</h2>
143
+ <table>
144
+ <thead><tr><th>Benutzername</th><th>Bisher</th><th>Neu</th></tr></thead>
145
+ <tbody>
146
+ {% for u in plan.umbenennen %}
147
+ <tr>
148
+ <td><code>{{ u.konto.benutzername }}</code></td>
149
+ <td>{{ u.konto.anzeigename }}</td>
150
+ <td><strong>{{ u.neuer_vorname }} {{ u.neuer_nachname }}</strong></td>
151
+ </tr>
152
+ {% endfor %}
153
+ </tbody>
154
+ </table>
155
+ </section>
156
+ {% endif %}
157
+
158
+ {% if plan.reaktivieren %}
159
+ <section class="lila">
160
+ <h2><span class="marke">↻</span> Reaktivieren – Rückkehrer ({{ plan.reaktivieren | length }})</h2>
161
+ <table>
162
+ <thead><tr><th>Name</th><th>Benutzername</th><th>Neue Klasse</th></tr></thead>
163
+ <tbody>
164
+ {% for r in plan.reaktivieren %}
165
+ <tr>
166
+ <td>{{ r.konto.anzeigename }}</td>
167
+ <td><code>{{ r.konto.benutzername }}</code></td>
168
+ <td><strong>{{ r.nach_klasse or "–" }}</strong></td>
169
+ </tr>
170
+ {% endfor %}
171
+ </tbody>
172
+ </table>
173
+ </section>
174
+ {% endif %}
175
+
176
+ {% if plan.deaktivieren %}
177
+ <section class="rot">
178
+ <h2><span class="marke">⏻</span> Deaktivieren – Abgänger ({{ plan.deaktivieren | length }})</h2>
179
+ <table>
180
+ <thead><tr><th>Name</th><th>Benutzername</th><th>Bisherige Klasse</th><th>Weiteres Vorgehen</th></tr></thead>
181
+ <tbody>
182
+ {% for d in plan.deaktivieren %}
183
+ <tr>
184
+ <td>{{ d.konto.anzeigename }}</td>
185
+ <td><code>{{ d.konto.benutzername }}</code></td>
186
+ <td>{{ d.konto.klasse or "–" }}</td>
187
+ <td>Abgänger-OU, Löschung nach {{ frist_tage }} Tagen (<code>schulsync cleanup</code>)</td>
188
+ </tr>
189
+ {% endfor %}
190
+ </tbody>
191
+ </table>
192
+ </section>
193
+ {% endif %}
194
+
195
+ {% if plan.leer and not plan.konflikte %}
196
+ <p class="hinweis">✔ Keine Abweichungen – Active Directory und Schulverwaltung sind synchron.</p>
197
+ {% endif %}
198
+
199
+ <footer>
200
+ Erzeugt von <strong>SchulSync {{ version }}</strong> am {{ zeitpunkt }}.
201
+ Dieses Dokument enthält personenbezogene Daten – vertraulich behandeln und
202
+ nach Gebrauch löschen (Art. 5 Abs. 1 lit. e DSGVO).
203
+ </footer>
204
+ </div>
205
+ </body>
206
+ </html>
schulsync/usernames.py ADDED
@@ -0,0 +1,91 @@
1
+ """Benutzernamen-Erzeugung.
2
+
3
+ Regeln, die sich im Schulbetrieb bewährt haben:
4
+
5
+ * Schema konfigurierbar (Standard ``vorname.nachname``), alles klein.
6
+ * Umlaute & Co. werden AD-tauglich transliteriert: ``Müller`` → ``mueller``,
7
+ ``Yılmaz`` → ``yilmaz``, ``Đorđević`` → ``dordevic``. Schulen sind
8
+ vielfältig – die Benutzernamen müssen es aushalten.
9
+ * ``sAMAccountName`` ist historisch auf **20 Zeichen** begrenzt – wird
10
+ gekürzt, bevor das AD einen kryptischen Fehler wirft.
11
+ * Kollisionen (zweite ``lena.schmidt``) bekommen einen Zähler: ``lena.schmidt2``.
12
+ * Deterministisch: gleicher Input ⇒ gleicher Benutzername.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ import unicodedata
19
+
20
+ SAM_MAX = 20 # historisches NetBIOS-Limit für sAMAccountName
21
+
22
+ # Gezielte Ersetzungen, die NFKD nicht (oder falsch) erledigt:
23
+ _ERSETZUNGEN = str.maketrans(
24
+ {
25
+ "ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss",
26
+ "Ä": "ae", "Ö": "oe", "Ü": "ue", "ẞ": "ss",
27
+ "ı": "i", "İ": "i", # Türkisch
28
+ "ð": "d", "Ð": "d",
29
+ "đ": "d", "Đ": "d", # Südslawisch
30
+ "ø": "oe", "Ø": "oe",
31
+ "æ": "ae", "Æ": "ae",
32
+ "œ": "oe", "Œ": "oe",
33
+ "þ": "th", "Þ": "th",
34
+ "ł": "l", "Ł": "l", # Polnisch
35
+ }
36
+ )
37
+
38
+
39
+ def transliteriere(text: str) -> str:
40
+ """Wandelt einen Namen in reines ASCII-Kleinbuchstaben-Format um."""
41
+ text = text.translate(_ERSETZUNGEN)
42
+ # NFKD zerlegt z. B. é in e + Akzent; die Akzente werden verworfen.
43
+ text = unicodedata.normalize("NFKD", text)
44
+ text = "".join(c for c in text if not unicodedata.combining(c))
45
+ text = text.lower()
46
+ text = re.sub(r"[\s']+", "-", text.strip()) # Leerzeichen/Apostroph → Bindestrich
47
+ text = re.sub(r"[^a-z0-9-]", "", text) # Rest raus (AD-sicher)
48
+ text = re.sub(r"-{2,}", "-", text).strip("-")
49
+ return text
50
+
51
+
52
+ def _kuerze(basis: str, max_len: int) -> str:
53
+ return basis[:max_len].rstrip(".-")
54
+
55
+
56
+ def erzeuge_benutzername(
57
+ vorname: str,
58
+ nachname: str,
59
+ schema: str = "{vorname}.{nachname}",
60
+ vergeben: set[str] | None = None,
61
+ ) -> str:
62
+ """Erzeugt einen eindeutigen, AD-tauglichen Benutzernamen.
63
+
64
+ ``vergeben`` enthält alle bereits existierenden sAMAccountNames der
65
+ Domäne (nicht nur die von SchulSync verwalteten!) – sonst kollidiert
66
+ die neue Schülerin irgendwann mit dem Hausmeister-Konto.
67
+ """
68
+ vergeben = {v.lower() for v in (vergeben or set())}
69
+ # Bei mehrteiligen Vornamen zählt der Rufname (erster Teil):
70
+ erster_vorname = vorname.strip().split()[0] if vorname.strip() else vorname
71
+ basis = schema.format(
72
+ vorname=transliteriere(erster_vorname),
73
+ nachname=transliteriere(nachname),
74
+ )
75
+ basis = re.sub(r"\.{2,}", ".", basis).strip(".")
76
+ if not basis.replace(".", "").replace("-", ""):
77
+ raise ValueError(
78
+ f"Aus '{vorname} {nachname}' lässt sich kein Benutzername bilden – "
79
+ "bitte Datensatz in der Schulverwaltung prüfen."
80
+ )
81
+
82
+ kandidat = _kuerze(basis, SAM_MAX)
83
+ if kandidat.lower() not in vergeben:
84
+ return kandidat
85
+
86
+ for zaehler in range(2, 1000):
87
+ suffix = str(zaehler)
88
+ kandidat = _kuerze(basis, SAM_MAX - len(suffix)) + suffix
89
+ if kandidat.lower() not in vergeben:
90
+ return kandidat
91
+ raise ValueError(f"Kein freier Benutzername für '{vorname} {nachname}' gefunden.")