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/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
+ )