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 ADDED
@@ -0,0 +1,7 @@
1
+ """SchulSync – Schuljahreswechsel ohne Handarbeit.
2
+
3
+ Deklarative Synchronisation von Schüler- und Lehrkräftekonten aus der
4
+ Schulverwaltung (SchILD-NRW, ASV-BW, generisches CSV) ins Active Directory.
5
+ """
6
+
7
+ __version__ = "0.1.0"
schulsync/applier.py ADDED
@@ -0,0 +1,185 @@
1
+ """Führt einen Plan gegen das AD aus.
2
+
3
+ Grundsätze:
4
+
5
+ * **Reihenfolge mit System**: erst Struktur, dann Reaktivieren/Anlegen,
6
+ dann Umbenennen/Verschieben, zuletzt Deaktivieren. So ist der Schaden
7
+ bei einem Abbruch mitten im Lauf minimal – und ein erneuter Lauf
8
+ (idempotent!) räumt den Rest auf.
9
+ * **Ein Fehler stoppt nicht die Schule**: schlägt ein Konto fehl, wird
10
+ der Fehler gesammelt und weitergemacht. Am Ende gibt es eine ehrliche
11
+ Bilanz statt eines halben Stilllstands.
12
+ * Initialpasswörter werden nur im Speicher gehalten und als Datei mit
13
+ Modus 0600 abgelegt – der Aufrufer entscheidet, wann sie gedruckt
14
+ und gelöscht wird.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import csv
20
+ import os
21
+ from dataclasses import dataclass, field
22
+ from datetime import date
23
+ from pathlib import Path
24
+
25
+ from .config import Config
26
+ from .ldapclient import AdVerbindung, LdapFehler
27
+ from .models import Plan, Rolle
28
+ from .passwords import erzeuge_passwort
29
+
30
+
31
+ @dataclass
32
+ class NeuesKonto:
33
+ benutzername: str
34
+ anzeigename: str
35
+ klasse: str | None
36
+ rolle: Rolle
37
+ passwort: str
38
+
39
+
40
+ @dataclass
41
+ class ApplyErgebnis:
42
+ struktur_neu: list[str] = field(default_factory=list)
43
+ angelegt: list[NeuesKonto] = field(default_factory=list)
44
+ verschoben: int = 0
45
+ umbenannt: int = 0
46
+ deaktiviert: int = 0
47
+ reaktiviert: int = 0
48
+ fehler: list[str] = field(default_factory=list)
49
+
50
+ @property
51
+ def erfolgreich(self) -> bool:
52
+ return not self.fehler
53
+
54
+
55
+ def wende_plan_an(
56
+ plan: Plan,
57
+ ad: AdVerbindung,
58
+ config: Config,
59
+ heute: date,
60
+ ) -> ApplyErgebnis:
61
+ ergebnis = ApplyErgebnis()
62
+ s = config.struktur
63
+
64
+ # 1) Struktur: alle Ziel-Klassen-OUs und -Gruppen müssen existieren.
65
+ klassen = {a.person.klasse for a in plan.anlegen if a.person.klasse}
66
+ klassen |= {v.nach_klasse for v in plan.verschieben if v.nach_klasse}
67
+ klassen |= {r.nach_klasse for r in plan.reaktivieren if r.nach_klasse}
68
+ try:
69
+ ergebnis.struktur_neu = ad.stelle_struktur_sicher(klassen)
70
+ except LdapFehler as e:
71
+ # Ohne Struktur ist alles Weitere zwecklos.
72
+ ergebnis.fehler.append(str(e))
73
+ return ergebnis
74
+
75
+ def _ziel_ou(rolle: Rolle, klasse: str | None) -> str:
76
+ if rolle is Rolle.LEHRKRAFT:
77
+ return config.lehrkraefte_dn
78
+ return config.klassen_dn(klasse) if klasse else config.schueler_dn
79
+
80
+ def _gruppen(rolle: Rolle, klasse: str | None) -> list[str]:
81
+ if rolle is Rolle.LEHRKRAFT:
82
+ return [s.gruppe_alle_lehrkraefte]
83
+ gruppen = [s.gruppe_alle_schueler]
84
+ if klasse:
85
+ gruppen.append(config.klassen_gruppe(klasse))
86
+ return gruppen
87
+
88
+ # 2) Reaktivieren (Rückkehrer zuerst – sie blockieren keinen Benutzernamen).
89
+ for r in plan.reaktivieren:
90
+ try:
91
+ ad.aktiviere(r.konto.dn)
92
+ neuer_dn = ad.verschiebe(r.konto.dn, _ziel_ou(r.konto.rolle, r.nach_klasse))
93
+ for gruppe in _gruppen(r.konto.rolle, r.nach_klasse):
94
+ ad.fuege_zu_gruppe_hinzu(neuer_dn, gruppe)
95
+ r.konto.dn = neuer_dn
96
+ r.konto.klasse = r.nach_klasse
97
+ ergebnis.reaktiviert += 1
98
+ except LdapFehler as e:
99
+ ergebnis.fehler.append(f"Reaktivieren {r.konto.benutzername}: {e}")
100
+
101
+ # 3) Anlegen.
102
+ for a in plan.anlegen:
103
+ p = a.person
104
+ passwort = erzeuge_passwort(config.konten.passwort_woerter)
105
+ try:
106
+ dn = ad.lege_benutzer_an(
107
+ benutzername=a.benutzername,
108
+ vorname=p.vorname,
109
+ nachname=p.nachname,
110
+ quell_id=p.quell_id,
111
+ rolle=p.rolle,
112
+ eltern_dn=_ziel_ou(p.rolle, p.klasse),
113
+ passwort=passwort,
114
+ )
115
+ for gruppe in _gruppen(p.rolle, p.klasse):
116
+ ad.fuege_zu_gruppe_hinzu(dn, gruppe)
117
+ ergebnis.angelegt.append(
118
+ NeuesKonto(
119
+ benutzername=a.benutzername,
120
+ anzeigename=p.anzeigename,
121
+ klasse=p.klasse,
122
+ rolle=p.rolle,
123
+ passwort=passwort,
124
+ )
125
+ )
126
+ except LdapFehler as e:
127
+ ergebnis.fehler.append(f"Anlegen {a.benutzername}: {e}")
128
+
129
+ # 4) Umbenennen (vor dem Verschieben – DN ändert sich dabei nicht,
130
+ # weil CN = Benutzername).
131
+ for u in plan.umbenennen:
132
+ try:
133
+ ad.benenne_um(u.konto.dn, u.neuer_vorname, u.neuer_nachname)
134
+ ergebnis.umbenannt += 1
135
+ except LdapFehler as e:
136
+ ergebnis.fehler.append(f"Umbenennen {u.konto.benutzername}: {e}")
137
+
138
+ # 5) Verschieben (Klassenwechsel): OU-Umzug + Gruppen tauschen.
139
+ for v in plan.verschieben:
140
+ try:
141
+ neuer_dn = ad.verschiebe(v.konto.dn, _ziel_ou(v.konto.rolle, v.nach_klasse))
142
+ if v.von_klasse:
143
+ ad.entferne_aus_gruppe(neuer_dn, config.klassen_gruppe(v.von_klasse))
144
+ if v.nach_klasse:
145
+ ad.fuege_zu_gruppe_hinzu(neuer_dn, config.klassen_gruppe(v.nach_klasse))
146
+ v.konto.dn = neuer_dn
147
+ ergebnis.verschoben += 1
148
+ except LdapFehler as e:
149
+ ergebnis.fehler.append(f"Verschieben {v.konto.benutzername}: {e}")
150
+
151
+ # 6) Deaktivieren: Konto sperren, aus allen SchulSync-Gruppen nehmen,
152
+ # in die Abgänger-OU verschieben, Frist-Stempel setzen.
153
+ for d in plan.deaktivieren:
154
+ try:
155
+ ad.deaktiviere(d.konto.dn, am=heute, frist_tage=config.aufbewahrung.abgaenger_tage)
156
+ for gruppe in ad.gruppen_von(d.konto.dn):
157
+ ad.entferne_aus_gruppe(d.konto.dn, gruppe)
158
+ d.konto.dn = ad.verschiebe(d.konto.dn, config.abgaenger_dn)
159
+ ergebnis.deaktiviert += 1
160
+ except LdapFehler as e:
161
+ ergebnis.fehler.append(f"Deaktivieren {d.konto.benutzername}: {e}")
162
+
163
+ return ergebnis
164
+
165
+
166
+ def schreibe_zugangsdaten(
167
+ konten: list[NeuesKonto], pfad: Path, heute: date
168
+ ) -> Path:
169
+ """Schreibt die Initialpasswörter als CSV (Dateimodus 0600).
170
+
171
+ Diese Datei ist der Rohstoff für ``schulsync briefe`` – und sollte
172
+ nach dem Druck gelöscht werden (steht auch so im Dateikopf).
173
+ """
174
+ pfad = Path(pfad)
175
+ with open(pfad, "w", newline="", encoding="utf-8") as f:
176
+ os.fchmod(f.fileno(), 0o600)
177
+ f.write(f"# Initialpasswörter vom {heute.isoformat()} – VERTRAULICH.\n")
178
+ f.write("# Nach dem Druck der Briefe löschen (schulsync briefe erledigt den Rest).\n")
179
+ w = csv.writer(f, delimiter=";")
180
+ w.writerow(["Benutzername", "Name", "Klasse", "Rolle", "Initialpasswort"])
181
+ for k in konten:
182
+ w.writerow(
183
+ [k.benutzername, k.anzeigename, k.klasse or "", k.rolle.value, k.passwort]
184
+ )
185
+ return pfad
schulsync/cli.py ADDED
@@ -0,0 +1,423 @@
1
+ """SchulSync-Kommandozeile.
2
+
3
+ Arbeitsablauf beim Schuljahreswechsel::
4
+
5
+ schulsync validate export.csv # Export prüfen
6
+ schulsync plan export.csv # Was würde passieren? (ändert nichts)
7
+ schulsync apply export.csv --ja # Plan ausführen
8
+ schulsync briefe zugangsdaten-*.csv # Passwort-Briefe drucken
9
+ schulsync cleanup --ja # Abgänger nach Ablauf der Frist löschen
10
+
11
+ Exit-Codes (skript- und monitoringfreundlich):
12
+ 0 = Erfolg / keine Abweichungen
13
+ 1 = Fehler (Konfiguration, CSV, LDAP)
14
+ 2 = Konflikte im Plan – manuelle Klärung nötig
15
+ 3 = ``plan --check``: es gibt Abweichungen (für Cron/Monitoring)
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from datetime import date, datetime
21
+ from pathlib import Path
22
+
23
+ import typer
24
+ from rich.console import Console
25
+ from rich.panel import Panel
26
+ from rich.table import Table
27
+
28
+ from . import __version__
29
+ from .applier import schreibe_zugangsdaten, wende_plan_an
30
+ from .config import Config, KonfigFehler, lade_config
31
+ from .csvsource import CsvFehler, lese_export
32
+ from .ldapclient import AdVerbindung, LdapFehler
33
+ from .models import Plan, Rolle, SollPerson
34
+ from .planner import berechne_cleanup, berechne_plan
35
+ from .report import lese_zugangsdaten, schreibe_briefe, schreibe_report
36
+
37
+ app = typer.Typer(
38
+ help="Schuljahreswechsel ohne Handarbeit: Schulverwaltung → Active Directory.",
39
+ no_args_is_help=True,
40
+ rich_markup_mode="rich",
41
+ )
42
+ console = Console()
43
+
44
+ ConfigOption = typer.Option("schulsync.yaml", "--config", "-c", help="Pfad zur Konfiguration.")
45
+ MAX_ZEILEN_TERMINAL = 15
46
+
47
+
48
+ def _fehler(meldung: str, code: int = 1) -> None:
49
+ console.print(Panel(meldung, title="Fehler", border_style="red"))
50
+ raise typer.Exit(code)
51
+
52
+
53
+ def _lade_config(pfad: Path) -> Config:
54
+ try:
55
+ return lade_config(pfad)
56
+ except KonfigFehler as e:
57
+ _fehler(str(e))
58
+ raise # nie erreicht – beruhigt die Typprüfung
59
+
60
+
61
+ def _lade_soll(
62
+ schueler_csv: Path,
63
+ lehrkraefte_csv: Path | None,
64
+ config: Config,
65
+ ) -> list[SollPerson]:
66
+ soll: list[SollPerson] = []
67
+ for pfad, rolle in ((schueler_csv, Rolle.SCHUELER), (lehrkraefte_csv, Rolle.LEHRKRAFT)):
68
+ if pfad is None:
69
+ continue
70
+ ergebnis = lese_export(
71
+ pfad,
72
+ rolle,
73
+ profil_name=config.csv.profil,
74
+ spalten=config.csv.spalten,
75
+ trennzeichen=config.csv.trennzeichen,
76
+ )
77
+ for w in ergebnis.warnungen:
78
+ console.print(f"[yellow]⚠[/] {w}")
79
+ console.print(
80
+ f"[green]✔[/] {pfad}: [bold]{len(ergebnis.personen)}[/] "
81
+ f"{'Schüler:innen' if rolle is Rolle.SCHUELER else 'Lehrkräfte'} "
82
+ f"({ergebnis.kodierung}, Trennzeichen '{ergebnis.trennzeichen}')"
83
+ )
84
+ soll.extend(ergebnis.personen)
85
+ return soll
86
+
87
+
88
+ def zeige_plan(plan: Plan, ziel: str, out: Console | None = None) -> None:
89
+ """Stellt den Plan im Terminal dar (wird auch für die README-SVGs genutzt)."""
90
+ out = out or console
91
+ uebersicht = Table.grid(padding=(0, 2))
92
+ uebersicht.add_column(justify="right", style="bold")
93
+ uebersicht.add_column()
94
+ zeilen = [
95
+ (len(plan.anlegen), "[green]+ Anlegen[/]"),
96
+ (len(plan.verschieben), "[blue]⇄ Klassenwechsel[/]"),
97
+ (len(plan.umbenennen), "[yellow]✎ Umbenennen[/]"),
98
+ (len(plan.reaktivieren), "[magenta]↻ Reaktivieren (Rückkehrer)[/]"),
99
+ (len(plan.deaktivieren), "[red]⏻ Deaktivieren (Abgänger)[/]"),
100
+ (plan.unveraendert, "[dim]= Unverändert[/]"),
101
+ ]
102
+ for zahl, label in zeilen:
103
+ uebersicht.add_row(str(zahl), label)
104
+ out.print(
105
+ Panel(
106
+ uebersicht,
107
+ title=f"[bold]SchulSync-Plan[/] → {ziel}",
108
+ subtitle="[dim]Es wurde noch nichts geändert[/]",
109
+ border_style="cyan",
110
+ )
111
+ )
112
+
113
+ def _tabelle(titel: str, stil: str, spalten: list[str], zeilen: list[list[str]]) -> None:
114
+ if not zeilen:
115
+ return
116
+ t = Table(title=f"{titel} ({len(zeilen)})", title_style=f"bold {stil}",
117
+ border_style=stil, title_justify="left")
118
+ for s in spalten:
119
+ t.add_column(s)
120
+ for z in zeilen[:MAX_ZEILEN_TERMINAL]:
121
+ t.add_row(*z)
122
+ if len(zeilen) > MAX_ZEILEN_TERMINAL:
123
+ t.add_row(*["…"] * len(spalten))
124
+ t.caption = f"{len(zeilen) - MAX_ZEILEN_TERMINAL} weitere im HTML-Report"
125
+ out.print(t)
126
+
127
+ _tabelle(
128
+ "+ Anlegen", "green", ["Name", "Klasse", "Benutzername"],
129
+ [[a.person.anzeigename, a.person.klasse or "–", a.benutzername] for a in plan.anlegen],
130
+ )
131
+ _tabelle(
132
+ "⇄ Klassenwechsel", "blue", ["Name", "Benutzername", "Wechsel"],
133
+ [[v.konto.anzeigename, v.konto.benutzername, f"{v.von_klasse or '–'} → {v.nach_klasse}"]
134
+ for v in plan.verschieben],
135
+ )
136
+ _tabelle(
137
+ "✎ Umbenennen", "yellow", ["Benutzername", "Bisher", "Neu"],
138
+ [[u.konto.benutzername, u.konto.anzeigename, f"{u.neuer_vorname} {u.neuer_nachname}"]
139
+ for u in plan.umbenennen],
140
+ )
141
+ _tabelle(
142
+ "↻ Reaktivieren", "magenta", ["Name", "Benutzername", "Neue Klasse"],
143
+ [[r.konto.anzeigename, r.konto.benutzername, r.nach_klasse or "–"]
144
+ for r in plan.reaktivieren],
145
+ )
146
+ _tabelle(
147
+ "⏻ Deaktivieren", "red", ["Name", "Benutzername", "Bisherige Klasse"],
148
+ [[d.konto.anzeigename, d.konto.benutzername, d.konto.klasse or "–"]
149
+ for d in plan.deaktivieren],
150
+ )
151
+ if plan.konflikte:
152
+ _tabelle(
153
+ "⚠ Konflikte – bitte zuerst klären", "red", ["Betrifft", "Grund"],
154
+ [[k.betrifft, k.grund] for k in plan.konflikte],
155
+ )
156
+ if plan.leer and not plan.konflikte:
157
+ out.print("[green]✔ Keine Abweichungen – AD und Schulverwaltung sind synchron.[/]")
158
+
159
+
160
+ def _berechne(
161
+ config: Config,
162
+ schueler_csv: Path,
163
+ lehrkraefte_csv: Path | None,
164
+ ohne_notbremse: bool,
165
+ ) -> tuple[Plan, AdVerbindung]:
166
+ soll = _lade_soll(schueler_csv, lehrkraefte_csv, config)
167
+ ad = AdVerbindung(config)
168
+ ad.verbinden()
169
+ ist = ad.lade_ist_konten()
170
+ vergeben = ad.alle_benutzernamen_der_domaene()
171
+ console.print(
172
+ f"[green]✔[/] AD verbunden: [bold]{len(ist)}[/] verwaltete Konten, "
173
+ f"{len(vergeben)} Benutzernamen domänenweit belegt"
174
+ )
175
+ plan = berechne_plan(
176
+ soll,
177
+ ist,
178
+ vergeben,
179
+ benutzername_schema=config.konten.benutzername_schema,
180
+ notbremse=not ohne_notbremse,
181
+ )
182
+ return plan, ad
183
+
184
+
185
+ # --------------------------------------------------------------------------
186
+ # Kommandos
187
+ # --------------------------------------------------------------------------
188
+
189
+
190
+ @app.command()
191
+ def version() -> None:
192
+ """Zeigt die SchulSync-Version."""
193
+ console.print(f"SchulSync {__version__}")
194
+
195
+
196
+ @app.command()
197
+ def validate(
198
+ schueler_csv: Path = typer.Argument(..., help="Export der Schulverwaltung (Schüler)."),
199
+ lehrkraefte: Path | None = typer.Option(None, help="Optionaler Lehrkräfte-Export."),
200
+ config_pfad: Path = ConfigOption,
201
+ ) -> None:
202
+ """Prüft einen Export, ohne das AD anzufassen: Kodierung, Spalten, Dubletten."""
203
+ config = _lade_config(config_pfad)
204
+ try:
205
+ personen = _lade_soll(schueler_csv, lehrkraefte, config)
206
+ except CsvFehler as e:
207
+ _fehler(str(e))
208
+ return
209
+ klassen = sorted({p.klasse for p in personen if p.klasse})
210
+ console.print(
211
+ f"[green]✔ Export in Ordnung:[/] {len(personen)} Personen, "
212
+ f"{len(klassen)} Klassen ({', '.join(klassen[:12])}{' …' if len(klassen) > 12 else ''})"
213
+ )
214
+
215
+
216
+ @app.command()
217
+ def check(config_pfad: Path = ConfigOption) -> None:
218
+ """Prüft Konfiguration und AD-Verbindung (nur lesend)."""
219
+ config = _lade_config(config_pfad)
220
+ console.print(f"[green]✔[/] Konfiguration gelesen: {config_pfad}")
221
+ try:
222
+ with AdVerbindung(config) as ad:
223
+ console.print(f"[green]✔[/] Verbunden mit {config.ldap.server} (TLS aktiv)")
224
+ if ad.basis_existiert():
225
+ konten = ad.lade_ist_konten()
226
+ console.print(
227
+ f"[green]✔[/] Basis-OU vorhanden: {config.basis_dn} "
228
+ f"({len(konten)} verwaltete Konten)"
229
+ )
230
+ else:
231
+ console.print(
232
+ f"[yellow]⚠[/] Basis-OU {config.basis_dn} existiert noch nicht – "
233
+ "sie wird beim ersten [bold]apply[/] automatisch angelegt."
234
+ )
235
+ except LdapFehler as e:
236
+ _fehler(str(e))
237
+
238
+
239
+ @app.command()
240
+ def plan(
241
+ schueler_csv: Path = typer.Argument(..., help="Export der Schulverwaltung (Schüler)."),
242
+ lehrkraefte: Path | None = typer.Option(None, help="Optionaler Lehrkräfte-Export."),
243
+ report: Path | None = typer.Option(None, help="HTML-Diff-Report an diesen Pfad schreiben."),
244
+ check_modus: bool = typer.Option(
245
+ False, "--check", help="Exit-Code 3 bei Abweichungen (für Cron/Monitoring)."
246
+ ),
247
+ ohne_notbremse: bool = typer.Option(
248
+ False, "--ohne-notbremse",
249
+ help="Notbremse gegen unvollständige Exporte deaktivieren.",
250
+ ),
251
+ config_pfad: Path = ConfigOption,
252
+ ) -> None:
253
+ """Zeigt, was ein Lauf ändern würde – garantiert ohne Schreibzugriff (Dry-Run)."""
254
+ config = _lade_config(config_pfad)
255
+ try:
256
+ ergebnis, ad = _berechne(config, schueler_csv, lehrkraefte, ohne_notbremse)
257
+ ad.trennen()
258
+ except (CsvFehler, LdapFehler) as e:
259
+ _fehler(str(e))
260
+ return
261
+ zeige_plan(ergebnis, config.ldap.base_dn)
262
+ if report:
263
+ schreibe_report(
264
+ ergebnis, config, str(schueler_csv), report, datetime.now(), modus="Plan (Dry-Run)"
265
+ )
266
+ console.print(f"[green]✔[/] HTML-Report: [bold]{report}[/]")
267
+ if ergebnis.konflikte:
268
+ raise typer.Exit(2)
269
+ if check_modus and not ergebnis.leer:
270
+ raise typer.Exit(3)
271
+
272
+
273
+ @app.command()
274
+ def apply(
275
+ schueler_csv: Path = typer.Argument(..., help="Export der Schulverwaltung (Schüler)."),
276
+ lehrkraefte: Path | None = typer.Option(None, help="Optionaler Lehrkräfte-Export."),
277
+ ja: bool = typer.Option(False, "--ja", help="Plan ohne Rückfrage ausführen."),
278
+ report: Path | None = typer.Option(None, help="HTML-Report an diesen Pfad schreiben."),
279
+ ohne_notbremse: bool = typer.Option(
280
+ False, "--ohne-notbremse",
281
+ help="Notbremse gegen unvollständige Exporte deaktivieren.",
282
+ ),
283
+ config_pfad: Path = ConfigOption,
284
+ ) -> None:
285
+ """Führt den Plan aus: legt an, verschiebt, deaktiviert – nach Bestätigung."""
286
+ config = _lade_config(config_pfad)
287
+ heute = date.today()
288
+ try:
289
+ ergebnis, ad = _berechne(config, schueler_csv, lehrkraefte, ohne_notbremse)
290
+ except (CsvFehler, LdapFehler) as e:
291
+ _fehler(str(e))
292
+ return
293
+
294
+ try:
295
+ zeige_plan(ergebnis, config.ldap.base_dn)
296
+ if ergebnis.konflikte:
297
+ _fehler(
298
+ "Der Plan enthält Konflikte – bitte zuerst klären. Es wurde nichts geändert.",
299
+ code=2,
300
+ )
301
+ if ergebnis.leer:
302
+ console.print("[green]Nichts zu tun.[/]")
303
+ return
304
+ if not ja and not typer.confirm(
305
+ f"{ergebnis.anzahl_aenderungen} Änderungen an {config.ldap.base_dn} ausführen?"
306
+ ):
307
+ console.print("Abgebrochen – es wurde nichts geändert.")
308
+ raise typer.Exit(0)
309
+
310
+ resultat = wende_plan_an(ergebnis, ad, config, heute)
311
+ finally:
312
+ ad.trennen()
313
+
314
+ if resultat.struktur_neu:
315
+ console.print(f"[green]✔[/] Struktur ergänzt: {len(resultat.struktur_neu)} Objekte")
316
+ console.print(
317
+ f"[green]✔ Ausgeführt:[/] {len(resultat.angelegt)} angelegt, "
318
+ f"{resultat.verschoben} verschoben, {resultat.umbenannt} umbenannt, "
319
+ f"{resultat.reaktiviert} reaktiviert, {resultat.deaktiviert} deaktiviert"
320
+ )
321
+
322
+ if resultat.angelegt:
323
+ zugangsdaten = Path(f"zugangsdaten-{heute.isoformat()}.csv")
324
+ schreibe_zugangsdaten(resultat.angelegt, zugangsdaten, heute)
325
+ console.print(
326
+ Panel(
327
+ f"Initialpasswörter: [bold]{zugangsdaten}[/] (Dateimodus 0600)\n"
328
+ f"Briefe drucken: [bold]schulsync briefe {zugangsdaten}[/]\n"
329
+ "Danach die Datei löschen – die Passwörter gelten nur bis zur ersten Anmeldung.",
330
+ title="Vertraulich",
331
+ border_style="yellow",
332
+ )
333
+ )
334
+
335
+ if report:
336
+ schreibe_report(
337
+ ergebnis, config, str(schueler_csv), report, datetime.now(), modus="Apply (ausgeführt)"
338
+ )
339
+ console.print(f"[green]✔[/] HTML-Report: [bold]{report}[/]")
340
+
341
+ if resultat.fehler:
342
+ for f in resultat.fehler:
343
+ console.print(f"[red]✘[/] {f}")
344
+ _fehler(f"{len(resultat.fehler)} Aktionen fehlgeschlagen (Details oben).")
345
+
346
+
347
+ @app.command()
348
+ def briefe(
349
+ zugangsdaten: Path = typer.Argument(..., help="Zugangsdaten-CSV aus 'schulsync apply'."),
350
+ ziel: Path = typer.Option(Path("briefe"), help="Zielordner für die HTML-Briefe."),
351
+ loeschen: bool = typer.Option(
352
+ False, "--loeschen", help="Zugangsdaten-CSV nach Erzeugung der Briefe löschen."
353
+ ),
354
+ config_pfad: Path = ConfigOption,
355
+ ) -> None:
356
+ """Erzeugt druckfertige Zugangsdaten-Briefe (eine HTML-Datei pro Klasse)."""
357
+ config = _lade_config(config_pfad)
358
+ if not zugangsdaten.exists():
359
+ _fehler(f"'{zugangsdaten}' nicht gefunden.")
360
+ konten = lese_zugangsdaten(zugangsdaten)
361
+ if not konten:
362
+ _fehler(f"'{zugangsdaten}' enthält keine Konten.")
363
+ dateien = schreibe_briefe(konten, config, ziel, datum=date.today().strftime("%d.%m.%Y"))
364
+ for datei in dateien:
365
+ console.print(f"[green]✔[/] {datei}")
366
+ console.print(
367
+ f"{len(konten)} Briefe in {len(dateien)} Dateien – im Browser öffnen und drucken."
368
+ )
369
+ if loeschen:
370
+ zugangsdaten.unlink()
371
+ console.print(f"[green]✔[/] {zugangsdaten} gelöscht (Passwörter nur noch auf Papier).")
372
+ else:
373
+ console.print(
374
+ f"[yellow]⚠[/] Denken Sie daran, [bold]{zugangsdaten}[/] nach dem Druck zu löschen "
375
+ "(oder gleich [bold]--loeschen[/] verwenden)."
376
+ )
377
+
378
+
379
+ @app.command()
380
+ def cleanup(
381
+ ja: bool = typer.Option(False, "--ja", help="Ohne Rückfrage löschen."),
382
+ config_pfad: Path = ConfigOption,
383
+ ) -> None:
384
+ """Löscht Abgänger-Konten, deren Aufbewahrungsfrist abgelaufen ist (Löschkonzept)."""
385
+ config = _lade_config(config_pfad)
386
+ frist = config.aufbewahrung.abgaenger_tage
387
+ try:
388
+ with AdVerbindung(config) as ad:
389
+ ist = ad.lade_ist_konten()
390
+ zu_loeschen, wartend = berechne_cleanup(ist, date.today(), frist)
391
+ if wartend:
392
+ console.print(
393
+ f"[dim]{len(wartend)} Abgänger-Konten noch in der {frist}-Tage-Frist.[/]"
394
+ )
395
+ if not zu_loeschen:
396
+ console.print("[green]✔ Nichts zu löschen.[/]")
397
+ return
398
+ t = Table(title=f"Endgültig löschen ({len(zu_loeschen)})", border_style="red",
399
+ title_justify="left")
400
+ t.add_column("Name")
401
+ t.add_column("Benutzername")
402
+ t.add_column("Deaktiviert am")
403
+ for eintrag in zu_loeschen:
404
+ t.add_row(
405
+ eintrag.konto.anzeigename,
406
+ eintrag.konto.benutzername,
407
+ eintrag.deaktiviert_am.isoformat(),
408
+ )
409
+ console.print(t)
410
+ if not ja and not typer.confirm(
411
+ f"{len(zu_loeschen)} Konten endgültig löschen (Frist: {frist} Tage abgelaufen)?"
412
+ ):
413
+ console.print("Abgebrochen – es wurde nichts gelöscht.")
414
+ raise typer.Exit(0)
415
+ for eintrag in zu_loeschen:
416
+ ad.loesche(eintrag.konto.dn)
417
+ console.print(f"[green]✔ {len(zu_loeschen)} Konten gelöscht.[/]")
418
+ except LdapFehler as e:
419
+ _fehler(str(e))
420
+
421
+
422
+ if __name__ == "__main__":
423
+ app()