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/__init__.py +7 -0
- schulsync/applier.py +185 -0
- schulsync/cli.py +423 -0
- schulsync/config.py +141 -0
- schulsync/csvsource.py +194 -0
- schulsync/ldapclient.py +392 -0
- schulsync/models.py +167 -0
- schulsync/passwords.py +42 -0
- schulsync/planner.py +172 -0
- schulsync/report.py +94 -0
- schulsync/templates/briefe.html.j2 +56 -0
- schulsync/templates/report.html.j2 +206 -0
- schulsync/usernames.py +91 -0
- schulsync-0.1.0.dist-info/METADATA +229 -0
- schulsync-0.1.0.dist-info/RECORD +18 -0
- schulsync-0.1.0.dist-info/WHEEL +4 -0
- schulsync-0.1.0.dist-info/entry_points.txt +2 -0
- schulsync-0.1.0.dist-info/licenses/LICENSE +21 -0
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.")
|