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/__init__.py
ADDED
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()
|