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/config.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Konfiguration von SchulSync (``schulsync.yaml``).
|
|
2
|
+
|
|
3
|
+
Sicherheitsprinzip: Das LDAP-Passwort steht **niemals** in der Datei,
|
|
4
|
+
sondern ausschließlich in der Umgebungsvariable ``SCHULSYNC_LDAP_PASSWORT``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
from pydantic import BaseModel, Field, field_validator
|
|
14
|
+
|
|
15
|
+
PASSWORT_ENV = "SCHULSYNC_LDAP_PASSWORT"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LdapConfig(BaseModel):
|
|
19
|
+
server: str = "ldaps://dc1.schule.local"
|
|
20
|
+
bind_benutzer: str = "Administrator@schule.local"
|
|
21
|
+
base_dn: str = "DC=schule,DC=local"
|
|
22
|
+
tls_verifizieren: bool = True # nur im Lab mit Self-Signed-Zertifikat: false
|
|
23
|
+
zeitlimit_sekunden: int = 10
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def passwort(self) -> str:
|
|
27
|
+
pw = os.environ.get(PASSWORT_ENV, "")
|
|
28
|
+
if not pw:
|
|
29
|
+
raise KonfigFehler(
|
|
30
|
+
f"LDAP-Passwort fehlt. Bitte Umgebungsvariable {PASSWORT_ENV} setzen – "
|
|
31
|
+
"Passwörter gehören nicht in Konfigurationsdateien."
|
|
32
|
+
)
|
|
33
|
+
return pw
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class StrukturConfig(BaseModel):
|
|
37
|
+
"""OU- und Gruppenlayout unterhalb von base_dn.
|
|
38
|
+
|
|
39
|
+
Standard-Layout::
|
|
40
|
+
|
|
41
|
+
OU=SchulSync
|
|
42
|
+
├── OU=Schueler
|
|
43
|
+
│ ├── OU=5A … (je Klasse eine OU)
|
|
44
|
+
├── OU=Lehrkraefte
|
|
45
|
+
└── OU=Abgaenger (deaktivierte Konten, Aufbewahrungsfrist)
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
basis_ou: str = "SchulSync"
|
|
49
|
+
schueler_ou: str = "Schueler"
|
|
50
|
+
lehrkraefte_ou: str = "Lehrkraefte"
|
|
51
|
+
abgaenger_ou: str = "Abgaenger"
|
|
52
|
+
klassen_gruppen_praefix: str = "Klasse-"
|
|
53
|
+
gruppe_alle_schueler: str = "Alle-Schueler"
|
|
54
|
+
gruppe_alle_lehrkraefte: str = "Alle-Lehrkraefte"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class KontenConfig(BaseModel):
|
|
58
|
+
benutzername_schema: str = "{vorname}.{nachname}"
|
|
59
|
+
upn_suffix: str = "@schule.local"
|
|
60
|
+
# Optional: Home-Verzeichnis-Attribute (Anlage der Ordner übernimmt der Fileserver)
|
|
61
|
+
home_laufwerk: str | None = None # z. B. "H:"
|
|
62
|
+
home_pfad: str | None = None # z. B. r"\\fs01\homes\{benutzername}"
|
|
63
|
+
passwort_woerter: int = 2
|
|
64
|
+
|
|
65
|
+
@field_validator("upn_suffix")
|
|
66
|
+
@classmethod
|
|
67
|
+
def _upn_mit_at(cls, v: str) -> str:
|
|
68
|
+
if not v.startswith("@"):
|
|
69
|
+
raise ValueError("upn_suffix muss mit '@' beginnen, z. B. '@schule.local'")
|
|
70
|
+
return v
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class AufbewahrungConfig(BaseModel):
|
|
74
|
+
"""Löschkonzept: Abgänger-Konten werden nach Ablauf der Frist durch
|
|
75
|
+
``schulsync cleanup`` endgültig gelöscht (Art. 5 & 17 DSGVO)."""
|
|
76
|
+
|
|
77
|
+
abgaenger_tage: int = Field(default=90, ge=0)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class CsvConfig(BaseModel):
|
|
81
|
+
profil: str = "schild-nrw" # schild-nrw | asv-bw | generisch
|
|
82
|
+
# nur bei profil: generisch nötig – eigene Spaltenzuordnung:
|
|
83
|
+
spalten: dict[str, str] | None = None
|
|
84
|
+
trennzeichen: str | None = None # None = automatisch erkennen
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class SchuleConfig(BaseModel):
|
|
88
|
+
"""Angaben für Berichte und Zugangsdaten-Briefe."""
|
|
89
|
+
|
|
90
|
+
name: str = "Muster-Realschule"
|
|
91
|
+
it_kontakt: str = "IT-Support, Zimmer 042"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Config(BaseModel):
|
|
95
|
+
ldap: LdapConfig = Field(default_factory=LdapConfig)
|
|
96
|
+
struktur: StrukturConfig = Field(default_factory=StrukturConfig)
|
|
97
|
+
konten: KontenConfig = Field(default_factory=KontenConfig)
|
|
98
|
+
aufbewahrung: AufbewahrungConfig = Field(default_factory=AufbewahrungConfig)
|
|
99
|
+
csv: CsvConfig = Field(default_factory=CsvConfig)
|
|
100
|
+
schule: SchuleConfig = Field(default_factory=SchuleConfig)
|
|
101
|
+
|
|
102
|
+
# --- abgeleitete DNs ---------------------------------------------------
|
|
103
|
+
@property
|
|
104
|
+
def basis_dn(self) -> str:
|
|
105
|
+
return f"OU={self.struktur.basis_ou},{self.ldap.base_dn}"
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def schueler_dn(self) -> str:
|
|
109
|
+
return f"OU={self.struktur.schueler_ou},{self.basis_dn}"
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def lehrkraefte_dn(self) -> str:
|
|
113
|
+
return f"OU={self.struktur.lehrkraefte_ou},{self.basis_dn}"
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def abgaenger_dn(self) -> str:
|
|
117
|
+
return f"OU={self.struktur.abgaenger_ou},{self.basis_dn}"
|
|
118
|
+
|
|
119
|
+
def klassen_dn(self, klasse: str) -> str:
|
|
120
|
+
return f"OU={klasse},{self.schueler_dn}"
|
|
121
|
+
|
|
122
|
+
def klassen_gruppe(self, klasse: str) -> str:
|
|
123
|
+
return f"{self.struktur.klassen_gruppen_praefix}{klasse}"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class KonfigFehler(Exception):
|
|
127
|
+
"""Verständlicher Konfigurationsfehler für die CLI."""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def lade_config(pfad: Path | str = "schulsync.yaml") -> Config:
|
|
131
|
+
pfad = Path(pfad)
|
|
132
|
+
if not pfad.exists():
|
|
133
|
+
raise KonfigFehler(
|
|
134
|
+
f"Konfigurationsdatei '{pfad}' nicht gefunden. "
|
|
135
|
+
"Vorlage: examples/schulsync.example.yaml"
|
|
136
|
+
)
|
|
137
|
+
try:
|
|
138
|
+
daten = yaml.safe_load(pfad.read_text(encoding="utf-8")) or {}
|
|
139
|
+
except yaml.YAMLError as e:
|
|
140
|
+
raise KonfigFehler(f"'{pfad}' ist kein gültiges YAML: {e}") from e
|
|
141
|
+
return Config.model_validate(daten)
|
schulsync/csvsource.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Einlesen der Schulverwaltungs-Exporte (CSV).
|
|
2
|
+
|
|
3
|
+
Realität an Schulen: Exporte kommen mal als UTF-8, mal als Windows-1252,
|
|
4
|
+
mit Semikolon oder Komma, und die Spaltennamen unterscheiden sich je nach
|
|
5
|
+
Bundesland und Berichtsvorlage. Dieses Modul fängt genau das ab.
|
|
6
|
+
|
|
7
|
+
Eingebaute Profile:
|
|
8
|
+
|
|
9
|
+
* ``schild-nrw`` – SchILD-NRW-Textexport (Semikolon, typ. Windows-1252)
|
|
10
|
+
* ``asv-bw`` – typischer ASV-BW-Export aus der Berichtsbibliothek
|
|
11
|
+
* ``generisch`` – eigene Spaltenzuordnung in schulsync.yaml
|
|
12
|
+
|
|
13
|
+
Datensparsamkeit (Art. 5 DSGVO): Es werden **nur** ID, Vorname, Nachname
|
|
14
|
+
und Klasse gelesen. Geburtsdatum, Geschlecht, Adresse usw. werden bewusst
|
|
15
|
+
ignoriert – sie sind für die Kontoanlage nicht erforderlich.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import csv
|
|
21
|
+
import io
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from .models import Rolle, SollPerson, normalisiere_klasse
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class CsvProfil:
|
|
30
|
+
name: str
|
|
31
|
+
spalte_id: str
|
|
32
|
+
spalte_vorname: str
|
|
33
|
+
spalte_nachname: str
|
|
34
|
+
spalte_klasse: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
PROFILE: dict[str, CsvProfil] = {
|
|
38
|
+
"schild-nrw": CsvProfil(
|
|
39
|
+
name="schild-nrw",
|
|
40
|
+
spalte_id="Interne ID-Nummer",
|
|
41
|
+
spalte_vorname="Vorname",
|
|
42
|
+
spalte_nachname="Nachname",
|
|
43
|
+
spalte_klasse="Klasse",
|
|
44
|
+
),
|
|
45
|
+
"asv-bw": CsvProfil(
|
|
46
|
+
name="asv-bw",
|
|
47
|
+
spalte_id="Schülernummer",
|
|
48
|
+
spalte_vorname="Rufname",
|
|
49
|
+
spalte_nachname="Familienname",
|
|
50
|
+
spalte_klasse="Klasse",
|
|
51
|
+
),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class CsvFehler(Exception):
|
|
56
|
+
"""Verständlicher Fehler beim Einlesen des Exports."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _dekodiere(pfad: Path) -> tuple[str, str]:
|
|
60
|
+
"""Liest die Datei und erkennt die Kodierung (UTF-8 → Windows-1252)."""
|
|
61
|
+
roh = pfad.read_bytes()
|
|
62
|
+
for kodierung in ("utf-8-sig", "cp1252"):
|
|
63
|
+
try:
|
|
64
|
+
return roh.decode(kodierung), kodierung
|
|
65
|
+
except UnicodeDecodeError:
|
|
66
|
+
continue
|
|
67
|
+
# cp1252 kann fast alles dekodieren; hier landen wir praktisch nie.
|
|
68
|
+
return roh.decode("latin-1"), "latin-1"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _erkenne_trennzeichen(kopfzeile: str) -> str:
|
|
72
|
+
"""Semikolon ist an deutschen Schulen der Normalfall, Komma die Ausnahme."""
|
|
73
|
+
kandidaten = {t: kopfzeile.count(t) for t in (";", ",", "\t")}
|
|
74
|
+
bestes = max(kandidaten, key=lambda t: kandidaten[t])
|
|
75
|
+
if kandidaten[bestes] == 0:
|
|
76
|
+
raise CsvFehler("Kein Trennzeichen (;, , oder Tab) in der Kopfzeile gefunden.")
|
|
77
|
+
return bestes
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _profil_aus_config(profil_name: str, spalten: dict[str, str] | None) -> CsvProfil:
|
|
81
|
+
if profil_name == "generisch":
|
|
82
|
+
noetig = {"id", "vorname", "nachname", "klasse"}
|
|
83
|
+
if not spalten or not noetig.issubset(spalten):
|
|
84
|
+
fehlend = sorted(noetig - set(spalten or {}))
|
|
85
|
+
raise CsvFehler(
|
|
86
|
+
"Profil 'generisch' braucht unter csv.spalten die Schlüssel "
|
|
87
|
+
f"{sorted(noetig)} – es fehlen: {fehlend}"
|
|
88
|
+
)
|
|
89
|
+
return CsvProfil(
|
|
90
|
+
name="generisch",
|
|
91
|
+
spalte_id=spalten["id"],
|
|
92
|
+
spalte_vorname=spalten["vorname"],
|
|
93
|
+
spalte_nachname=spalten["nachname"],
|
|
94
|
+
spalte_klasse=spalten["klasse"],
|
|
95
|
+
)
|
|
96
|
+
try:
|
|
97
|
+
return PROFILE[profil_name]
|
|
98
|
+
except KeyError:
|
|
99
|
+
raise CsvFehler(
|
|
100
|
+
f"Unbekanntes CSV-Profil '{profil_name}'. "
|
|
101
|
+
f"Verfügbar: {', '.join(sorted(PROFILE))}, generisch"
|
|
102
|
+
) from None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class LeseErgebnis:
|
|
107
|
+
personen: list[SollPerson]
|
|
108
|
+
kodierung: str
|
|
109
|
+
trennzeichen: str
|
|
110
|
+
warnungen: list[str]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def lese_export(
|
|
114
|
+
pfad: Path | str,
|
|
115
|
+
rolle: Rolle,
|
|
116
|
+
profil_name: str = "schild-nrw",
|
|
117
|
+
spalten: dict[str, str] | None = None,
|
|
118
|
+
trennzeichen: str | None = None,
|
|
119
|
+
) -> LeseErgebnis:
|
|
120
|
+
"""Liest einen Export ein und validiert ihn streng.
|
|
121
|
+
|
|
122
|
+
Harte Fehler (Abbruch): fehlende Spalten, doppelte IDs, leere Pflichtfelder.
|
|
123
|
+
Lieber ein klarer Fehler als 600 halbrichtige Konten.
|
|
124
|
+
"""
|
|
125
|
+
pfad = Path(pfad)
|
|
126
|
+
if not pfad.exists():
|
|
127
|
+
raise CsvFehler(f"Datei '{pfad}' nicht gefunden.")
|
|
128
|
+
|
|
129
|
+
profil = _profil_aus_config(profil_name, spalten)
|
|
130
|
+
text, kodierung = _dekodiere(pfad)
|
|
131
|
+
erste_zeile = text.splitlines()[0] if text.strip() else ""
|
|
132
|
+
if not erste_zeile:
|
|
133
|
+
raise CsvFehler(f"'{pfad}' ist leer.")
|
|
134
|
+
sep = trennzeichen or _erkenne_trennzeichen(erste_zeile)
|
|
135
|
+
|
|
136
|
+
leser = csv.DictReader(io.StringIO(text), delimiter=sep)
|
|
137
|
+
feldnamen = [f.strip() for f in (leser.fieldnames or [])]
|
|
138
|
+
|
|
139
|
+
braucht = [profil.spalte_id, profil.spalte_vorname, profil.spalte_nachname]
|
|
140
|
+
if rolle is Rolle.SCHUELER:
|
|
141
|
+
braucht.append(profil.spalte_klasse)
|
|
142
|
+
fehlend = [s for s in braucht if s not in feldnamen]
|
|
143
|
+
if fehlend:
|
|
144
|
+
raise CsvFehler(
|
|
145
|
+
f"Spalten {fehlend} fehlen in '{pfad}' (Profil '{profil.name}', "
|
|
146
|
+
f"gefunden: {feldnamen}). Prüfen Sie Profil bzw. csv.spalten in schulsync.yaml."
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
personen: list[SollPerson] = []
|
|
150
|
+
warnungen: list[str] = []
|
|
151
|
+
gesehen: dict[str, int] = {}
|
|
152
|
+
|
|
153
|
+
for nr, zeile in enumerate(leser, start=2): # Zeile 1 = Kopf
|
|
154
|
+
zeile = {(k or "").strip(): (v or "").strip() for k, v in zeile.items()}
|
|
155
|
+
quell_id = zeile.get(profil.spalte_id, "")
|
|
156
|
+
vorname = zeile.get(profil.spalte_vorname, "")
|
|
157
|
+
nachname = zeile.get(profil.spalte_nachname, "")
|
|
158
|
+
klasse_roh = zeile.get(profil.spalte_klasse, "")
|
|
159
|
+
|
|
160
|
+
if not any([quell_id, vorname, nachname]):
|
|
161
|
+
continue # komplett leere Zeile am Dateiende
|
|
162
|
+
if not quell_id:
|
|
163
|
+
raise CsvFehler(f"Zeile {nr}: {profil.spalte_id} fehlt ({vorname} {nachname}).")
|
|
164
|
+
if not vorname or not nachname:
|
|
165
|
+
raise CsvFehler(f"Zeile {nr}: Vor- oder Nachname fehlt (ID {quell_id}).")
|
|
166
|
+
if quell_id in gesehen:
|
|
167
|
+
raise CsvFehler(
|
|
168
|
+
f"Zeile {nr}: ID {quell_id} kommt doppelt vor (zuerst in Zeile "
|
|
169
|
+
f"{gesehen[quell_id]}). Export bitte in der Schulverwaltung prüfen."
|
|
170
|
+
)
|
|
171
|
+
gesehen[quell_id] = nr
|
|
172
|
+
|
|
173
|
+
klasse: str | None = None
|
|
174
|
+
if rolle is Rolle.SCHUELER:
|
|
175
|
+
if not klasse_roh:
|
|
176
|
+
raise CsvFehler(f"Zeile {nr}: Klasse fehlt (ID {quell_id}).")
|
|
177
|
+
klasse = normalisiere_klasse(klasse_roh)
|
|
178
|
+
if klasse != klasse_roh:
|
|
179
|
+
warnungen.append(f"Klasse '{klasse_roh}' → '{klasse}' normalisiert (Zeile {nr}).")
|
|
180
|
+
|
|
181
|
+
personen.append(
|
|
182
|
+
SollPerson(
|
|
183
|
+
quell_id=quell_id,
|
|
184
|
+
vorname=vorname,
|
|
185
|
+
nachname=nachname,
|
|
186
|
+
rolle=rolle,
|
|
187
|
+
klasse=klasse,
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
personen.sort(key=lambda p: (p.klasse or "", p.nachname, p.vorname, p.quell_id))
|
|
192
|
+
return LeseErgebnis(
|
|
193
|
+
personen=personen, kodierung=kodierung, trennzeichen=sep, warnungen=warnungen
|
|
194
|
+
)
|