229 Commits
Author SHA1 Message Date
adminandClaude Sonnet 5 c128fc6741 fix: Kalenderraster mit einem gemeinsamen Faktor statt unabhängig in x/y skalieren
CI / build-and-test (push) Waiting to run
Breite und Höhe wurden bisher getrennt behandelt (Breite = volle reale
Boxbreite, Höhe = Größenwahl*Einheitenfaktor) - bei einer Box, deren
Seitenverhältnis vom Referenzentwurf abweicht, verzerrte das die Tageskacheln
sichtbar.

Das Raster wird jetzt komplett in einem festen 170mm-Referenzentwurf
berechnet und erst am Ende mit einem einzigen Faktor k = min(a_x, a_y,
Größenwahl) * millimeterScale in die Koordinaten der Vorlage skaliert - a_x/
a_y sind die Verhältnisse von realer Boxbreite/-höhe zum Referenzentwurf.
Dadurch bleiben die Kacheln immer proportional, auf Kosten von ungenutztem
Rand auf der Achse, die nicht der begrenzende Faktor ist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 15:42:56 +02:00
adminandClaude Sonnet 5 4542f263fc fix: Anwesenheitskalender respektiert die von der Vorlage vorgegebene Boxhöhe
CI / build-and-test (push) Waiting to run
Nachdem die Kalender-Breite jetzt an die reale DRAWBOX/FLOWDRAWBOX-Breite
gekoppelt ist, konnte das (korrekt größer werdende) Raster die von der
Vorlage deklarierte Höhe überschreiten - eine DRAWBOX bricht anders als eine
FLOWDRAWBOX nicht automatisch auf Folgeseiten um, überschüssiger Inhalt wird
von QuestTemplateRenderer stillschweigend am unteren Rand abgeschnitten.

StudentAttendanceCalendarDrawingBuilder ermittelt jetzt vorab, wie viele
Wochen die gewählten Monate brauchen, und verkleinert "Größe" automatisch so
weit, dass der Kalender innerhalb der deklarierten Höhe bleibt (nur bei
DRAWBOX/IsFixed - eine FLOWDRAWBOX darf weiterhin frei wachsen, da sie bei
Bedarf auf weitere Seiten fließt statt abzuschneiden).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 14:39:45 +02:00
adminandClaude Sonnet 5 86a97d91a7 fix: Anwesenheitskalender/Fehltagesliste nutzen die real deklarierte Boxgröße
CI / build-and-test (push) Waiting to run
Die DrawingValue-Builder gingen bisher immer von einer festen Breite (170)
aus, unabhängig davon, wie breit die DRAWBOX/FLOWDRAWBOX in der jeweiligen
Vorlage tatsächlich deklariert ist. Bei einer breiteren Box (oder einer in
"pt" statt "mm" deklarierten Seite, wie im echten Fehlzeitenbrief-Template)
blieb dadurch ein Großteil der eigentlich verfügbaren Fläche ungenutzt leer
- sichtbar als "Luft" rechts und unterhalb des Kalenders im generierten PDF.

CreateLetterDialogViewModel/LetterTemplateTools ermitteln jetzt die real
deklarierte Boxbreite samt Maßeinheit aus dem geladenen Template
(LetterPlaceholderBuilder.FindDeclaredDrawingBox) und reichen sie an die
Builder durch, die ihr mm-entworfenes Raster entsprechend skalieren.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 14:27:05 +02:00
adminandClaude Sonnet 5 763d7744be feat: Circle/RoundedRectangle-Zeichenbefehle im Canvas-Backend, Fehlzeitenkalender-Kacheln größer
CI / build-and-test (push) Waiting to run
Neue DrawingCommand-Primitives DrawCircle und DrawRoundedRectangle (SVG-Rendering
plus FLOWDRAWBOX-Paginierung). Anwesenheitskalender nutzt RoundedRectangle für die
Tageskacheln und verkleinert Monatsabstand/Kachel-Innenabstand, damit die Kacheln
den Rasterplatz sichtbar besser ausfüllen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 13:59:08 +02:00
adminandClaude Sonnet 5 77fe9b1c79 fix: Dokumentationsdialog wächst bei Elternbrief über den Bildschirm
CI / build-and-test (push) Waiting to run
SizeToContent="Height" ohne Begrenzung ließ das Fenster beim Typ
"Elternbrief" (drei zusätzliche Felder) so hoch werden, dass der
Speichern-Button auf kleineren Bildschirmen (z.B. MacBook) nicht mehr
erreichbar war. ScrollViewer bekommt eine MaxHeight, ab der intern
gescrollt wird - analog zum bestehenden Muster in McpConfirmDialog.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 23:12:53 +02:00
adminandClaude Sonnet 5 92e497b54f fix: Fehlzeitenkalender Spaltenbreiten und unnötigen Seitenumbruch
CI / build-and-test (push) Waiting to run
Datum-Spalte in der Fehltage-Tabelle war für "dd.MM.yyyy" zu schmal
bemessen (an der kurzen Kopfzeile "Datum" orientiert) und wurde
abgeschnitten. RenderFlow erzwang außerdem zwischen den Ausschnitten
einer zu hohen Zeichenbox einen harten Seitenumbruch unabhängig vom
tatsächlich verbleibenden Platz auf der Seite - dadurch begann z.B.
der zweite Monat auf Seite 3, obwohl Seite 2 noch reichlich Platz
hatte.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 22:42:29 +02:00
admin 861f2c76ca fix: Umbruchlogik
CI / build-and-test (push) Canceled after 0s
2026-09-16 02:21:21 +02:00
admin ef3e9dbbb6 fix: Briefe Kontaktauswahl
CI / build-and-test (push) Canceled after 0s
2026-09-16 01:49:46 +02:00
admin 03a5e0dd9c fix: Fehlzeitenkalender in Templates, Datenbezug und Layout
CI / build-and-test (push) Canceled after 0s
2026-09-16 00:58:09 +02:00
admin 6dccf96039 fix: Fehlzeitenkalender in Templates
CI / build-and-test (push) Canceled after 0s
2026-09-16 00:50:10 +02:00
admin 6e57bf407d feat: fehlende Variablen ergänzen
CI / build-and-test (push) Canceled after 0s
2026-09-15 23:30:22 +02:00
admin 0b5cfc5522 feat: Feldeditor für Autofelder im TemplateDesigner
CI / build-and-test (push) Canceled after 0s
2026-09-15 11:07:37 +02:00
admin 09cbbf1b77 feat: Fehlzeiten Tabelle für Templates 2026-09-15 10:35:55 +02:00
admin 1671796844 feat: Fehlzeiten Calender 2026-09-15 10:02:33 +02:00
admin 70dc78904c Merge remote-tracking branch 'origin/main'
CI / build-and-test (push) Canceled after 0s
2026-09-14 00:54:43 +02:00
adminandClaude Sonnet 5 7b660c7152 feat: Planungsideen-Feld je Stunde + persistierter Materialerstellungs-Prompt
CI / build-and-test (push) Canceled after 0s
Lesson bekommt ein neues Freitextfeld PlanningIdeas fuer grobe Ideen vor der
Feinplanung, sichtbar im LessonDialog und als Kontext an die KI-Planung
(Backend + MCP) durchgereicht. LessonPhaseStep.MaterialPrompt speichert den
beim Uebernehmen einer KI-Stunde erzeugten Materialerstellungs-Prompt dauerhaft,
statt ihn nur einmalig im AiAssistDialog anzuzeigen - im Verlaufsplan-Editor
ueber einen neuen Kopieren-Button je Phase erneut nutzbar, auch ueber MCP
lesbar/schreibbar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 00:41:13 +02:00
admin 3675b70004 feat: exclude group from untis hub
CI / build-and-test (push) Canceled after 0s
2026-09-13 23:58:38 +02:00
admin eb1b2340b7 feat: expand teaching mode with live timeline and seating quick checks
CI / build-and-test (push) Canceled after 0s
2026-09-13 23:16:36 +02:00
adminandClaude Sonnet 5 b8193be6ec fix: Untis-Hub-Status ueber Geraete hinweg synchronisieren
CI / build-and-test (push) Canceled after 0s
UntisHubJobState (Faelligkeits-Zeitstempel des Untis-Hubs) lief bisher
ausserhalb des Sync - jedes Geraet fuehrte seine eigene Buchhaltung, wodurch
auf allen Instanzen dieselben Punkte offen blieben und ein bereits erledigter
Abgleich anderswo erneut WebUntis-Traffic ausgeloest haette (Nutzer-Feedback).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 22:24:03 +02:00
admin 0962845ead fix: startup speed
CI / build-and-test (push) Canceled after 0s
2026-09-13 22:04:07 +02:00
adminandClaude Sonnet 5 42c650518c MCP: TMPDIR-Auseinanderdriften zwischen Desktop und Bridge auf Mac/Linux behoben
LehrerApp.Desktop und LehrerApp.McpBridge emulieren die Named Pipe unter
macOS/Linux über eine Socket-Datei unter TMPDIR. Von Finder/Dock
gestartet bekommt Desktop das reguläre Session-TMPDIR unter
/var/folders/.../T, während die Bridge als Kindprozess von Claude
Desktop oft eine reduzierte Umgebung mit TMPDIR=/tmp erbt - beide
suchten die Pipe-Datei dann an unterschiedlichen Orten und fanden sich
nie ("MCP nicht erreichbar", obwohl beide Prozesse liefen). TMPDIR wird
jetzt in beiden Prozessen vor der ersten Pipe-Instanz hart auf denselben,
von ApplicationData abgeleiteten Ordner gesetzt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 01:46:04 +02:00
adminandClaude Sonnet 5 2c791258a1 fix: GroupId ueber MCP auflösbar (get_groups + get_untis_hub_status)
CI / build-and-test (push) Canceled after 0s
Nutzer-Feedback aus echtem Live-Test der neuen Untis-MCP-Tools: groupId ist ueberall
Pflichtparameter (get_grades, get_schedule, get_lesson_plans, get_untis_absence_rows, ...), war
aber nirgends ueber MCP auflösbar - ein KI-Client kannte bestenfalls den Klarnamen einer
Lerngruppe, nie ihre Id.

- Neues GroupTools.cs mit get_groups (Read): listet Lerngruppen mit Id/Name/Typ/Schuljahr/
  Klassenstufe/SubjectId/IsActive, optional nach Schuljahr gefiltert.
- UntisHubStatusRowDto liefert zusaetzlich GroupId mit (null bei den drei dashboard-weiten
  Zeilen), damit fuer eine dort gelistete Gruppe nicht zusaetzlich get_groups noetig ist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 01:13:13 +02:00
admin c528bd825f fix: mcp timeout
CI / build-and-test (push) Canceled after 0s
2026-09-13 00:38:05 +02:00
adminandClaude Sonnet 5 56ab5067e6 feat: Formulare-Menü mit Elternbrief-Einstieg + Arbeitsblatt-Personalisierung (Nutzer-Feedback)
CI / build-and-test (push) Canceled after 0s
Neue Menü-Kategorie "Formulare" in der Menüleiste: "Elternbrief erzeugen..." öffnet
jetzt einen Schüler-Picker statt nur aus der Schülerdetailansicht erreichbar zu sein,
"Arbeitsblatt personalisieren..." ist aktiv, sobald eine einzelne Lerngruppe geöffnet
ist, und erzeugt aus einer in TemplateDesigner gebauten .lavorlage-Vorlage ein PDF je
aktivem Gruppenmitglied - über eine von den Elternbrief-Vorlagen getrennte
WorksheetTemplateStore-Bibliothek.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 00:21:08 +02:00
adminandClaude Sonnet 5 bfe214ecf2 feat: Untis-Hub-Kadenzkopplung, anpassbarer Statusvorschlag + KI-/MCP-Unterstuetzung beim Fehlzeitenabgleich (Nutzer-Feedback)
CI / build-and-test (push) Canceled after 0s
- UntisHubService.RecordRun: ein abgeschlossener Langzeit-Fehlzeitenabgleich schliesst die
  kurzfristige Kadenz derselben Gruppe automatisch mit ab (nicht umgekehrt).
- Fehlzeitenabgleich-Dialog: neue "Uebernahme als"-ComboBox statt starrem Zielstatus, vorbelegt
  mit dem berechneten Vorschlag, aber frei aenderbar.
- Neuer ai-backend-Endpunkt untis-status.php + AiPlanningService.RequestUntisStatusSuggestionsAsync:
  gebuendelter, anonymisierter KI-Statusvorschlag (nur Positions-Id + Rohsignale, nie Name/Klasse/
  Datum), mit hartem Id-Mengen-Abgleich gegen Verwechslung.
- Neue MCP-Tools (UntisComparisonTools): get_untis_hub_status, get_untis_absence_rows/
  apply_untis_absence_status (anonymer Weg ueber ENr-Zuordnung) sowie
  get_named_untis_absence_pattern als bewusste, eng begrenzte Ausnahme (Name+Fehlzeiten fuer
  explizit angegebene Schueler-IDs, mit Bestaetigung ohne Sitzungsfreigabe - dafuer
  IMcpConfirmationService.ConfirmAsync um allowSessionTrust erweitert).
- MapStatus/ENr-Zuordnung aus dem ViewModel in das neue, geteilte UntisLessonAbsenceHelper
  gezogen, damit Dialog und MCP-Tool nie unterschiedliche Statusvorschlaege berechnen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 22:55:33 +02:00
adminandClaude Sonnet 5 d979a84e7b feat: LehrerApp.McpBridge zu resilientem MCP-Proxy umgebaut
Claude Desktop deaktivierte den ganzen MCP-Server, sobald der Initialize-Handshake beim
Bridge-Start fehlschlug - der Regelfall, wenn LehrerApp.Desktop (z.B. nach Windows-Autostart
von Claude Desktop) noch nicht läuft. Die Bridge ist jetzt selbst ein MCP-Server gegenüber
Claude Desktop und gleichzeitig ein MCP-Client gegenüber dem echten Server in LehrerApp.Desktop:
der Handshake gelingt dadurch immer.

Verbunden spiegelt sie die echte Werkzeugliste 1:1; ohne Verbindung bietet sie nur ein
lehrerapp_status-Werkzeug an, das den Grund erklärt und erneut verbindet. Ein Hintergrund-Loop
versucht unabhängig davon alle 5s zu reconnecten und schaltet per notifications/tools/list_changed
automatisch auf die echten Werkzeuge um, sobald LehrerApp erreichbar ist. Bricht die Verbindung
während eines laufenden Aufrufs ab, kommt ein normales Tool-Fehlerergebnis statt eines
Prozessabsturzes zurück.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 21:45:03 +02:00
adminandClaude Sonnet 5 3f8813df51 feat: MCP-Tools für Fach-/Kompetenzverwaltung + Kompetenzzuordnung zu Lessons (Nutzer-Nachtrag)
Neue CompetencyTools.cs: create/update/delete_subject, create/update/delete_competency_domain,
add/update/remove_competency_item, get_subjects, get_competency_catalog. delete_subject und
delete_competency_domain als weitere Destructive-Ausnahmen neben delete_lesson (kein Papierkorb).

Lesson bekommt ein neues Competencies-Feld (analog zu Unit.Competencies) sowie
add_lesson_competency/remove_lesson_competency in LessonPlanTools.cs.

ICompetencyDomainRepository um GetBySubject() erweitert, um den Katalog eines Fachs ohne
bekannte Klassenstufe abzufragen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 20:01:23 +02:00
adminandClaude Sonnet 5 19aa302487 feat: Sitzungsfreigabe für MCP-Bestätigungen + LessonStatus.Cancelled
Sitzungsfreigabe: McpConfirmDialog bietet zwei Checkboxen ("diese Art
von Aktion" / "alle KI-Aktionen für den Rest der Sitzung nicht mehr
nachfragen"), nur beim Bestätigen wirksam. IMcpConfirmationService.
ConfirmAsync bekommt einen per [CallerMemberName] automatisch
befüllten operationKey - kein bestehender Aufruf musste geändert
werden. Freigaben leben als In-Memory-Bookkeeping auf der
Confirmation-Service-Instanz, gelten bis App-Ende, werden aber trotzdem
geloggt, damit stillschweigende Bestätigungen nicht spurlos bleiben.
Grund: 17 identische Bestätigungen in Folge für eine neu generierte
Unterrichtseinheit sind unzumutbar.

LessonStatus.Cancelled ("Ausgefallen") als Alternative zu
delete_lesson für Stunden, die nur ausgefallen sind (Exkursion,
Feiertag), aber als Ereignis dokumentiert bleiben sollen. Als
Cancelled=4 angehängt (bestehende LiteDB-Werte 0/1 sind fix). Konsistent
wie "bereits durchgeführt" behandelt an allen Stellen, die bisher nur
auf Conducted prüften: Terminverschiebung, Fortschrittsanzeige,
Hausaufgabe-kontrollieren-Erinnerung, "nächste Stunde"-Vorschlag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 02:25:45 +02:00
adminandClaude Sonnet 5 a4733c156b feat: prominenterer Bestätigungsdialog + move_lesson/delete_lesson (Nutzer-Feedback)
Nach dem ersten Live-Test mit echtem Bridge-Prozess: der bisherige
ConfirmDialog fiel zu wenig auf, wenn LehrerApp im Hintergrund lief
(Normalfall, da der Anstoß vom KI-Client in einem anderen Fenster
kommt). Neuer, eigenständiger McpConfirmDialog statt Änderung am
geteilten ConfirmDialog (hätte alle anderen Aufrufer mitbetroffen):
breiter, auffälliger Kopfbereich, Topmost. AvaloniaMcpConfirmationService
holt das Hauptfenster zusätzlich aus einer möglichen Minimierung und
aktiviert es vor dem Anzeigen.

move_lesson kapselt die bereits vorhandene LessonSchedulingService.Move
(shiftFollowingLessons öffnet eine Lücke für eine neue Stunde, indem
spätere Stunden derselben Einheit mitverschoben werden) - keine neue
Terminlogik, nur Wiederverwendung.

delete_lesson ist eine bewusste, gezielte Ausnahme von "v1 ohne
Lösch-Tools" auf expliziten Nutzerwunsch: eigene
AllowedDestructiveWriteTools-Liste, Destructive=true-Annotation,
Bestätigungstext betont ausdrücklich die fehlende Papierkorb-Deckung
für Lessons.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 00:43:59 +02:00
adminandClaude Sonnet 5 e6c30b0bc7 feat: MCP-Tool add_lesson_attachment (Nutzer-Nachtrag)
CI / build-and-test (push) Canceled after 0s
download_lesson_attachment deckte bisher nur die Leserichtung ab, der
Nutzer legt Arbeitsblätter aber tatsächlich über die Anhang-Funktion an
Einzelstunden ab. Neues Write-Tool nimmt Dateiname + Base64-Inhalt
entgegen, validiert vor der Bestätigungsnachfrage (leer/ungültig/zu
groß) und lädt erst nach Bestätigung hoch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 23:45:56 +02:00
adminandClaude Sonnet 5 55fba2cadb feat: lokaler MCP-Server, Phase 4 (Elternbrief-Vorlagen + Claude-Desktop-Registrierung)
Schließt die MCP-Server-Spec ab. "Worksheets" aus der Spec entsprechen
im tatsächlichen Datenmodell den .lavorlage-Elternbrief-Vorlagen
(LehrerApp.Templating) - es gibt kein separates Arbeitsblatt-Konzept mit
Fach/Klassenstufe-Metadaten. Neue Tools list_letter_templates (Read) und
render_letter (Read, liefert Base64-PDF, kein DB-Schreibzugriff).

upload_worksheet/update_worksheet bewusst nicht umgesetzt: das
Seitenlayout ist eine eigene positionsbasierte DSL mit eigenem
visuellen Editor (LehrerApp.TemplateDesigner) - ein LLM müsste sie
blind erzeugen, mit hohem Risiko für kaputte Layouts. Platzhalter-Logik
aus CreateLetterDialogViewModel nach LetterPlaceholderBuilder extrahiert,
damit Dialog und MCP-Tool nicht auseinanderdriften.

Neuer McpClientRegistrationService trägt den Bridge-Pfad in Claude
Desktops claude_desktop_config.json ein (Button in den Einstellungen,
nie automatisch), ohne bestehende Fremdeinträge zu verlieren und ohne
eine nicht lesbare Konfigurationsdatei zu überschreiben.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 22:53:46 +02:00
adminandClaude Sonnet 5 dd2e1e7c61 feat: lokaler MCP-Server, Phase 3 (kleinteilige Unit/Lesson-Tools)
Statt eines "update_lesson", das die ganze Stunde inkl. Verlaufsplan als
ein großes JSON-Objekt tauscht, gezielte kleine Tools je Teiloperation
(Nutzervorschlag): create/update_unit, create/update_lesson (Metadaten
ohne Phasen), add/update/remove_lesson_phase (je eine Phase),
download_lesson_attachment (Base64, auf 3 MB gedeckelt). Verkürzt das
Lesen-Schreiben-Zeitfenster je Operation und liefert lesbare Diffs für
den Bestätigungsdialog statt eines Objekt-Dumps.

ILessonRepository um GetById ergänzt (fehlte bisher, war aber
Voraussetzung für jedes der neuen Tools). get_lesson_plans liefert jetzt
zusätzlich Phase-IDs und Attachment-Metadaten, damit ein Client sie
gezielt referenzieren kann.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 21:52:48 +02:00
adminandClaude Sonnet 5 0c60a54c4d build: LehrerApp.McpBridge ins macOS-App-Bundle aufnehmen
build-macos-app.sh publiziert und platziert jetzt zusätzlich zur
Hauptapp die MCP-Bridge-Binary (Contents/MacOS/LehrerApp.McpBridge),
inklusive Universal-Binary-Handling und eigenständiger Ad-hoc-Signierung
vor dem Bundle-weiten codesign --deep-Schritt. Publish/Lipo/Kopier-Logik
dafür in eine wiederverwendbare place_binary()-Funktion extrahiert.

Nur auf Bash-Syntax geprüft (kein macOS in dieser Session verfügbar) -
noch nicht auf echtem macOS gebaut/verifiziert, siehe TODO.md 4.5.27.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 20:23:45 +02:00
adminandClaude Sonnet 5 9567d8d616 feat: lokaler MCP-Server, Phase 2 (Write-Tools + Bestätigungsdialog + Lesson-Plans)
Neue Write-Tools create_time_entry, create_grade_entry und
update_student_group_assignment schreiben nie direkt: jeder Aufruf zeigt
zuerst einen menschenlesbaren Bestätigungsdialog (bestehender
ConfirmDialog, über Dispatcher.UIThread aus dem Pipe-Session-Thread
angezeigt) und schreibt erst nach Bestätigung, mit 2-Minuten-Timeout
gegen eine hängende Session. Zusätzliches Read-Tool get_lesson_plans.

create_note bewusst nicht umgesetzt (kollidiert mit dem bestehenden
Dokumentations-Ausschluss aus Phase 1), create_lesson_plan/
update_lesson_plan wegen der Modellkomplexität von Lesson zurückgestellt
(siehe TODO.md 4.5.26).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 20:14:51 +02:00
adminandClaude Sonnet 5 98f5573999 feat: lokaler MCP-Server, Phase 1 (Infrastruktur + Read-Tools)
Erlaubt einem lokalen KI-Client (z.B. Claude Desktop) strukturierten
Lesezugriff auf Schüler, Klausuren, Noten, Stundenplan und Zeiterfassung.
Neuer LehrerApp.McpBridge-Prozess reicht stdio-JSON-RPC über eine Named
Pipe an einen In-Process-MCP-Server im Avalonia-Hauptprozess durch
(ModelContextProtocol.Core, StreamServerTransport direkt auf der Pipe).
Standardmäßig deaktiviert, Opt-in über neuen Einstellungen-Tab.
Dokumentationstypen (Gesprächsnotizen/Vorfälle/Förderpläne) sind auf
Code-Ebene nie erreichbar (McpToolScope, analog PlainEventStore.Allowed).

Write-Tools, Bestätigungsdialog-UI, Worksheets/Lesson-Plans-Tools und
macOS-Packaging folgen in späteren Phasen (siehe TODO.md 4.5.25).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 19:59:47 +02:00
admin 455c61c946 fix: Uhrzeit-Wraparound-Flakiness in MissingTeachingTime-Tests
CI / build-and-test (push) Canceled after 0s
TimeOnly.FromDateTime(DateTime.Now).AddHours(+-2) wickelt bei Mitternacht um (TimeOnly hat
keinen Datumsanteil) - je nach Ausfuehrungsuhrzeit (ca. 22:00-02:00) landete "+2 Stunden"
zeitlich vor statt nach "jetzt" oder umgekehrt, wodurch zwei Tests zufaellig rot wurden statt
das eigentliche Verhalten (Wartezeit nach Unterrichtsende) zu pruefen. Per Gegenprobe auf
unveraendertem main bestaetigt: dieselbe Flakiness besteht dort bereits.

DashboardViewModel erhaelt einen optionalen Func<DateTime>? now-Konstruktorparameter (Default
DateTime.Now, gleiches Muster wie die bereits bestehenden optionalen annualPlanEvents/
schoolWeather-Parameter) und nutzt ihn statt direkter DateTime.Now-Aufrufe in Load() und
LoadMissingTeachingTime. Die beiden betroffenen Tests injizieren jetzt einen festen
Referenzzeitpunkt (ein Feiertags-freier Dienstag im September) und sind damit unabhaengig
von der tatsaechlichen Ausfuehrungsuhrzeit deterministisch gruen.

Details siehe TODO.md, Abschnitt 9 (Dashboard).
2026-09-11 01:19:29 +02:00
admin 6af4bee1f0 refactor: Dashboard-Handlungsbedarf zu einer Karte zusammenfassen
Fasst die sieben Kacheln, die tatsaechlich "wo muss ich reagieren" beantworten
(Entschuldigungen, Fehlzeiten-Warnung, Foerderplan-Wiedervorlage, Korrekturen,
ungeplante Stunden, Auffaelligkeiten, Unterrichtszeit-Nacherfassung), zu einer
Karte "Handlungsbedarf" mit Filter-Chips zusammen statt sieben eigener
Sichtbarkeits-Schalter.

Neues gemeinsames Modell AttentionItem/AttentionGroup/AttentionAction traegt nur
Anzeigedaten - die Datenbeschaffung bleibt unveraendert in den jeweiligen
DashboardViewModel.LoadXxx-Methoden, die am Ende ein AttentionItem statt ihrer
eigenen Item-Klasse erzeugen. RebuildAttention() gruppiert nach Art (feste
Reihenfolge) und wendet Filter an, ohne die Repos erneut abzufragen.

SupportPlanDueItem, CorrectionProgressItem, UnplannedLessonItem und
DashboardAlertItem entfallen (nur dashboard-intern verwendet); OpenExcuseItem
und AttendanceWarningItem bleiben bestehen, da GroupOverviewViewModel sie
weiterhin nutzt. Fuenf Navigations-Commands wurden durch ein einziges
OpenAttentionItemCommand ersetzt.

Bewusste Verhaltensaenderung: LoadAlerts dupliziert Fehlzeiten-Ueberschreitungen
nicht mehr in die Auffaelligkeiten-Gruppe, da dieselbe Zahl sonst zweimal in
derselben Karte erschiene (vorher durch zwei getrennte Kacheln nicht sichtbar).

Details und Testanpassungen siehe TODO.md, Abschnitt 9 (Dashboard).
2026-09-11 00:40:58 +02:00
admin c3ce1a7204 fix: Dashboard-Kachelraster - Luecken und falsche Margins bei ausgeblendeten Kacheln
ApplyCardLayout() zaehlte bisher IsVisible statt EffectiveIsVisible, wodurch eine
eingeschaltete aber leere HideWhenEmpty-Kachel weiterhin einen Rasterplatz belegte.
Da im Alltag meist mehrere der sieben HideWhenEmpty-Kacheln leer sind, war das der
Normalfall, nicht die Ausnahme. UpdateDashboardSummary() setzte zudem alle IsEmpty-
Werte, ohne das Layout danach neu zu berechnen.

Zusaetzlich hatte jede Kachel ihren Grid-Margin fest im XAML verdrahtet (links/rechts),
obwohl Spalte und Zeile erst zur Laufzeit aus Sichtbarkeit und Reihenfolge berechnet
werden - beim Ausblenden einer Kachel wanderten die Nachbarn in die andere Spalte,
der Rinnstein sass dann auf der falschen Seite. DashboardCardOption.Margin leitet den
Wert jetzt aus Column ab; das XAML bindet darauf statt fixer Werte.

Drei Regressionstests decken beide Faelle ab.
2026-09-11 00:12:10 +02:00
adminandClaude Sonnet 5 ba52109eaa feat: Untis-Hub für WebUntis-Sync-Gesundheitsstatus
CI / build-and-test (push) Canceled after 0s
Zeigt die Fälligkeit der vier bestehenden, manuell ausgelösten WebUntis-Abgleiche
(Fehlzeiten je Lerngruppe, offene Stunden, Klassenbuch-, Hausaufgabenabgleich) in
einem neuen Hub-Fenster, ohne selbst WebUntis anzufragen - nur gespeicherte
Zeitstempel werden ausgewertet. Konsolidiert die bisher verstreuten Einstiegspunkte
(Sidebar-Button, Dashboard-Buttons) in ein neues WebUntis-Menü plus einen
kompakten Gesundheits-Indikator auf dem Dashboard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 23:41:12 +02:00
admin bd0d7e47ea fix: UntisLogo
CI / build-and-test (push) Canceled after 0s
2026-09-09 00:07:39 +02:00
admin 2ec8adac61 Webuntis stunden check
CI / build-and-test (push) Canceled after 0s
2026-09-08 23:59:07 +02:00
admin e86125e5f9 Hausaufgaben feature und Stundeplan fix
CI / build-and-test (push) Canceled after 0s
2026-09-08 18:01:42 +02:00
admin 86e114580f Fix: Sync Log und verwaise Einträge
CI / build-and-test (push) Canceled after 0s
2026-09-07 17:09:38 +02:00
admin 835cccadec ki Backend fix - Answer Size
CI / build-and-test (push) Canceled after 0s
2026-09-07 12:45:50 +02:00
admin 709ea88c2a Arbeitszeitnacherfassung
CI / build-and-test (push) Canceled after 0s
2026-09-06 13:51:17 +02:00
admin 73453fd88d Add Feature: Monatsübersicht Fehlzeiten Widget
CI / build-and-test (push) Canceled after 0s
2026-09-06 13:27:52 +02:00
admin 265190b3aa UI Improvements: Planungsdialog, Fehlzeitendarstellung, Vorauswahl der Einheit
CI / build-and-test (push) Canceled after 0s
2026-09-05 00:02:28 +02:00
admin fedfceb81d KI-Backend: Antwortretten - Funktion
CI / build-and-test (push) Canceled after 0s
2026-09-04 12:10:42 +02:00
admin 7ef0c95a73 Optimierung des Startvorgangs, lazy load, async im thread pool oder während des Startbildschirms vor dem Mainwindow.
CI / build-and-test (push) Canceled after 0s
2026-09-02 21:46:01 +02:00
adminandClaude Sonnet 5 94946ffefb Stundenplan: verwaiste Stunden per "FixIt"-Button verknüpfen
CI / build-and-test (push) Canceled after 0s
Der Stundenplan erkennt eine Stunde nur über Datum+Stundennummer
(kein gespeichertes Verknüpfungsfeld). Per JSON-Import oder KI ohne
Stundenplan-Bezug angelegte Stunden ohne passende Stundennummer
tauchten dort nie auf - ein Klick auf den Termin bot nur "neu anlegen"
an und hätte eine Dublette erzeugt. Neuer Button im Anlegen-Dialog
sucht stattdessen nach einer passenden vorhandenen Stunde und hängt
sie auf den geklickten Termin um.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 21:27:06 +02:00
admin 3cccb3226c Merge remote-tracking branch 'origin/main'
CI / build-and-test (push) Canceled after 0s
2026-09-02 20:09:24 +02:00
adminandClaude Sonnet 5 988a293c42 Planung: Stunde einer anderen Einheit zuweisen ("Einheit wechseln")
Bisher war die Einheit einer Stunde nach dem Anlegen fix - bei falscher
Auswahl im Erstellen-Dialog blieb nur Löschen+Neuanlegen. Neuer Button
öffnet einen Dialog zur Auswahl der Ziel-Einheit; die Ansicht springt
danach automatisch dorthin mit der Stunde vorausgewählt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 20:07:53 +02:00
adminandClaude Sonnet 5 92f9a75ec1 E-Mail-Ordner-Triage: eM Client 11 MCP-Support als bevorzugten Weg ergänzen
CI / build-and-test (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 18:59:41 +02:00
adminandClaude Sonnet 5 0a419c4c11 WebUntis: Browser-Header gegen HTTP 403, Klassenlehrer-Reports on-demand
CI / build-and-test (push) Canceled after 0s
Fehlzeiten-/Klassenbuch-Reports (reports.do, api/polling/REPORT) sowie
der JSON-RPC-Login schickten bisher keinen User-Agent, Referer oder die
übrigen Header, die WebUntis' eigene WebApp immer mitsendet - führte zu
HTTP 403. Header 1:1 aus einem echten Browser-Request übernommen und
zusätzliche Set-Cookie-Werte (z.B. Tenant-Id) aus der Login-Antwort
eingesammelt statt nur JSESSIONID/schoolname. Zusätzlich eine feste
kurze Pause zwischen Login und erstem Report-Request.

Die Klassenlehrer-Übersicht holt WebUntis-Daten jetzt nicht mehr eager
beim Navigieren oder bei jedem Sync-Ereignis, sondern nur noch über den
bestehenden "Aktualisieren"-Button - vermeidet spürbare Startlatenz und
unnötige WebUntis-Last. Eingehende Sync-Ereignisse (z.B. ein auf einem
anderen Gerät geschlossener Vorgang) bauen die Ansicht per
RefreshFromLocalDataOnly() weiterhin sofort neu auf, aber ausschließlich
aus den zuletzt geholten WebUntis-Daten plus aktuellen lokalen Daten -
ohne selbst je einen WebUntis-Request auszulösen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 14:41:41 +02:00
adminandClaude Sonnet 5 216708e879 Gesamtbild: konfigurierbare Gewichtung, Dämpfung nach Vorgang, Rückfall-Warnung
CI / build-and-test (push) Canceled after 0s
Feste Score-Gewichte durch einen Einstellungsdialog ersetzt. Ein
geschlossener Vorgang dämpft ab seinem Schließ-Datum den zu seinen
Schlagwörtern passenden Score-Bereich (Anwesenheit/Verspätung/
Klassenbuch), ohne Schlagwort-Treffer vorsichtshalber alle drei — die
faktische Fehlquote bleibt davon unberührt. Verschlechtert sich die Lage
danach wieder spürbar, erscheint eine "erneut auffällig"-Warnung im
bestehenden "Muster erkannt"-Panel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 23:52:38 +02:00
adminandClaude Sonnet 5 0d5fb6d146 Vorgänge: verknüpfte Dokumentation direkt zum Nachlesen öffnen
CI / build-and-test (push) Canceled after 0s
"Bearbeiten"-Button an jedem verknüpften bzw. verknüpfbaren Eintrag
öffnet den bestehenden Dokumentations-Dialog, ohne den Vorgänge-Tab zu
verlassen — bisher musste man dafür in den Schülerdatensatz wechseln.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 23:08:59 +02:00
admin 1d9521fc60 SplashScreen
CI / build-and-test (push) Canceled after 0s
2026-09-01 22:51:50 +02:00
admin 4af83c3e68 Update Launch.json 2026-09-01 22:43:10 +02:00
adminandClaude Sonnet 5 15463bc292 "Vorgang": Fallmappe für Klassenbuch- und Dokumentationseinträge
CI / build-and-test (push) Canceled after 0s
Neuer 4. Tab in der Klassenlehreransicht: bündelt Titel, Beschreibung,
Schlagwörter, verknüpfte Dokumentation und angeheftete (eingefrorene)
WebUntis-Klassenbucheinträge zu einem laufenden Problem mit einer/einem
oder mehreren Schüler*innen. Anheften direkt aus dem Klassenbuch-Tab per
Rechtsklick. Sync-fähig nach dem bestehenden Documentation-Muster.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 22:42:06 +02:00
adminandClaude Sonnet 5 987b49e761 Klassenlehrer: faire Fehlquote, Muster-Score statt Heute-Snapshot
CI / build-and-test (push) Canceled after 0s
Verspätung zählt in der Fehlquote nicht mehr wie ein voller Fehltag,
sondern separat mit reduziertem Gewicht. Neuer "Gesamtbild"-Reiter
(Standardansicht) sortiert die Klasse nach einem kumulierten
Auffälligkeits-Score übers Schuljahr statt nur nach dem heutigen Status,
und bezieht dabei auch Klassenbucheinträge anderer Lehrkräfte ein
(Schlüsselwörter für Hausaufgaben/Mitarbeit, "suspendiert" schießt sofort
deutlich hoch).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 21:36:34 +02:00
adminandClaude Sonnet 5 fb7a015b42 CalendarDatePicker: DateTimeOffset<->DateTime-Konvertierung per Converter
CI / build-and-test (push) Canceled after 0s
CalendarDatePicker.SelectedDate ist (WPF-Toolkit-Ursprung) DateTime?,
nicht DateTimeOffset? wie beim eingebauten DatePicker - Avalonia bietet
dafuer keine automatische Konvertierung. Ohne Converter blieb das Feld
leer und ein Klick auf einen Kalendertag warf eine InvalidCastException.
Betraf auch die schon bestehenden Bindings in WithdrawStudentDialog und
CreateLetterDialog, nur bislang unbemerkt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 20:09:08 +02:00
adminandClaude Sonnet 5 09e97efb61 CalendarDatePicker: DateTimeOffset-Felder nullbar machen
CI / build-and-test (push) Canceled after 0s
CalendarDatePicker.SelectedDate ist DateTimeOffset?, die per DatePicker-
zu-CalendarDatePicker-Umstellung betroffenen ViewModel-Eigenschaften
waren aber nicht-nullbares DateTimeOffset. Der alte DatePicker tolerierte
das, CalendarDatePickers Bindung nicht - Feld blieb leer und ein
Konvertierungsfehler wurde beim Auswählen geworfen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 17:19:07 +02:00
adminandClaude Sonnet 5 d297f9f109 Klassenbuch-Tab zum Dokumentations-Hub ausgebaut
CI / build-and-test (push) Canceled after 0s
Umschalter zwischen WebUntis-Klassenbuch und eigener Dokumentation statt
Merge beider strukturell unterschiedlicher Datensätze, mit Zahlen-Badges
je Quelle (kritisch/Nacharbeit hervorgehoben) im Tab und an der
"Klassenbuch öffnen"-Aktion der Übersicht.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 17:05:13 +02:00
admin de6d4236a2 Merge branch 'claude/epic-murdock-f9aa7b'
CI / build-and-test (push) Canceled after 0s
# Conflicts:
#	TODO.md
2026-09-01 15:34:41 +02:00
admin edf7510765 Datepicker update
CI / build-and-test (push) Canceled after 0s
2026-09-01 15:31:16 +02:00
adminandClaude Sonnet 5 2ccc3201d2 DatePicker durch CalendarDatePicker ersetzen (Zeitraum-Datumsfelder)
Avalonias Spinner-DatePicker übernimmt Änderungen nur über einen separaten
Häkchen-Button im Flyout und verwirft sie sonst stillschweigend. Betrifft
alle verbliebenen Zeitraum-Datumsfelder mit diesem Muster.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 14:46:53 +02:00
admin ab1feba0f0 Merge remote-tracking branch 'origin/main'
CI / build-and-test (push) Canceled after 0s
# Conflicts:
#	LehrerApp.TemplateDesigner.Tests/ProjectLifecycleTests.cs
#	LehrerApp.TemplateDesigner/DesignerViewModel.cs
#	LehrerApp.TemplateDesigner/MainWindow.axaml
#	LehrerApp.Templating/LayoutParser.cs
#	LehrerApp.Templating/QuestTemplateRenderer.cs
2026-09-01 10:15:54 +02:00
admin b2d0ccd30d TemplateDesigner: Redesign des TemplateSkripts 2026-09-01 10:07:25 +02:00
admin c6efdab9ef TemplateDesigner: SystemVars
CI / build-and-test (push) Canceled after 0s
2026-08-31 23:47:48 +02:00
admin 11ef73ab1f TemplateDesigner: pdf-Export
CI / build-and-test (push) Canceled after 0s
2026-08-31 23:28:08 +02:00
admin 4b4a746e17 Update im KI-Backend für pdf-import
CI / build-and-test (push) Canceled after 0s
2026-08-31 23:20:19 +02:00
admin e2e2bfb854 feat: add external drawing boxes and paged callbacks
CI / build-and-test (push) Canceled after 0s
2026-08-31 22:10:06 +02:00
admin 3e5f197bdb feat: add flowing templates and PDF import pipeline
CI / build-and-test (push) Canceled after 0s
2026-08-31 19:56:25 +02:00
adminandClaude Sonnet 5 95e3f677e4 Klassenlehrer: Fehlquote nutzt echten WebUntis-Ferienkalender + Sommerferien-Näherung, Diagnose-Logging
CI / build-and-test (push) Canceled after 0s
Nutzer-Verifikation deckte auf, dass WebUntis' getHolidays für dieses Konto nie einen
Sommerferien-Eintrag liefert (11 Jahre Kontohistorie geprüft, kein einziger Juli-/August-Zeitraum
dabei) - vermutlich weil die Sommerferien WebUntis-intern zwischen zwei Schuljahres-Datensätzen
liegen statt in einem. Die reine Ferienkalender-Abfrage aus dem letzten Commit kann diese Lücke
also nicht schließen: der frühere heuristische Ansatz (frühester Fehlzeiten-Eintrag der Klasse als
Näherung für den ersten Unterrichtstag) ist deshalb zusätzlich wieder da, kombiniert mit dem echten
Ferienkalender für den Rest des Jahres.

Herkunft des Nenners jetzt im Tooltip sichtbar (angenommener Unterrichtsbeginn, abgezogene
Ferientage) statt nur intern verrechnet, plus Logging jedes Ferienabrufs (Erfolg oder Fehler) -
sonst wäre der falsche Wert wieder unbemerkt geblieben.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 13:21:16 +02:00
adminandClaude Sonnet 5 cdbed69d8b Klassenlehrer: Verspätungen nicht mehr als fehlende Entschuldigung, echter Ferienkalender für Fehlquote
CI / build-and-test (push) Canceled after 0s
Verspätungen sind i.d.R. nicht entschuldigungsfähig - "Offene Entschuldigungen" erinnert dafür
nicht mehr, stattdessen eskaliert die Mustererkennung ab 5 Verspätungen im Schuljahr zu einem
Elterngespräch/Brief-Hinweis mit Wiedervorlage-Option.

Die Jahresfehlquote nutzte bislang den 1. August als fest verdrahteten Schuljahresbeginn und
zählte damit noch laufende Sommerferien als Schultage mit, was die Quote kurz nach
Schuljahresbeginn stark verfälschte (~57% statt ~100% bei durchgehend fehlenden Schülern). Nutzt
jetzt den echten WebUntis-Ferienkalender (GetHolidaysAsync, gecacht in WebUntisSettingsService).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 11:56:35 +02:00
admin 527c186090 Add constant rich text placeholders
CI / build-and-test (push) Canceled after 0s
2026-08-30 23:23:48 +02:00
adminandClaude Sonnet 5 28469bf547 E-Mail-Ordner-Triage als offenen Punkt in TODO.md skizzieren
CI / build-and-test (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:55:57 +02:00
admin 9dce6576e2 TemplateDesigner: UI update
CI / build-and-test (push) Canceled after 0s
2026-08-30 22:47:53 +02:00
admin 9f28081931 Fix Platzhalter Editer im TemplateDesigner
CI / build-and-test (push) Canceled after 0s
2026-08-30 22:30:26 +02:00
admin 6468596bf3 Add designer file menu and package metadata
CI / build-and-test (push) Canceled after 0s
2026-08-30 20:49:13 +02:00
adminandClaude Sonnet 5 23abe7af63 Dashboard-Erinnerung für fehlende Unterrichtszeit-Erfassung
CI / build-and-test (push) Canceled after 0s
Neue Karte "Unterrichtszeit nacherfassen" schaut 14 Tage zurück und
listet Schultage mit Unterricht laut Stundenplan ohne zugehörigen
"Unterricht"-Zeiteintrag (Ferien/Feiertage/komplett ausgefallene Tage
ausgenommen). Für heute erscheint der Eintrag erst 30 Minuten nach dem
laut Stundenplan letzten Unterrichtsende. Klick auf "Erfassen" öffnet
den bestehenden Nacherfassen-Dialog vorbelegt mit Datum, Kategorie
"Unterricht" und berechnetem Zeitfenster.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 19:38:57 +02:00
admin 3f71f7f371 Add visual template coordinate editor 2026-08-30 19:29:06 +02:00
admin 2f2761caf8 QuestPDF-Vorlagensystem und Designer hinzufügen
CI / build-and-test (push) Canceled after 0s
2026-08-30 15:37:22 +02:00
adminandClaude Sonnet 5 a088823b30 Notentendenzen (3+/2-) korrekt in Notenschnitt einrechnen, Abschnittsende-Vorschlag aus Einheit
CI / build-and-test (push) Canceled after 0s
Bisher liess int.TryParse jede Note mit Tendenz durchfallen und WeightedAverage rechnete den
betroffenen Bereich mit 0 statt sie auszulassen - faelschte Notenuebersicht, Zielnoten- und
Was-waere-wenn-Rechner. Ausserdem schlaegt der Mitarbeits-Assistent beim Abschnitt-Abschliessen
jetzt das Ende der zuletzt abgeschlossenen Unterrichtseinheit statt immer "heute" vor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 13:24:16 +02:00
adminandClaude Sonnet 5 1215d4ac22 Tagesflagge im Sitzplan, Leistungsüberblick mit Zielnoten-Rechner
CI / build-and-test (push) Canceled after 0s
- Mitarbeit: Tagesflagge (👑 Spitzentag / 😴 Schlaftag /  Schlechter Tag)
  je Schüler und Sitzung. Primär im Sitzplatz-Dialog (⇧1/2/3, ⇧X) mit
  Badge auf der Sitzplatz-Kachel; Fallback in der Schnelleingabe für
  Lerngruppen ohne Sitzplan.
- Noten: neuer Dialog "Überblick" in der Notenübersicht zeigt Klausuren,
  Mitarbeit- und sonstige Noten eines Schülers samt berechneter
  Zeugnisnote. Zielnoten-Rechner (ReportGradeTargetCalculator) beantwortet
  "was brauche ich noch für Note X", ein Was-wäre-wenn-Rechner simuliert
  eine zusätzliche Klausurnote. Bewerter-/Schülermodus per Umschalter im
  selben Fenster — Bewertermodus zeigt zusätzlich Kursdurchschnitt je
  Klausur und erlaubt das Bearbeiten von Mitarbeit-/Sonstige-Noten.
  Klausurverlauf als Balken-Sparkline wie die bestehende Notenentwicklung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 11:43:47 +02:00
adminandClaude Sonnet 5 84c03962ef Klausurschnitt-Vorjahresvergleich beim Hochstufen
CI / build-and-test (push) Canceled after 0s
LearningGroup.PreviousGroupId wird von GroupRolloverService jetzt automatisch
gesetzt; neue Karte "Vorjahresvergleich" im Kurs-Tab Übersicht zeigt den
gepoolten Klausurdurchschnitt neben dem der Vorgängergruppe, sobald dort
Ergebnisse vorliegen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 02:33:07 +02:00
admin b7c7d46ee4 Doppelstunden im Stundenplan korrekt anzeigen 2026-08-30 02:29:50 +02:00
adminandClaude Sonnet 5 da4b88c93d Persönliche Klausurlast pro Woche, Mitarbeit-Reset und Sitzplan-Strichliste
CI / build-and-test (push) Canceled after 0s
- Klausuren: neuer ExamWeekLoadService zählt eigene Klausuren je ISO-Woche
  über alle Kurse hinweg (Klassen-Kollisionen deckt bereits der externe
  Klausurplaner ab). Hinweis direkt im ExamDialog beim Datum, zusätzlich
  neue Dashboard-Karte "Klausurwochen" für die 60-Tage-Vorausschau.
- Mitarbeit: Schnelleingabe-Dialog kann eine Bewertung jetzt per Entf auf
  "nicht bewertet" zurücksetzen (bisher nur im Sitzplatz-Dialog möglich).
- Sitzplan: Strichliste "gemeldet"/"gemeldet und drangekommen" direkt auf
  der Sitzplatz-Kachel (ParticipationEntry.RaisedHandCount/CalledOnCount);
  schlägt im Sitzplatz-Dialog eine Quantitätsbewertung vor (ein Klick zum
  Übernehmen, nie automatisch).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 02:11:20 +02:00
adminandClaude Sonnet 5 538ad65a3a Dashboard-Kalender: Tageszahlen zentrieren, Karte nicht mehr strecken
Wochentag-Header waren zentriert, die Tageszahlen darunter aber
faktisch linksbündig (Button ohne HorizontalAlignment wurde in der
UniformGrid-Spalte gestretcht). Zusätzlich wurde die Kalenderkarte bei
breiten Fenstern über die ganze 2*-Spalte gezogen; MaxWidth+Left-Align
begrenzt sie auf ihre Inhaltsbreite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 01:06:23 +02:00
admin dade41ee8d Stundenplan verbesserung
CI / build-and-test (push) Canceled after 0s
2026-08-30 00:53:32 +02:00
adminandClaude Sonnet 5 2b299fc940 Dashboard-Performance, CI-Workflow, Settings-Aufteilung, Backup-Härtung
- Dashboard: Fehlzeiten-Warnung lädt Mitarbeitssitzungen einmal vorab
  statt pro Schüler/Eintrag einzeln nachzuschlagen (N+1 vermieden);
  neues IParticipationSessionRepository.GetAll() dafür.
- CI: .gitea/workflows/ci.yml baut und testet bei jedem Push/PR.
  Dabei fehlende Release|Any CPU-Konfiguration für 6 Projekte in der
  .sln behoben (LehrerApp.Data.Tests wurde bei Release-Builds der
  Solution bislang stillschweigend übersprungen). TreatWarningsAsErrors
  jetzt aktiv.
- SettingsViewModel (1986 Zeilen) als partial class auf 20 Themen-
  dateien aufgeteilt, Verhalten unverändert.
- Backup: optionaler zweiter Sicherungsordner (USB-Stick/Netzlaufwerk,
  best-effort) und Integritätsprüfung nach jedem Backup
  (DatabaseEncryptionService.CanOpenAndRead).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 00:52:44 +02:00
adminandClaude Sonnet 5 dbd8777e64 Punkteeingabe: Enter und Pfeiltasten unterstützen beide Korrektur-Workflows
Enter läuft jetzt zeilenweise (Arbeit für Arbeit), Pfeil-Hoch/-Runter spaltenweise
mit Umbruch zur Nachbaraufgabe (Aufgabe für Aufgabe) - beides ohne Moduswechsel,
da sich beide Reihenfolgen ohnehin auf dieselbe Tabelle abbilden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 22:25:53 +02:00
admin 89a6376fb6 Improve desktop UX and accessibility 2026-08-29 20:56:55 +02:00
admin b7a105af73 Dashboard und Schnellnavigation fix 2026-08-29 19:35:05 +02:00
adminandClaude Sonnet 5 7d3d021d09 Stundenplan: Wochenend-Flakiness in Unterrichtsmodus-Test behoben
OpenWeekCell_LessonHeuteWaehrendUnterrichtszeit_OeffnetUnterrichtsmodus schlug
an Sa/So fehl, weil today.DayOfWeek dort keiner Kachel im Mo-Fr-Wochenraster
entspricht. TimetableViewModel bekommt dafür eine austauschbare Clock-Property
(Standard: DateTime.Now), sodass der Test "heute" unabhängig vom tatsächlichen
Wochentag simulieren kann.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 01:35:16 +02:00
adminandClaude Sonnet 5 7fbf035cbb Klausuren-Hauptseite: gruppenübergreifende Liste mit Prioritäts-Score statt Datum
Sidebar-Klausuren war bisher ein Placeholder. Neue Seite zeigt alle Klausuren des
Schuljahres sortiert nach abgeleiteter Dringlichkeit (Korrekturfortschritt statt
manuellem Status), mit Detailbereich, Parallelkurs-Umschalter und Notenspiegel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 01:27:18 +02:00
admin 9b04d7eb98 UX-Update 2.0, Neues Dashboard 2026-08-29 00:45:19 +02:00
adminandClaude Sonnet 5 229ef79e75 Stundenplan: Popup-Menü im Wochenraster statt Dropdown in der Tagesliste
Nutzer-Feedback: die Tagesliste-Buttons waren schon in Ordnung, das Problem
lag im Wochenraster darüber — ein Klick auf eine Stunden-Kachel führt dort
entweder in den Planungsviewer oder zur Einheitenplanung, ohne dass von
außen erkennbar wäre welches Ziel man bekommt. Tagesliste auf den
ursprünglichen Stand zurückgesetzt.

Neu: ein kleiner "⋮"-Button pro Kachel öffnet ein Popup-Menü mit vier
ausdrücklich benannten Zielen (Unterrichtsansicht/Planungsviewer/Sitzplan/
Planung). MenuFlyout statt ComboBox — dabei verstanden, dass ein
$parent[ItemsControl]-Vorfahrenpfad im Flyout nicht funktioniert, eine
normale {Binding} über die DataContext-Vererbung aber sehr wohl. Der
Direktklick springt jetzt außerdem "einheitlicher": bei einer Lesson
während der eigentlichen Unterrichtszeit direkt in den Unterrichtsmodus
statt in den Viewer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 23:21:49 +02:00
adminandClaude Sonnet 5 cd08bbbbc4 Stundenplan: Dropdown für Unterrichtsansicht/Sitzplan/Planung/Planungsviewer
Die zwei Buttons in der "Heute"-Tagesliste mit teils vom Lesson-Status
abhängiger Doppelbedeutung ("Verlaufsplan ansehen"/"Zur Lerngruppe") waren
unklar. Ersetzt durch ein Dropdown mit vier ausdrücklich benannten Zielen;
drei nutzen bestehende Commands, "Sitzplan" (Sitzpläne-Tab) ist neu und
bisher nur indirekt über den Unterrichtsmodus erreichbar gewesen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:03:11 +02:00
adminandClaude Sonnet 5 442e3d5e3d Unterrichtsmodus: Schnellbewertung und Hausaufgabe-Kontrolle direkt im Fenster
Zwei neue Buttons ("Mitarbeit", "Anwesenheit/Hausaufgabe") öffnen die
bestehenden Schnellbewertungs-Dialoge direkt aus dem Unterrichtsmodus, ohne
zum Mitarbeit-Tab wechseln zu müssen — TeachingModeViewModel bekommt dafür
eine eigene ParticipationTabViewModel-Instanz, vorselektiert auf die zu
dieser Stunde gehörende Sitzung. Die linke Spalte zeigt jetzt zusätzlich die
Hausaufgabe der letzten Stunde mit Kontrolliert-Checkbox sowie die
Hausaufgabe dieser Stunde als editierbares Feld, statt nur lesbar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 16:20:40 +02:00
adminandClaude Sonnet 5 3f484ce3c8 Klassenlehrer-Tab: vier Feature-Ideen umgesetzt (ohne Elterngesprächs-PDF)
Offene Entschuldigungen mit Frist (neue Seitenspalten-Karte, sortiert nach
Tagen offen), aggregierte Klassenbuch-Kategorien (Chip-Reihe im
Klassenbuch-Tab), Wochentags-Häufung als dritte Mustererkennungsregel, und
eine Wiedervorlagen-Verknüpfung: ein "+"-Button legt direkt aus der Zeile
eine WorkTask "Eltern kontaktieren" an, und eine dritte Musterregel
verknüpft hohe Jahresfehlquote mit fallendem Mitarbeit-Trend.

ContextMenu auf den Zeilen-Buttons scheiterte an Avalonias
$parent-Vorfahren-Bindings (Popup hängt nicht im normalen visuellen Baum) —
stattdessen zwei Geschwister-Buttons pro Zeile. Elterngesprächs-Blatt als
PDF bewusst zurückgestellt, Konzept noch nicht geschärft.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 13:57:09 +02:00
adminandClaude Sonnet 5 3f8dab4abd Klassenlehrer-Tab: Warnung/Gefahr-Farben per validate_palette.js ersetzt
Amber und Korallrot waren laut Nutzer-Feedback auf dem Bildschirm kaum
auseinanderzuhalten. Die dataviz-Skill-Validierung bestätigte das
quantitativ (ΔE 2.5 unter Deuteranopie, 11.4 selbst bei normaler
Farbwahrnehmung — beides unter der Zielschwelle). Neue Werte aus der
validierten Kategorial-/Statuspalette der Skill statt erneut freihändig
gewählter Farben; Tagesüberblick-Balken tauscht zusätzlich die
Segmentreihenfolge, weil Grün und das neue Dunkel-Gelb sonst direkt
aneinanderstießen. Auch ein Kopiereinfügen-Duplikat in TODO.md behoben.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 13:33:54 +02:00
adminandClaude Sonnet 5 b3b86f0cf4 Klassenlehrer-Tab: restliche Quick-Wins (responsive Balken, Tagesüberblick-Chart, Tooltips, Status-Symbol)
Balkenbreiten hingen bisher an einer im ViewModel hart codierten Pixelkonstante;
Grid.ColumnDefinitions ließ sich dafür nicht binden (AVLN3000, kein Setter bei
kompilierten Bindings), deshalb neuer FractionWidthConverter (MultiBinding
gegen die gerenderte Breite einer Track-Border statt Grid-Sternspalten).
Tagesüberblick zeigt jetzt denselben gestapelten Balken statt vier
Einzelzeilen. AbsenceTooltip und TodayUnexcusedPercent waren berechnet aber
ungebunden, jetzt sichtbar. Status-Zeilen bekommen zusätzlich zur Farbe ein
vorangestelltes Symbol (Barrierefreiheit).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 13:25:32 +02:00
adminandClaude Sonnet 5 9d375caf14 Klassenlehrer-Tab: Theme-Ressourcen statt Hex-Farben, gestapelter Trend, Fehlquote seit Schuljahresbeginn
Semantische Brushes (Light/Dark) lösen die fest codierten Dark-Hex-Werte ab, die
im hellen Theme unbenutzbar waren. Trend-Chart zeigt jetzt einen echten
gestapelten Balken über die letzten 7 Schultage statt drei unabhängig
skalierten über Kalendertage; neue Fehlquote je Schüler*in seit
Schuljahresbeginn ergänzt den Heute-Snapshot.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 12:47:13 +02:00
admin cf8de08501 Redesign Klassenlehrer Tab. 2026-08-26 22:50:40 +02:00
admin 265ffe9a74 Untis: Stundenplansync ab App Start 2026-08-26 22:24:10 +02:00
adminandClaude Sonnet 5 0c8bb58e1b fix: Klassenlehrer-Übersicht rief Schülerreport bislang live statt gecacht ab
Die Übersicht wirkte beim Öffnen spürbar zeitverzögert, obwohl Fehlzeiten
und Klassenbuch schon lokal gecacht waren (Nutzer-Feedback: "fühlt sich an
wie ein Live-Pull mit CSV-Parsing"). Grund: genau das passierte - der
Schülerreport (Namen fürs Roster) lief komplett am Cache vorbei bei jedem
Öffnen live gegen WebUntis.

Neues UntisStudentRosterCacheEntry/UntisStudentRosterCacheRepository
(gleiches "kein db.OnChange"-Prinzip wie die bestehenden Caches) plus
UntisReportCacheService.GetStudentRosterAsync - ohne heißes/kaltes Fenster,
da eine Klassenliste keine Historie hat, nur dieselbe Stundenschwelle
"gilt der letzte Abruf noch als frisch".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 22:18:53 +02:00
adminandClaude Sonnet 5 69cc16bbd2 fix: Namensabgleich und Tab-Wechsel im Klassenlehrer-Bereich, Fehltag-Hervorhebung
Zwei Bugs behoben:
- Gefilterte Listen blieben nach Klick auf eine Roster-Zeile leer: WebUntis
  liefert Schülernamen je nach Bericht in unterschiedlicher Reihenfolge
  (Schülerreport "Vorname Nachname", Fehlzeiten-/Klassenbuchberichte
  "Nachname Vorname"). Neuer reihenfolge-unabhängiger Namensvergleich
  (UntisNameMatching), betraf auch den Roster-Namensabgleich der Übersicht
  selbst (Ampel-Symbole/Badges waren ohne ExternKey vermutlich fast immer
  falsch).
- Nach dem ersten Tab-Wechsel (Klick auf Roster-Zeile) blieb ein zweiter
  Klick wirkungslos, sobald man zwischendurch manuell zur Übersicht
  zurückgeklickt hatte: TabbedPage.SelectedIndex war ohne Mode=TwoWay
  gebunden. Explizit ergänzt.

Zusätzlich: Fehlzeiten-Zeilen für einen kompletten Fehltag werden jetzt
dezent hervorgehoben (Nutzer-Feedback, bisher nicht erkennbar).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 23:27:14 +02:00
adminandClaude Sonnet 5 2b29dea824 WIP (unstable): lokaler Cache für Klassenlehrer-Fehlzeiten & Klassenbucheinträge
Nutzer-Feedback: die WebUntis-Berichtszeilen sind starr genug für ein eigenes
Datenmodell, Warnungen sollen sofort da sein statt bei jedem Öffnen neu
abgerufen zu werden - vor allem darf derselbe Bericht nicht mehrfach pro
Stunde abgerufen werden, nur weil die Ansicht mehrfach geöffnet wird (Sorge,
bei WebUntis aufzufallen).

Neue Modelle UntisAbsenceCacheEntry/UntisClassRegisterCacheEntry (1:1 zu den
bestehenden DTOs) + UntisCacheFetchState, bewusst nicht synchronisiert
(gleiches "kein db.OnChange"-Muster wie UntisSnapshotEntry/AnnualPlanEvent) -
jedes Gerät ruft WebUntis selbst ab, die Zeilenzahl wächst übers Schuljahr
gewollt an.

UntisReportCacheService: festes heißes Fenster der letzten 14 Tage, höchstens
stündlich automatisch aufgefrischt; alles Ältere gilt als endgültig und wird
dauerhaft aus dem Cache bedient. Die Entscheidungslogik (Plan) ist als reine,
ohne Repositories/HTTP testbare Funktion ausgelagert. Klassenlehrer-Ansichten
nutzen den Cache-Service statt WebUntisIntegrationService direkt; ein
zusätzlicher Button umgeht die Stundensperre bewusst für manuelle Abrufe.

Noch nicht mit echtem WebUntis-Zugang gegengeprüft (siehe TODO.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 23:10:01 +02:00
adminandClaude Sonnet 5 056e864edd fix: WebUntis-Schülerreport verkraftet fehlenden externKey
Klassenlehrer-Übersicht scheiterte komplett mit "Ungültige Zahl in Spalte
externKey", sobald ein Schüler der Klasse keinen gepflegten externen
Schlüssel hat (in echten Schuldaten vorkommend). externKey ist jetzt
optional statt Pflichtfeld (UntisStudent/UntisStudentDto.ExternKey -> int?);
betraf denselben Bericht wie der bestehende WebUntis-Klassenimport, der das
aber schon immer beim Weiterimport toleriert hatte.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 22:47:04 +02:00
adminandClaude Sonnet 5 33c15ffe3e WIP (unstable): Klassenlehrer-Feature auf Einstellungen/eigenen Bereich umgestellt
Nutzer-Feedback: Klassenlehrer ist eine Rolle für die ganze Klasse, unabhängig vom
eigenen Unterricht - die Anbindung an ein LearningGroup.IsClassTeacher-Flag mit Tab
in der Gruppendetailansicht war deshalb der falsche Ort. Ersetzt durch:

- Klassenauswahl in den WebUntis-Einstellungen (WebUntisSettingsService.HomeroomClassName),
  über den bestehenden WebUntis-Klassenpicker.
- Eigener Sidebar-Bereich "Klassenlehrer" statt Gruppen-Tab.
- Zwei Ebenen: kompakte Roster-Übersicht mit Ampel-Symbolen (Fehlzeiten heute,
  Klassenbuch-Badge) und Details (die bisherigen Rohdaten-Listen, jetzt gefiltert
  auf einzelne Schüler*innen anspringbar).

Noch nicht mit echtem WebUntis-Zugang gegengeprüft (siehe TODO.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 22:30:34 +02:00
admin 5e16ebf1bf Update Sync 2026-08-25 21:30:00 +02:00
adminandClaude Sonnet 5 7245196689 WIP (unstable): Klassenlehrer-Tab (fremde Klassenbucheinträge + Fehlzeiten der Klasse)
Neuer Tab in der Gruppendetailansicht (LearningGroup.IsClassTeacher-Flag), zeigt
Klassenbucheinträge anderer Lehrkräfte sowie Fehlzeiten der ganzen Klasse über alle
Fächer, pro Schüler*in und Tag zusammengefasst. Beides rein lesend, kein Übernehmen
in lokale Daten. Fehlzeiten-Bericht (AbsencePerStudent) adressiert die Klasse über
"KL"+getKlassen-ID, automatisch aufgelöst - noch nicht mit echtem WebUntis-Zugang
gegengeprüft (siehe TODO.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 21:19:39 +02:00
adminandClaude Sonnet 5 f9f5f75aa8 Dokumentation: Eintrag von WebUntis-Klassenbuch-Abgleich ausschließen
Neues Feld Documentation.ExcludeFromWebUntisSync (Checkbox im Dialog) blendet
den Eintrag aus dem Zwischenablage-Vorschlag im Klassenbuch-Abgleich aus, für
Notizen, die nur für die eigene pädagogische Arbeit gedacht sind.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 11:08:50 +02:00
admin 7fa8a93c82 Klassenbucheinträge abgleich und poll 2026-08-25 09:31:41 +02:00
admin 87a7badb44 Untis-API: Fehlzeiten abgleich 2026-08-25 00:21:53 +02:00
admin 17475a781f Untis-API Optimierung. 2026-08-24 22:33:30 +02:00
admin 1db16f8b69 Untis-API: Download Bug behoben 2026-08-24 22:06:42 +02:00
admin 34a9fdf73b Untis-API von Server zu Client 2026-08-24 22:00:26 +02:00
admin 0f10f754d0 Untis API Integration 2026-08-24 21:52:00 +02:00
admin 5841d96c5b Vorarbeit: WebUntis-API 2026-08-24 12:14:36 +02:00
adminandClaude Sonnet 5 a30aab0fa5 Fix: doppelte Mitarbeits-Sitzung durch Unterrichtsmodus-Instanz
EnsureTodaySession() prüfte nur den lokalen Instanz-Zustand, nicht das
Repository. Da der Unterrichtsmodus eine eigene, per DI frisch aufgelöste
SeatingPlanTabViewModel-Instanz nutzt (getrennt vom Sitzplan-Tab der
Kursübersicht), legte eine spätere Bewertung im normalen Tab eine zweite,
unverknüpfte Sitzung für denselben Tag an, statt die bereits über den
Unterrichtsmodus verknüpfte zu erkennen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 11:43:19 +02:00
adminandClaude Sonnet 5 07788dcfcb TODO: TickTick-Sync für Erinnerungen ergänzt
Recherche zur TickTick Open API (Auth per persönlichem Token, Push/Pull-
Endpunkte, Architektur analog zum bestehenden WebUntis-Abgleich) als
neuer Punkt 6.1.7 festgehalten.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 01:12:47 +02:00
admin 788ae6ad32 Wetterdienst: Hover Bugfix 2026-08-23 21:51:26 +02:00
admin 83a430f8ee Wetterdienst: Erweitert 2026-08-23 21:45:25 +02:00
admin e76884a58e Wetterdienst: Verhersage verlängert, Anzeige im Stundenplan. 2026-08-23 21:31:15 +02:00
admin 1b32166ce0 Wetterdienst 2026-08-23 20:51:19 +02:00
admin cc973418f3 Wetterdienst 2026-08-23 20:51:06 +02:00
admin dd5ecb0334 Jahresplanimport und Ablgeich von Untis 2026-08-23 19:23:05 +02:00
adminandClaude Sonnet 5 4a59bff9de Merge branch 'unstable': WebUntis-iCal-Abgleich für Vertretungen/Ausfälle
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 18:43:49 +02:00
adminandClaude Sonnet 5 4b9bcfe85d fix: WebUntis-Vertretungen für kombinierte Gruppen und Karteileichen
- ClassToken-Vergleich normalisiert jetzt symmetrisch (beide Seiten) und
  ExtractClassTokens akzeptiert ";" und "," als Trennzeichen zwischen
  Klassen, da nie an einem echten kombinierten Termin verifiziert wurde,
  welches WebUntis tatsächlich verwendet.
- Abweichende Vertretungen tragen jetzt den genauen Vergleichsgrund
  (Fach/Klasse, roh vs. erwartet) in ihrer Beschreibung.
- Automatisch erzeugte Vertretungen, die bei einem späteren Poll nicht
  mehr abweichen, werden jetzt aktiv wieder entfernt statt als
  Karteileichen stehen zu bleiben (UntisDiffResult.SubstitutionExternalIdsToDelete).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 18:43:36 +02:00
adminandClaude Sonnet 5 4eb4d0a946 WIP (unstable): WebUntis-iCal-Abgleich für Vertretungen/Ausfälle
Erkennt Vertretungen, Ausfälle und Zusatzaufsichten aus dem persönlichen
WebUntis-iCal-Feed und schreibt sie automatisch als SubstitutionEntry.
Bekannter offener Bug: es tauchen weiterhin falsche Vertretungen für
Stunden auf, die real unverändert sind — wird in einem Folge-Commit
untersucht, deshalb vorerst auf diesem Branch statt main.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 13:30:15 +02:00
adminandClaude Sonnet 5 e65a729f97 feat: Pädagogische Klassen-Aufgaben, Prioritäten/Checklisten, Dashboard-Schnellzugriffe
Neue Aufgabenart über bestehende Felder (WorkTask.Kind=Reminder + GroupId) statt Vererbung,
mit Priorität (TaskPriority) und optionaler Abhak-Liste (ChecklistItems, wahlweise Kurs-
Roster oder Freitext). Kurs-Dashboard zeigt jetzt eine "Anstehende Aufgaben"-Karte samt
direktem Anlege-Button; derselbe Anlege-Einstieg wurde auch ins Hauptdashboard und (als
Schnellüberblick samt fehlender Übersicht/Mitarbeit-Buttons) in die Gruppenliste gezogen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 22:24:38 +02:00
admin e7c3fdd4ec Bugfix: Label path 2026-08-20 23:59:30 +02:00
admin 59bd8abb45 feat: sync guard - Blockieren alter Clients beim sync 2026-08-20 23:54:04 +02:00
adminandClaude Sonnet 5 7b883555bf feat: Chemikalien-Recherche über lokale RiSU-Stoffliste (kein KI-Aufruf mehr)
Ersetzt den ursprünglichen KI-Websuche-Ansatz (GESTIS scheiterte sowohl über
Websuche als auch über direkten API-Zugriff an fehlenden schulspezifischen
Tätigkeitsbeschränkungen) durch einen reinen lokalen Datenbank-Lookup gegen die
offizielle RiSU-Stoffliste (1764 Stoffe inkl. Tätigkeitsbeschränkungs-Codes,
Legende zur Abfragezeit übersetzt statt in die Datenbank gebacken). Keine
KI-Kosten, keine Drittanbieter-Abhängigkeit mehr für diese Funktion.

HazardSubstance.AgeRestriction zu ActivityRestriction umbenannt, da die
Tätigkeitsbeschränkung mehr abdeckt als reine Altersgrenzen. Details und
verworfene Zwischenstände in TODO.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 23:43:56 +02:00
adminandClaude Sonnet 5 038337997f feat: Anhänge je Stunde, Klausur-Sitzplan mischen, Gefährdungsbeurteilungs-Assistent
Lesson bekommt dieselbe Anhang-Infrastruktur wie Documentation (Material,
Arbeitsblätter, Experimentunterlagen), samt Fix einer Sync-Lücke, die Anhang-
Dateibytes bisher nur für Documentation statt generisch übertragen hat
(IHasAttachments). Sitzplan-Tab bekommt einen "Plätze mischen"-Button für
Klausursitzpläne. Neu: mehrschrittiger Gefährdungsbeurteilungs-Assistent mit
optionalem KI-Entwurf (ai-backend/gbu.php) und PDF-Export, Format bewusst als
JSON-Anhang statt eigener Datenbank-Entität. Details und Architekturentscheidungen
in TODO.md (4.2, 7.1.5, 10.1.8).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 17:13:14 +02:00
adminandClaude Sonnet 5 331033db5c feat: Dunkelmodus, Fenstergröße merken, Papierkorb für Löschvorgänge (12.4, 14.3, 14.6)
Dunkelmodus über neuen Einstellungen-Tab "Darstellung" (Systemvorgabe/Hell/Dunkel),
Fenstergröße/Maximiert-Status wird über Sitzungen hinweg gemerkt (bewusst ohne
Fensterposition), und ein generischer Snapshot-basierter Papierkorb (30 Tage) für
Sitzpläne, Noten, Notenschlüssel-Vorlagen, Aufgaben und Zeiteinträge. Details und
bewusste Scope-Entscheidungen (Spaltenbreiten zurückgestellt, Farb-Audit für
Dunkelmodus offen, welche Entitäten der Papierkorb abdeckt) in TODO.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 16:26:10 +02:00
adminandClaude Sonnet 5 d9d007cd4b feat: Unterrichtsmodus - konsolidierte Ansicht für die laufende Stunde
Verlaufsplan und Sitzplan bisher über mehrere Tabs verteilt, obwohl im Unterricht selbst beides
gleichzeitig gebraucht wird. Neuer Button "▶ Unterricht" in der "Heute"-Tagesliste des
Stundenplans (nur bei vorhandener Lesson) öffnet ein maximiertes Fenster: links der
schreibgeschützte Verlaufsplan als kompakte Kartenliste, rechts der volle, unverändert
wiederverwendete SeatingPlanTabView (Drag&Drop, Schnellbewertung, Situations-Tags, PDF-Export).
TeachingModeViewModel baut auf dem bestehenden LessonViewerViewModel auf, dadurch kommen "Zur
Mitarbeit"/"Zu den Noten" ohne Duplizierung mit.

Neue SeatingPlanTabViewModel.SelectOrCreateSessionForLesson verknüpft die Mitarbeitssitzung
automatisch mit der konkreten Stunde - anders als die bestehende EnsureTodaySession darf das hier
sofort beim Start passieren, da durch den expliziten Klick für genau diese Stunde eindeutig
feststeht, worum es geht (keine Geistersitzungs-Gefahr wie beim bloßen Tab-Öffnen).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 14:53:16 +02:00
adminandClaude Sonnet 5 7365947edc feat: PDF-Druck für Sitzplan, Notenliste, Klausur-Notenspiegel, Kompetenzbericht, Dokumentation
QuestPDF als PDF-Bibliothek eingeführt (11.3), bewusst nur in LehrerApp.Desktop referenziert -
zieht SkiaSharp-Native-Binaries mit, die im Server-Docker-Image nichts verloren haben. Neuer
PdfExportService mit gemeinsamem Kopf-/Fußzeilen-Layout (Titel, Stand-Datum, Seitenzahlen) und
schlanken Druck-DTOs statt ViewModels, damit die Erzeugung ohne Avalonia-Bezug testbar bleibt.
Speicherdialog läuft über den bestehenden ExportService (neues PDF-Format).

Fünf Druckvorlagen (11.4) als "Als PDF"-Button direkt in der jeweiligen Ansicht:
- Sitzplan: Raster mit Tafel-Banner, Tischabständen als echte Lücken, ausgeblendeten Plätzen als
  leere Rasterposition.
- Notenliste: druckt exakt die aktuell angezeigte Matrix (Zeitraum/Darstellung/Sortierung).
- Klausur-Notenspiegel: Kennzahlen, Notenverteilung und Aufgabenanalyse mit Balkendiagrammen,
  auffällig schwache Aufgaben rot markiert.
- Kompetenzbericht: Analyse-Tabelle für den ausgewählten Schüler (erledigt zugleich 8.3.3).
- Schülerdokumentation: alle Einträge chronologisch, vertrauliche/Entwurfs-Einträge markiert.

Layout an gerenderten Muster-PDFs visuell geprüft, nicht nur per Unit-Test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 14:27:05 +02:00
adminandClaude Sonnet 5 4cce84c7dd feat: Warnung + selbstverwalteter Wiederherstellungscode für verlorenen Sync-Schlüssel
Der Sync-Schlüssel verlässt nie den Server im Klartext - verliert das einzige Gerät sync.key,
erzeugte AppBootstrapper bisher stillschweigend einen neuen, unabhängigen Schlüssel, ohne dass
der Nutzer etwas davon merkt. Bereits synchronisierte Server-Daten werden dadurch dauerhaft
unlesbar.

AppBootstrapper erkennt jetzt, ob ein fehlender Schlüssel bei einem bereits eingeloggten Gerät
auftritt (SyncKeyStatus) - SettingsViewModel zeigt dafür ein Warnbanner im Synchronisation-Tab mit
der Empfehlung, im Zweifel über ein noch intaktes zweites Gerät neu zu koppeln.

Zusätzlich ein komplett offline laufender, selbstverwalteter Wiederherstellungscode (kein neuer
dauerhafter Server-Speicher für ein sicherheitsrelevantes Geheimnis): SyncCrypto bekommt einen
neuen, vom Pairing-Code kryptografisch unabhängigen Recovery-Code (120 Bit, Base32), mit dem der
Sync-Schlüssel lokal verschlüsselt und als Datei durch den Nutzer selbst gesichert werden kann.
Neuer SyncKeyRecoveryService kapselt Erzeugen/Einlösen; Redeem schreibt den wiederhergestellten
Schlüssel und setzt vorsorglich Pull-Cursor/Versionsverfolgung zurück.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 12:30:55 +02:00
adminandClaude Sonnet 5 c5a1157a7d feat: unbenutzte Sitzplätze ausblenden können
Manche Räume sind unregelmäßig bestückt (z.B. eine Klasse mit 3 Reihen, in zwei davon fehlt ein
Einzeltisch) - das feste Rows x Columns-Raster kennt aber keine per-Reihe abweichende Spaltenzahl.

Neues Feld SeatingPlan.HiddenSeats für Plätze ohne physischen Tisch. Ein ausgeblendeter Platz
bleibt Teil des Rasters (für die Spalten-/Reihenausrichtung von SeatingPlanPanel), wird im
Ansichtsmodus aber nicht gerendert; im Bearbeitungsmodus bleibt er sichtbar (abgeblendet) mit
einem Umschalt-Button direkt auf dem Platz. Nur leere Plätze lassen sich ausblenden; Zuweisen/
Verschieben eines Schülers auf einen ausgeblendeten Platz wird sowohl im ViewModel als auch im
Drag&Drop-Ziel-Check verhindert. SeatingPlanRepository.Save filtert HiddenSeats zusätzlich auf
das gültige Raster und blendet einen Platz automatisch wieder ein, falls er trotzdem belegt wird.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 12:06:26 +02:00
adminandClaude Sonnet 5 391628ae36 Vernetzung Stundenplan ↔ Unterrichtsplanung: Terminvorschlag, Lesson-Viewer-Sprung, Verzweigung (4.5.1–4.5.3)
4.5.1: Terminvorschlag für neue Stunden schlägt jetzt auch die Stundennummer
vor (bei Doppelstunden die erste Periode), nicht nur den Wochentag —
zieht über den bestehenden LessonNumber-Changed-Hook automatisch auch den
Stundenbeginn nach.

4.5.2 (teilweise): Klick auf eine Stunde im Stundenplan ("Heute"-Liste und
read-only Wochenraster) springt bei bereits vorhandener Lesson direkt in
den Verlaufsplan-Viewer statt nur grob zum Planung-Tab. Das Anlegen einer
neuen Lesson direkt aus dem Stundenplan bleibt bewusst offen (ungeklärte
Unit-Zuordnung, siehe TODO.md).

4.5.3: Der Lesson-Viewer hat jetzt Buttons "Zur Mitarbeit"/"Zu den Noten"
zum Weiterverzweigen in die Lerngruppe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 23:39:27 +02:00
adminandClaude Sonnet 5 52a1d45539 Kurs-Übersicht: fehlende Hausaufgaben; Erinnerungen als eigene Aufgabenart; Kalender-/Sitzplan-Bugfixes
Kurs-Übersicht: neue Karte "Fehlende Hausaufgaben" listet Schüler mit nicht
gemachter Hausaufgabe aus der letzten Mitarbeitssitzung.

Aufgabenverwaltung: WorkTask.Kind unterscheidet jetzt Arbeitsaufträge von
reinen Erinnerungen ohne Zeitbezug (z.B. "morgen Ansage an die Klasse
machen") - eigener Button "🔔 Erinnerung", eigenes Icon in Liste/Dashboard,
läuft nicht in die Zeitauswertung ein.

Drei Bugfixes aus Nutzer-Feedback:
- Dashboard-Kalender zeigte eine Stunde doppelt, wenn sie über "Sitzung
  erzeugen" mit einer Mitarbeitssitzung verknüpft war.
- Der Sitzplan-Tab legte beim bloßen Öffnen einer Gruppe (nicht erst bei
  echter Nutzung) eine leere "Sitzplan"-Sitzung für heute an.
- Der "Meine Klasse"-Ring im Kalender erschien erst, sobald für den Tag
  eine Lesson existierte, statt schon laut Stundenplan.

Außerdem: Drawer-Icons pinnen jetzt explizit auf die farbige Emoji-Schrift
(Windows kann sonst je nach Font-Fallback auf eine einfarbige Variante
ausweichen).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 23:17:18 +02:00
adminandClaude Sonnet 5 4d2a89b14b Kurs-Übersicht gefüllt: nächste Termine, Mitarbeitshinweis, offene Kontrollen/Dokumentationen, auffällige Fehlzeiten
Der bislang leere "Übersicht"-Tab je Kurs zeigt jetzt einen kompakten
"Was steht an"-Überblick aus bereits vorhandenen Daten mit Klick-Durchsprung
in den jeweiligen Tab: nächste Stunde/Klausur, Hinweis auf die letzte
Mitarbeitssitzung, offene Hausaufgaben-Kontrolle, offene Entschuldigungen,
offene Dokumentations-Entwürfe und auffällige Fehlzeiten.

Die Fehlzeiten-Karte ist bewusst kein 1:1-Abbild der Dashboard-Warnung: erst
ab einer Mindeststichprobe von 8 erfassten Terminen im Schuljahr gemeldet,
damit ein einzelner Fehltag zu Schuljahresbeginn nicht sofort als auffällig
gilt. Das Hauptdashboard hat dasselbe Problem noch, wird dort separat
nachgezogen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 22:39:31 +02:00
adminandClaude Sonnet 5 f73bd6c9ec Sitzungen (3.3): aus Stunde erzeugen, löschen, Kalenderüberblick; Doppelstunden-Bugfix Serienerzeugung
Kapitel 3.3: neuer Button erzeugt eine Mitarbeitssitzung aus einer geplanten
Stunde (Datum/Thema, dupl.-sicher über Lesson.LessonId), Sitzungen lassen
sich jetzt mit Rückfrage bei vorhandenen Bewertungen löschen, und der
Dashboard-Kalender zeigt Sitzungen als dritten Termintyp neben Stunden/
Klausuren. Das Bearbeiten von Sitzungen war bereits vorhanden (undokumentiert
aus früherer Ad-hoc-Arbeit) und wird hier nur nachgezogen.

Bugfix: "Serie erzeugen" legte für Doppelstunden zwei Lessons mit gleichem
Datum an statt einer, da jede Stundenplan-Periode einzeln behandelt wurde.
Folgeperioden werden jetzt der Lesson der ersten Periode zugerechnet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 22:17:46 +02:00
admin 0faa3f54de Upgrade Sitzplan - Jetzt mit Bewertungsfeature 2026-08-19 16:46:08 +02:00
admin f514d1d58c Merge branch 'main' of https://git.science-teaching.de/admin/LehrerApp 2026-08-19 14:29:06 +02:00
admin 28beffe3b0 app icon win 2026-08-19 14:28:59 +02:00
admin 9106b4b0e1 Edit Stammdaten Mitarbeitsessions 2026-08-19 12:47:09 +02:00
admin b2c1b15167 Neue Abwensenheitsmodi plus Upgrade Sitzplan 2026-08-19 12:27:12 +02:00
admin 1731e5088e Anpassung am Sitzplan 2026-08-19 11:23:54 +02:00
adminandClaude Sonnet 5 f868b3a259 feat: sichtbare Seite nach eingehendem Sync automatisch neu laden
EventApplier schreibt eingehende Sync-Ereignisse absichtlich direkt auf die rohe LiteDB-Collection
(Ping-Pong-Vermeidung), benachrichtigt dabei aber kein ViewModel - eine per Sync empfangene neue
Stunde erschien bisher erst nach manuellem Neuladen (z.B. Tab-Wechsel).

Neues SyncEngine.DataChanged-Ereignis, gefeuert nach jedem Pull mit Ereignissen sowie bei einer
RemoteWon-Konfliktauflösung; SyncStatusViewModel reicht es durch. MainWindowViewModel abonniert es
und lädt ausschließlich die gerade sichtbare Seite über ihren eigenen, längst vorhandenen
Lade-Einstieg neu - keine Navigation weg von offenen Detailansichten, keine neue Lade-Logik, und
eine laufende Inline-Bearbeitung (Schülerdetail) wird nicht überschrieben.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 00:18:56 +02:00
adminandClaude Sonnet 5 979511ab6a fix: Kontowechsel ließ Push dauerhaft mit 404 scheitern
Nach einem Login mit korrigierter Groß-/Kleinschreibung (siehe vorheriger Fix zur userId-
Stabilität) bezog sich die lokale Versionsverfolgung je Entität (BasedOnServerSeq-Cache) und der
Pull-Cursor weiterhin auf das alte Konto - ServerSeq-Werte sind aber nur innerhalb des Event-Logs
EINES Kontos gültig. Der Push wurde zu Recht abgelehnt, der Server kannte die Entität unter der
neuen userId aber gar nicht (404 beim Nachladen), und HandleRejectedAsync gab bei einem 404
bisher einfach auf, ohne den veralteten Cache-Eintrag zu bereinigen - derselbe Fehlschlag bei
jedem weiteren Sync-Versuch.

Dreiteiliger Fix: (1) ein 404 beim Nachladen löscht jetzt den stale Cache-Eintrag, sodass der
nächste Push die Entität korrekt als neu behandelt und selbstheilend durchgeht; (2) der
"Vollständigen Sync erzwingen"-Button setzt jetzt auch die Push-Versionsverfolgung zurück, nicht
nur den Pull-Cursor; (3) SyncLogin erkennt einen echten Kontowechsel künftig proaktiv anhand der
kanonischen userId aus der Server-Antwort und resettet automatisch, bevor der Folgefehler
überhaupt auftreten kann.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 00:05:24 +02:00
adminandClaude Sonnet 5 ba8786906a fix: Login-userId war nicht stabil gegenüber Groß-/Kleinschreibung
/api/auth/login mintete die JWT-userId bisher aus dem roh eingegebenen Benutzernamen. LiteDBs
Standard-Collation vergleicht den eindeutigen Index auf Username aber case-insensitive - ein
Login mit nur einmal abweichender Schreibweise auf einem zweiten Gerät authentifiziert
erfolgreich, mintet aber eine andere userId. Da EventStore.GetCol(userId) diese direkt als
Dateiname für den Server-seitigen Event-Speicher nutzt, entstanden zwei komplett getrennte
Datenbestände für ein und dasselbe, aus Nutzersicht einzige Konto - Ursache dafür, dass ein
zweites Gerät trotz "since=0" durchgängig 0 Ereignisse erhielt.

UserStore.VerifyPassword(bool) durch Authenticate(UserEntry?) ersetzt, das bei Erfolg den
kanonisch gespeicherten Nutzereintrag liefert; /api/auth/login mintet Token und userId daraus
statt aus der Roheingabe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 23:51:31 +02:00
adminandClaude Sonnet 5 e7e5faeba8 fix: eigener Push ließ Client seinen Pull-Cursor über fremde Ereignisse springen
SyncEngine.PushAsync setzte den lokalen Pull-Cursor bisher aus PushResponse.ServerSequenceNr -
dem globalen Zähler über alle Geräte nach dem eigenen Push, nicht dem tatsächlich zugestellten
Stand. War beim Server zu diesem Zeitpunkt bereits ein noch nicht abgeholtes Ereignis eines
anderen Geräts mit niedrigerer ServerSeq vorhanden, sprang der Cursor darüber hinweg und der
direkt folgende Pull bekam 0 Ereignisse, ohne es je angewendet zu haben - ein zweiter,
unabhängiger Cursor-Bug mit demselben Symptom wie der vorherige Wasserzeichen-Fix, diesmal
client- statt serverseitig.

Ergänzt außerdem einen "Vollständigen Sync erzwingen"-Button in den Sync-Einstellungen, damit
bereits durch diesen Bug zu weit vorgerückte Geräte ihren Fortschritt manuell zurücksetzen und
alle Ereignisse erneut laden können.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 23:38:46 +02:00
adminandClaude Sonnet 5 5bd6967421 fix: Pull-Wasserzeichen sprang über fremde Ereignisse + schärfere Push-Kollisionskontrolle
EventStore.Pull gab bisher den globalen ServerSeq-Höchststand als neuen Cursor zurück statt
den höchsten unter den tatsächlich gelieferten Ereignissen - hatte ein Gerät selbst kurz zuvor
etwas gepusht, sprang sein Pull-Cursor über noch nicht abgeholte Ereignisse anderer Geräte
hinweg und verpasste sie dauerhaft, ohne jeden Fehler.

Ersetzt außerdem die bisherige 30-Sekunden-Heuristik zur Konflikterkennung beim Push durch
exakte BasedOnServerSeq-Prüfung: jedes SyncEvent trägt die ServerSeq, auf der es aufbaut: der
Server lehnt ab, wenn der aktuelle Stand nicht mehr passt. Bei Ablehnung lädt der Client sofort
den neuen Server-Stand nach, löst den Konflikt nach der bestehenden Desktop-vs-Companion/
Timestamp-Politik auf und macht ihn immer in der Konflikt-Review-UI sichtbar, statt die
verworfene Änderung stillschweigend zu verlieren.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 23:25:04 +02:00
adminandClaude Sonnet 5 270c4d40cd feat: Erfolgs-Protokollierung für die gesamte Sync-Kette
SyncEventPublisher/SyncEngine/EventApplier protokollierten bisher
ausschließlich Fehlschläge - ein sauberes Log bewies nur "nichts ist
abgestürzt", nicht ob eine Änderung tatsächlich hoch-/heruntergeladen
wurde. Jetzt wird auch der Erfolgspfad geloggt: Einreihen in die Outbox
(mit SequenceNr), Push/Pull mit Anzahl und Entitätstypen sowie der vom
Server bestätigten ServerSequenceNr, und jedes tatsächlich angewendete
Ereignis. Damit lässt sich anhand der Log-Dateien beider Geräte
nachvollziehen, an welcher Stelle der Kette eine Änderung verloren geht.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 22:20:33 +02:00
adminandClaude Sonnet 5 dea7da8cca fix: Rate-Limit hinter Reverse Proxy wirkte wie ein globales statt Pro-IP-Limit
RemoteIpAddress zeigte hinter Dokploy/Traefik ohne ForwardedHeaders-Middleware
für jede Anfrage auf dieselbe interne Proxy-IP - das Pro-IP-Limit (120/Minute)
wurde dadurch faktisch zu einem einzigen globalen Limit für die gesamte
Bereitstellung, geteilt von allen Geräten und Endpunkten zusammen.

ForwardedHeadersOptions (X-Forwarded-For/X-Forwarded-Proto) registriert,
KnownIPNetworks/KnownProxies geleert (Container nur über den Reverse Proxy
erreichbar). API-only-Fix, Server-Redeploy nötig.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 22:09:59 +02:00
adminandClaude Sonnet 5 722b7f87ff fix: Sync blockierte dauerhaft und unsichtbar bei fehlerhaftem Ereignis
EventApplier.ApplyAsync fing bisher nur LiteException ab - jede andere
Ausnahme (z.B. eine fehlerhafte Entschlüsselung/Deserialisierung eines
einzelnen Ereignisses) fiel unbehandelt aus der Pull-Schleife in
SyncEngine.PullAsync heraus, bevor der Fortschritt (SetLastServerSeq)
gespeichert wurde. Der nächste Sync-Versuch lud denselben Batch erneut
und scheiterte am selben Ereignis wieder - ein dauerhaft blockierter
Sync, bei dem selbst bereits erfolgreich angewendete Ereignisse im
selben Batch nie als erledigt markiert wurden. Zusätzlich protokollierte
weder SyncEngine noch EventApplier irgendetwas, ein Fehlschlag zeigte
sich höchstens als knapper Text in der Sync-Statusleiste.

EventApplier fängt jetzt jede Ausnahme pro Ereignis ab (geloggt über
AppLogger, übersprungen statt den Batch zu blockieren); SyncEngine
loggt jeden Sync-Fehlschlag vollständig. Betrifft nur den Desktop-Client,
kein API-Redeploy nötig.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 21:53:30 +02:00
adminandClaude Sonnet 5 dc21cb2319 fix: Geräte-Pairing - Schlüssel-Code stimmte nie mit dem angezeigten Code überein
SnapshotService.CreateAndUploadAsync lädt in zwei Schritten hoch: Schritt 1
holt einen Code vom Server, Schritt 2 verschlüsselt den Sync-Schlüssel mit
diesem Code und lädt erneut hoch. SnapshotStore.Store() vergab bei jedem
Aufruf bedingungslos einen neuen Zufallscode - der dem Nutzer am Ende
angezeigte Code war dadurch nie derselbe, mit dem der Schlüssel tatsächlich
verschlüsselt wurde. Jede Kopplung musste deterministisch an der
Schlüssel-Entschlüsselung scheitern.

SnapshotUploadRequest bekommt ein optionales Code-Feld; Store() aktualisiert
bei vorhandenem, passendem Code denselben Eintrag statt einen neuen mit
neuem Code anzulegen. Betrifft LehrerApp.Api - der Server muss neu deployt
werden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 21:34:43 +02:00
admin 26461bd2f2 Add shared export infrastructure 2026-08-18 21:21:56 +02:00
adminandClaude Sonnet 5 41bf2c0756 feat: Stundenplan-Badge für unkontrollierte Hausaufgaben (4.5.4)
Lesson bekommt HomeworkChecked/HomeworkCheckDismissed. Der Stundenplan
(Tagesliste und Wochenraster im "Heute"-Tab) zeigt ein 📓-Badge, wenn die
letzte Stunde einer Gruppe eine Hausaufgabe hatte, die noch nicht
kontrolliert oder bewusst übersprungen wurde. Umschaltbar über zwei sich
gegenseitig ausschließende Checkboxen ("Kontrolliert"/"Nicht
kontrollieren") im Verlaufsplan-Editor, direkt neben dem
Hausaufgabenfeld.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 20:52:22 +02:00
admin 462080ab92 Fix null phase data in timetable overview 2026-08-18 20:31:12 +02:00
adminandClaude Sonnet 5 a0f44f1b25 feat: Abwesenheits-Hinweise, Fehlquote bei Zeugnisnoten, Gruppen-Dokumentation
- Schnellbewerten-Dialog: abwesende Schüler werden gedimmt und mit ihrem
  Anwesenheitsstatus statt der Aspektbeschriftung angezeigt, damit keine
  Mitarbeitsnote für nicht anwesende Schüler vergeben wird.
- Zeugnisnoten-Dialog: zeigt je Schüler die Fehlquote im gewählten
  Zeitraum, ab 50 % hervorgehoben (informativ, keine automatische
  Notenänderung).
- Gruppen-Tab "Dokumentation" (bisher Platzhalter) implementiert: listet
  alle Dokumentationseinträge der Gruppen-Schüler, mit Schüler-Filter und
  optionalem "Nur dieser Unterricht"-Schalter. Einträge aus anderen
  Lerngruppen werden standardmäßig mitangezeigt, aber gedimmt. Der
  Dokumentationsdialog bekommt dafür einen optionalen Schüler-Picker.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 19:45:01 +02:00
adminandClaude Sonnet 5 f2cb3c98c6 fix: Geräte-Pairing - Fehlschlag beim Einlösen war unsichtbar
RedeemPairingCode() rief nach einem Fehlschlag sofort RestartApplication()
auf, direkt nach dem Setzen von PairingStatus - die Fehlermeldung konnte
nie gerendert werden, und wurde auch nirgends geloggt. Ein Fehlschlag
(falscher/abgelaufener Code, Server nicht erreichbar) wirkte dadurch wie
ein kommentarloser Absturz ohne jede Spur.

- SettingsViewModel loggt den Fehler jetzt über AppLogger und zeigt vor
  dem Neustart einen Bestätigungsdialog mit der echten Fehlermeldung an.
- AppBootstrapper.RestartApplication() gibt unter dotnet run/IDE-Debug
  (wo ProcessPath auf den dotnet-Host statt die App zeigt) die
  ursprünglichen Kommandozeilenargumente beim Neustart mit, statt nur
  die dotnet-CLI-Hilfe zu öffnen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 16:35:11 +02:00
admin d59d658bba Mac Build System und App Packing 2026-08-18 11:31:14 +02:00
adminandClaude Sonnet 5 fd509fc921 feat: Komfort-Features für Unterrichtsplanung, Zeiterfassung und Dashboard
- Neue-Stunde-Dialog: Datum wird beim Anlegen anhand des Stundenplans
  und der letzten Stunde der Einheit vorbelegt statt auf "heute"
  (behebt eine falsch erkannte Doppelstunde, wenn "heute" nicht auf
  den passenden Wochentag fiel).
- Zeiterfassung: Button "Unterrichtszeit heute übernehmen" schlägt
  Start/Ende aus dem heutigen Stundenplan inkl. Puffer davor/danach vor.
- Dashboard: neue Kachel "Ungeplante Stunden" erinnert an Stunden ohne
  Thema für heute/morgen, mit Opt-out je Gruppe ("Benötigt
  Unterrichtsplanung"), Doppelstunden-Erkennung (keine doppelte Meldung
  für die zweite Periode) und Berücksichtigung von Stundenausfall.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 11:30:50 +02:00
adminandClaude Sonnet 5 a36b1d1d40 fix: Aufsicht-Zeilen im Stundenplan deutlich schmaler als Stundenblöcke
UniformGrid erzwingt für alle Zellen dieselbe, vom größten Kind
bestimmte Höhe - eine Aufsicht-Zeile konnte dadurch nie schmaler
werden als eine Stundenzeile, auch nicht durch kleinere Inhalte.

Cells/WeekItems werden jetzt zusätzlich zeilenweise in neue
GridRows/WeekRows-Collections gruppiert (TimetableRowItem/
WeekRowItem, je eine eigene, ganz normal bindbare RowHeight -
Aufsicht-Zeilen 22px statt 46px/76px bei normalen Stunden-/
Kopfzeilen). Innerhalb jeder Zeile bleibt UniformGrid Columns="6"
für die Spaltenaufteilung. Grid.RowDefinitions ließ sich dafür nicht
per {Binding} setzen (Avalonia lehnt jede Bindungsform dafür mit
AVLN3000 ab) - deshalb dieser Umweg über echte Unterzeilen statt
eines einzelnen Grids mit dynamischer RowDefinitions-Bindung.

$parent[ItemsControl]-Bindings in den Zell-Templates mussten auf
$parent[ItemsControl;1] angepasst werden, da durch die neue
Verschachtelung sonst das innere Zeilen-ItemsControl statt des
äußeren (mit TimetableViewModel als DataContext) getroffen wird.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 09:14:14 +02:00
adminandClaude Sonnet 5 4510c56bb4 fix: Schnellbewerten-Dialog - Datenverlust, Pfeiltasten, Shortcuts
- GetRating las aus einem nie aktualisierten ParticipationEntry-
  Snapshot statt aus der laufend gepflegten Cells-Liste. Beim
  Zurückwechseln zu einem vorherigen Schüler zeigte der Dialog
  dadurch fälschlich keine Bewertung, obwohl sie tatsächlich
  gespeichert war. Jetzt liest GetRating aus Cells, das ungenutzte
  _entry-Feld entfernt.
- Pfeiltasten waren unbehandelt und fielen auf Avalonias
  Standard-Fokusnavigation durch: der Fokus sprang auf den
  Schließen-Button, ein Enter danach schloss den Dialog statt zum
  nächsten Schüler zu springen. Jetzt echte Navigation: ↑/↓ voriger/
  nächster Aspekt (neue PreviousAspect-Methode), ←/→ voriger/
  nächster Schüler - zugleich rechtsseitiges Pendant zu Q/W/E/R/T
  links. Hotkey-Legende im Dialog aktualisiert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 09:14:00 +02:00
adminandClaude Sonnet 5 762c8236fe fix: Sync-Statusanzeige zeigt nach Neustart fälschlich "nicht konfiguriert"
SyncEngine feuert sein erstes StatusChanged bereits im eigenen
Konstruktor. Da SyncEngine und SyncStatusViewModel beide DI-Singletons
sind und Letzterer Ersteren erst innerhalb der eigenen Factory aus dem
Container holt, lief dieser erste Broadcast ab, bevor
SyncStatusViewModel überhaupt abonniert hatte - der Status ging
verloren, StatusText blieb bis zum nächsten Auto-Sync oder manuellen
Sync beim hartcodierten Default "Kein Server konfiguriert".

Fix: Konstruktor ruft nach dem Abonnieren zusätzlich einmal
OnStatus(engine.Status) mit dem bereits vorhandenen aktuellen Zustand
auf. Regressionstest ergänzt - dabei fehlte LehrerApp.Desktop.Tests
das DisableTestParallelization-Attribut (gleicher bekannter
LiteDB-BsonMapper.Global-Bug wie in den anderen Testprojekten, sobald
zwei Testklassen parallel LiteDbContext konstruieren).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 01:14:22 +02:00
adminandClaude Sonnet 5 f8058b537b feat: Geräte-Pairing-UI + Einstellungen-Neustrukturierung
- Sync-Settings-Tab: neuer Bereich "Gerät koppeln" (10.3.1), nutzt den
  bereits vorhandenen, bisher nirgends verdrahteten SnapshotService.
  Ein Gerät erzeugt per CreatePairingCode einen verschlüsselten
  DB-Snapshot + Einmal-Code, das zweite Gerät übernimmt per
  RedeemPairingCode Datenbank und Sync-Schlüssel. Bestätigungsdialog
  vor dem Einlösen, da die lokale Datenbank dabei vollständig ersetzt
  wird (alter Stand wird automatisch als Backup gesichert).
- Einstellungen: TabPlacement von Top auf Left umgestellt (vertikale
  Liste statt langem horizontalem Balken) und die 13 Tabs in drei
  logische Gruppen sortiert (Fachliches / Zeitplanung / System).
- Neuer SettingsTab-Enum ersetzt rohe int-Tab-Indizes bei
  MainWindowViewModel.NavigateToSettings/
  TimetableViewModel.OnNavigateToSettings. Dabei einen bestehenden Bug
  gefunden und mitbehoben: das Zahnrad im Stundenplan öffnete über den
  hartcodierten Index 7 tatsächlich "Datenschutz" statt des laut
  Kommentar/Tooltip beabsichtigten "Ferien & Feiertage".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 01:09:25 +02:00
admin 65c2211dab feat: Importkonflikte gesammelt übernehmen 2026-08-18 00:52:05 +02:00
admin 99efa3a0fe feat: Teilnehmerlisten in Lerngruppen importieren 2026-08-18 00:44:07 +02:00
adminandClaude Sonnet 5 cdac335ad1 fix: Server-Daten in Named Volume statt Bind-Mount
./data war ein Bind-Mount relativ zum Git-Checkout - bei Dokploy wird
das Repo pro Deploy frisch geklont, ein per CLI angelegter Nutzer war
nach einem reinen Routing-Redeploy spurlos verschwunden. Betraf nicht
nur Nutzer, sondern denselben Api:DataPath für Ereignis-Log/Snapshots/
Anhänge - also jeden Server-Datenbestand bei jedem Redeploy.

- docker-compose.yml: Named Volume "api-data" statt Bind-Mount, lebt
  unabhängig vom Checkout im Docker-Daemon.
- backup.sh: auf volume-basiertes Backup umgeschrieben (Alpine-
  Hilfscontainer statt direktem Host-Pfad).
- docker/README.md, TODO.md 10.2.4: Vorfall dokumentiert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 00:02:56 +02:00
adminandClaude Sonnet 5 a68bb933c5 feat: set-password CLI-Befehl für Passwort-Reset
create-user lehnt einen bereits existierenden Nutzernamen ab und
UserStore hatte keinen Weg, ein Passwort nachträglich zu ändern -
einzige Alternative wäre manuelles Editieren der LiteDB-Binärdatei
gewesen. Neuer Befehl set-password <benutzername> [--password <pw>]
nach demselben Muster wie create-user (Cli.ParsePassword extrahiert,
von beiden Befehlen geteilt). docker/README.md ergänzt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 23:53:12 +02:00
adminandClaude Sonnet 5 788eb42fd2 fix: Docker-SDK-Image auf global.json-Version pinnen
docker compose Build schlug bei Dokploy fehl: "A compatible .NET SDK
was not found". global.json pinnt exakt 10.0.301 mit
rollForward: latestPatch (erlaubt nur neuere Patches im selben
Feature-Band), der treibende Tag mcr.microsoft.com/dotnet/sdk:10.0
zeigte inzwischen aber auf 10.0.400 (neues Feature-Band) - latestPatch
lehnt das ab. Build-Stage in Dockerfile.api auf den exakten Tag
10.0.301 gepinnt, TODO.md 10.2.4 um den Vorfall ergänzt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 23:27:35 +02:00
adminandClaude Sonnet 5 858499e8ec fix: personenbezogene Daten aus Companion-Klartextkanal ausschließen
- PlainEventStore.Allowed: Grade/ExamResult entfernt (beide tragen
  StudentId + Notenwert/-kommentar, PlainSyncEvent.Payload läuft aber
  als Klartext-JSON über den Server, anders als der AES-256-GCM-
  verschlüsselte Desktop-Kanal). Nur noch WorkTask/Lesson erlaubt.
- Regressionstest PlainEventStoreTests ergänzt.
- TODO.md 10.3.3 abgehakt, inkl. deutlichem Warnhinweis: die geplante
  Mitarbeitsnoten-Erfassung per Companion-App ist damit bewusst
  blockiert, bis 10.3.1 (Schlüsselaustausch) + eine echte Payload-
  Verschlüsselung für PlainSyncEvent existieren.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 23:27:08 +02:00
adminandClaude Sonnet 5 e679be7e9d docker: Dokploy-Deployment dokumentieren
Kurzanleitung ergänzt, wie die API über Dokploys Git-basiertes
Compose-Deployment ausgerollt wird (Repo-URL, Compose-Path,
JWT_SECRET als Env-Var, Domain/Port, Auto-Deploy-Webhook,
Nutzeranlage) – ersetzt den zunächst erwogenen manuellen
ZIP-Upload-Workaround.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 23:03:53 +02:00
admin 37f4fee574 feat: add student master data import 2026-08-17 13:12:03 +02:00
adminandClaude Sonnet 5 9286bfa3b5 Baustein 11: Kapitel 10 in TODO.md final abgleichen
- Einleitungstext von "Grundgerüst existiert..." auf den finalen Stand
  aktualisiert: Outbound-Publishing, Inbound-Apply, Anhang-Sync,
  Client-UI und Server-Härtung sind umgesetzt und nur aktiv, wenn
  Server-URL + Login konfiguriert sind.
- Abschließenden Verifikations-Absatz zu 10.3 ergänzt: Build/Test-Stand
  (591 Tests) sowie Live-Rauchtest gegen einen laufenden
  LehrerApp.Api-Prozess dokumentiert, inkl. der zwei in dieser Umgebung
  nicht verifizierbaren Szenarien (Zwei-Geräte-Sync, docker compose up).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 11:42:05 +02:00
adminandClaude Sonnet 5 6774123270 Baustein 10: Deployment-Haertung (Kapitel 10)
- Rate Limiting ueber ASP.NET Cores eingebautes
  Microsoft.AspNetCore.RateLimiting (keine neue Paketabhaengigkeit):
  /api/auth/login auf 5 Versuche/Minute begrenzt (Brute-Force-Schutz),
  alle Endpunkte zusaetzlich global auf 120 Anfragen/Minute je IP
- Kestrel MaxRequestBodySize auf 15 MB gedeckelt (Anhaenge sind
  clientseitig ohnehin auf 10 MB begrenzt)
- Neu docker/backup.sh: Tar-Archiv von ./data (Ereignis-Logs,
  Snapshots, Anhaenge, Nutzer), raeumt Archive aelter als 30 Tage auf,
  laeuft direkt auf dem Host
- docker/README.md um Backup- und Rate-Limit-Dokumentation ergaenzt

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 11:38:42 +02:00
adminandClaude Sonnet 5 fc2d7aea3e Baustein 9: Konflikt-Review-UI (Kapitel 10)
Minimale Liste im Tab "Synchronisation" - Entitaet, Zeitpunkt, welche
Seite ConflictResolver gewaehlt hat, mit "Gesehen"-Aktion. Kein
Feld-Diff fuer v1: die Payloads sind clientseitig verschluesselt, ein
Diff wuerde ohnehin nur rohes JSON zeigen.

Neu EventQueue.MarkReviewed(id) - bisher gab es AddConflict/
GetUnreviewed/ConflictCount, aber keinen Weg, einen Konflikt als
gesehen zu markieren.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 11:37:20 +02:00
adminandClaude Sonnet 5 de98b4ed54 Baustein 8: Settings-Tab "Synchronisation" (Kapitel 10)
Neuer Tab in den Einstellungen: Server-URL, Login, Verbindungstest,
Logout.

- Neu SyncSettingsService (Muster AiSettingsService): Token
  AES-256-verschluesselt ueber SyncCrypto mit eigenem, rein lokalem
  Schluessel - ersetzt die bisherigen Klartext-Helfer
  AppBootstrapper.LoadServerUrl/SaveServerUrl und die unverschluesselte
  auth.token-Datei
- Neu SyncAuthService fuer den Login-HTTP-Aufruf gegen /api/auth/login
  und den Verbindungstest (unterscheidet erreichbar & angemeldet /
  erreichbar aber nicht angemeldet / nicht erreichbar)
- Speichern/Anmelden startet die App neu (AppBootstrapper.
  RestartApplication, gleiches Muster wie bei DB-Passwort/AppLock-
  Aenderungen) - SyncEngine/SnapshotService werden nur einmalig beim
  Start registriert, es gibt keinen Live-Re-Registrierungspfad

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 11:34:49 +02:00
adminandClaude Sonnet 5 95345f9c46 Baustein 7: TODO.md-Korrektur (Kapitel 10)
- 10.1.4/10.1.5 abgehakt (SyncStatusViewModel/SyncStatusBar waren
  bereits vorhanden, aber in der Checkliste noch nicht als erledigt
  markiert)
- 10.1.6 abgehakt mit Umsetzungsnotiz (Baustein 2-4)
- Neuer Punkt 10.1.7 fuer den zuvor komplett fehlenden, in der
  Checkliste nicht erfassten Inbound-Apply-Baustein (Baustein 5)
- Neuer Punkt 10.1.8 fuer Anhang-Sync (Baustein 6)
- 10.2.1 abgehakt mit Umsetzungsnotiz (Baustein 1)
- Neuer Punkt 10.3.4 fuer die bewusst akzeptierte v1-Einschraenkung
  beim Regel-Bypass im Event-Applier

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 11:31:09 +02:00
adminandClaude Sonnet 5 f0f8fa25e5 Baustein 6: Datei-Anhaenge synchronisieren (Kapitel 10)
Anhaenge laufen bewusst NICHT ueber den JSON-Ereigniskanal (wuerde ihn
fuer Fotos/Scans stark aufblaehen), sondern ueber einen eigenen
verschluesselten Binaerkanal - analog zum bereits bestehenden Muster
in SnapshotService.

- Neue Endpunkte POST/GET /api/sync/attachments/{storageId} in
  LehrerApp.Api (AttachmentStore, dateibasiert je Nutzer)
- EventQueue: neue, vom JSON-Ereignis getrennte Warteliste fuer
  ausstehende Uploads (SyncEventPublisher traegt Anhaenge einer
  gespeicherten Documentation dort ein)
- AttachmentSyncer laedt ausstehende Anhaenge hoch (in
  SyncEngine.SyncNowAsync nach dem Event-Push)
- EventApplier laedt fehlende Anhaenge nach dem Anwenden eines
  Documentation-Ereignisses nach - ueber die rohe Collection statt
  IAttachmentStorage.Upload, da dieses immer eine neue Id vergaebe und
  hier die Original-StorageId erhalten bleiben muss

Round-Trip-Tests belegen byteidentische Uebertragung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 11:30:04 +02:00
adminandClaude Sonnet 5 ce4dfb0197 Baustein 5: EventApplier (Inbound Apply) + Loop-Prevention (Kapitel 10)
Die zentrale, bisher komplett fehlende Luecke: selbst mit Baustein
1-4 haette SyncEngine.PullAsync empfangene Ereignisse nur zur
Konflikterkennung genutzt, nie in die lokale LiteDB geschrieben -
ankommende Aenderungen von anderen Geraeten waeren nirgends sichtbar
geworden.

Neu EventApplier: entschluesselt, dispatcht ueber eine explizite
EntityType-Tabelle, schreibt IMMER direkt auf die rohe LiteDB-
Collection, nie ueber eine Repository-Save/Delete-Methode - sonst
wuerde der OnChange-Hook (Baustein 2) die gerade angewendete Aenderung
als neues ausgehendes Ereignis re-enqueuen (Sync-Ping-Pong). Ein
gemeinsames Suppress-Flag wurde geprueft und verworfen: SyncEngine
laeuft per Timer nebenlaeufig zum UI-Thread, ein Flag koennte einen
echten Nutzer-Save waehrenddessen verschlucken. Der direkte Collection-
Zugriff ist zustandslos und dadurch korrekt. Kaskaden-Faelle nutzen
dieselben internen LiteDbContext-Hilfsmethoden wie die Repositories
(Baustein 4).

Neu SyncEventPublisher, der den OnChange-Hook in ein verschluesseltes
EventQueue.Enqueue uebersetzt (an LiteDbContext.OnChange gehaengt).

Mit dediziertem Loop-Prevention-Test abgesichert: belegt mit echtem
LiteDbContext + OnChange-Zaehler, dass Apply keinen neuen Hook-Aufruf
ausloest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 11:27:12 +02:00
adminandClaude Sonnet 5 1240a2cd8a Baustein 4: Repositories verdrahten Teil B (Kaskaden & Batch, Kapitel 10)
Die heiklen Faelle, die nicht ueber die oeffentliche Save/Delete-Methode
laufen, sondern mehrere Collections direkt anfassen: GroupRepository
(Save + Delete-Kaskade), ExamRepository.Delete, ExamResultRepository
(Save/SaveMany), DocumentationRepository (Save/Delete/HardDelete),
ParticipationSessionRepository.Delete, ParticipationRepository
(SaveMany/DeleteBySession), CompetencyDomainRepository (inkl. Replace-
/DeleteBySubjectAndGrade).

Regel: pro oeffentlichem Repository-Aufruf genau EIN Sync-Ereignis
(z.B. GroupRepository.Delete -> ein Group/Delete-Ereignis, nicht 14),
Batch-Methoden feuern ein Ereignis pro betroffener Entitaet. Dafuer
ExamRepository.Delete/ParticipationSessionRepository.Delete/
DocumentationRepository.HardDelete auf die in Baustein 2 vorbereiteten
LiteDbContext-Kaskadenhelfer umgestellt (CascadeDeleteExam,
CascadeDeleteParticipationSession, CascadeHardDeleteDocumentation) -
dieselbe Kaskade existiert dadurch nur an einer Stelle im Code, nicht
doppelt (wichtig fuer Baustein 5, wo ein eingehendes Sync-Ereignis sie
erneut braucht).

Tests pruefen explizit die Ereignis-Anzahl bei Kaskaden/Batches.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 11:24:22 +02:00
adminandClaude Sonnet 5 ed34e5d036 Baustein 3: Repositories verdrahten Teil A (Kapitel 10)
OnChange-Hook auf den 19 einfachen Repositories (keine Kaskaden/Batches)
ergaenzt: SeatingPlan, GroupMembership, GradingKeyTemplate, Grade,
GradingScheme, ReportGrade, Unit, Lesson, WorkTask, TimeEntry,
ParticipationAspect, ParticipationSection, Subject, ShorthandCode,
AlternativeLessonPath, TimetableSlot, SchoolHoliday, SupervisionDuty,
SubstitutionEntry. Mechanisch, ein Aufruf nach dem bestehenden
Upsert/Delete.

Tabellengetriebener Test statt 19 fast identischer Testdateien.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 11:22:46 +02:00
adminandClaude Sonnet 5 831cc1c16c Baustein 2: Outbound-Hook-Infrastruktur (Kapitel 10)
LiteDbContext.OnChange-Hook (Sync-agnostisch, kein Verweis auf
LehrerApp.Sync aus LehrerApp.Data), damit Repositories lokale
Schreibvorgaenge signalisieren koennen, ohne dass Data von Sync
abhaengt. StudentRepository als Vorlage verdrahtet.

GroupRepository.Delete-Kaskade (14 betroffene Collections) nach
LiteDbContext.CascadeDeleteGroup extrahiert - reine Verschiebung,
kein Verhaltensunterschied, macht sie aber von einem spaeter
eingehenden Sync-Ereignis (Baustein 5) wiederverwendbar, ohne ueber
Repository-Save/Delete (und damit erneut ueber OnChange) zu laufen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 11:20:30 +02:00
adminandClaude Sonnet 5 6f9de325d5 Baustein 1: Server-Auth-Fix (Kapitel 10)
/api/auth/login und /api/auth/register akzeptierten zuvor jeden
beliebigen Nutzernamen/Passwort und stellten ein gueltiges 30-Tage-JWT
aus - konkrete, ausnutzbare Luecke bei echtem Deployment.

- PasswordHasher (PBKDF2, Salt pro Nutzer) + UserStore (LiteDB) statt
  des ungeprueften Stubs
- /api/auth/register ersatzlos entfernt (kein offener
  Registrierungs-Endpunkt fuer ein Einzel-/Familien-Deployment)
- Neue Nutzer per CLI (dotnet LehrerApp.Api.dll create-user <name>),
  dokumentiert in docker/README.md
- Neues Testprojekt LehrerApp.Api.Tests (bisher als einziges Projekt
  ohne Tests)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 11:20:19 +02:00
admin 8efeb68e93 feat: add planning JSON exchange 2026-08-17 02:04:29 +02:00
admin 50f3b2d6a9 feat: add seating plan drag and quick assessment 2026-08-17 01:49:05 +02:00
admin ad40d11d6e feat: complete dashboard chapter 2026-08-17 01:14:31 +02:00
admin fe36f51886 feat: add seating plans for learning groups 2026-08-17 01:01:40 +02:00
adminandClaude Sonnet 5 fbf70df439 Stunde/Einheit-Dialog: Gruppe + Fach anzeigen, nicht nur Fach
Bei einer Klasse, die in mehreren Fächern unterrichtet wird (mehrere
LearningGroup-Datensätze mit gleichem Namen, z.B. zwei "10c"), zeigte
UnitDialog bisher nur das Fach ("Fach: Chemie"), LessonDialog gar keinen
Gruppen-/Fach-Hinweis. Beide zeigen jetzt "Gruppe · Fach" (z.B. "10c ·
Chemie") direkt unter dem Dialogtitel, nicht editierbar, nur zur Einordnung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 00:47:07 +02:00
adminandClaude Sonnet 5 888df39f37 Fix: ArgumentException im Stundenplan bei gleichnamigen Lerngruppen
TimetableSlotDialogViewModel baute die Gruppenauswahl als Dictionary,
geschlüsselt nach LearningGroup.Name. Bei zwei Lerngruppen mit demselben
Namen (dieselbe Klasse in zwei Fächern unterrichtet, z.B. zwei "10c") warf
ToDictionary eine ArgumentException, der Zuweisen-Dialog ließ sich gar
nicht mehr öffnen.

Behoben durch eindeutige Anzeige-Labels statt des rohen Namens: bei einem
Namenskonflikt wird das Fach angehängt ("10c (Chemie)" vs. "10c
(Mathematik)"), mit nummeriertem Fallback für den Restfall gleicher Name
und gleiches Fach.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 00:27:14 +02:00
adminandClaude Sonnet 5 16ff4875d8 3.1.3: Aspekt-Typen Scale3/Binary/Points in Raster und Dialog unterstützt
Neue Klasse ParticipationRatingScale (Core) ist die einzige Quelle für
Rohwerte je Aspekt-Typ. Scale3/Binary liegen bewusst direkt auf derselben
-2..+2-Achse wie Scale5 (nur mit weniger Zwischenschritten), damit die
bestehende Gewichtung/Mittelwertbildung zur Mitarbeitsnote unverändert
kompatibel bleibt. Points ist grundverschieden (echter Zählwert 0..MaxPoints,
neues Feld auf ParticipationAspect) und wird nur zur Aggregation linear auf
dieselbe Achse normiert.

Ohne diese Normierung hätte ein Punkte-Aspekt die Mitarbeitsnote verfälscht:
drei Stellen summierten bisher den Rohwert direkt (ParticipationGradeDialog-
ViewModel.Recompute, ParticipationWizardViewModels.WeightedRating und
.ComputeSuggestion) - alle drei sind jetzt auf die Normierung umgestellt.

Raster: Punkte-Aspekte zeigen ein NumericUpDown statt fester Stufen-Buttons.
Schnelleingabe-Dialog: Zifferntasten/+/- sind jetzt typabhängig, Legende
zeigt live die für den aktuellen Aspekt gültigen Tasten. Aspekt-Verwaltung
um "Max. Punkte"-Feld ergänzt (nur bei Typ "Punkte" sichtbar).

Bewusst nicht angefasst: die Trendlinien-Visualisierung im
Mitarbeits-Assistenten bleibt fest auf die drei Standardaspekte
zugeschnitten - eigener, größerer Umbau.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 00:12:16 +02:00
adminandClaude Sonnet 5 f8dd567ef8 Dashboard 9.1/9.2: Uhrzeit/Raum bei heutigen Stunden + Absprung zur Mitarbeit
TodaysLessons löst je Stunde den Raum über den passenden TimetableSlot auf
und die Uhrzeit aus Lesson.StartTime bzw. dem Stundenraster - gleiche
Quellen wie im Verlaufsplan-Editor und Stundenplan. Bewusst nicht dupliziert:
die Vertretung/Ausfall-Logik der Stundenplan-eigenen "Heute"-Ansicht bleibt
dort, das Dashboard zeigt nur die einfache geplante Stunde.

Klick auf eine Stunde springt in die Mitarbeitserfassung der Gruppe -
bewusst anderes Ziel als der bestehende Stundenplan-Sprung (dort "Planung"),
da vom Dashboard aus morgens eher die Mitarbeitserfassung naheliegt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 23:54:52 +02:00
adminandClaude Sonnet 5 e40585801c Kapitel 3.1: Mitarbeit-Aspekte pro Gruppe verwalten (3.1.1/3.1.2/3.1.4)
Neuer Button "Aspekte verwalten" im Mitarbeit-Tab öffnet
ParticipationAspectsDialog: Anlegen, Bezeichnung/Typ/Gewichtung/Aktiv-Status
bearbeiten (speichert sofort, gleiches Muster wie die bestehende
Gewichtungs-Bearbeitung in 3.2.1), Hoch/Runter-Reihenfolge, Löschen mit
Rückfrage. Verwaltet bewusst nur die gruppenspezifischen Aspekte, nicht den
bislang nirgends befüllten globalen Standardkatalog.

Schlüssel ist nur beim Neuanlegen editierbar - er verknüpft AspectRating mit
dem Aspekt per Key, ein nachträgliches Umbenennen würde historische
Bewertungen unauffindbar machen.

IParticipationAspectRepository.Save validiert jetzt Pflichtfelder und
Schlüssel-Eindeutigkeit gegen globale Standards UND eigene Gruppen-Aspekte
zusammen, da beide im Bewertungsraster kombiniert verwendet werden. Neue
GetAllByGroup-Methode (inkl. inaktiver) für die Verwaltungsansicht.

3.1.3 (Scale3/Binary/Points im Bewertungsraster selbst) bewusst nicht
angefasst - eigener, größerer Eingriff in die Bewertungs-UI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 23:39:44 +02:00
adminandClaude Sonnet 5 10d82e5e2c KI-Feature 4.5.22: KI-Unterstützung direkt aus dem Stunden-Editor starten
Bisher musste man aus dem Stunden-Editor raus, die KI-Hilfe für die ganze
Einheit aufrufen und die gewünschte Stunde in der freien Anweisung erst
benennen. Neuer Button "KI-Unterstützung für diese Stunde" im LessonDialog
öffnet denselben AiAssistDialog im neuen Fokus-Modus.

AiPlanningRequest.FocusLessonId weist die KI im Systemprompt an, sich auf
genau diese Stunde zu beschränken; zusätzlich wie beim Umfangs-Umschalter
client-seitig hart durchgesetzt in Send() und ApplyResponse, statt der
KI-Antwort blind zu vertrauen.

Da "Übernehmen" direkt ins Repository speichert, wären die noch offenen
Feldwerte des ursprünglichen LessonDialog danach veraltet gewesen - der
Dialog schließt sich deshalb nach einer angewendeten KI-Änderung automatisch
mit dem frischen Stand statt ihn mit alten Werten zu überschreiben.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 22:48:00 +02:00
adminandClaude Sonnet 5 364df378f0 KI-Feature 4.5.21: didaktischer Hintergrund je Stunde, nur auf Nachfrage
Neuer Endpunkt ai-backend/explain.php mit eigenem statischen Systemprompt
(Begründung des Phasenaufbaus, mögliche Stolpersteine, Differenzierungsideen).
Bewusst als separater Endpunkt statt Zusatzfeld in jeder plan.php-Antwort,
damit die Erklärung nur bei tatsächlicher Nutzung abgerechnet wird statt bei
jeder Planungsanfrage mitgeneriert zu werden.

Die Guthaben-Abrechnung (SELECT-FOR-UPDATE, Transaktions-Insert) wurde aus
plan.php nach ai_backend_call_and_charge in db.php ausgelagert, damit sie
nicht an zwei Stellen gepflegt werden muss. Kein neues DB-Schema nötig.

Im AiAssistDialog erscheint je Stunde ein Button "Didaktischen Hintergrund
erklären", der nach dem Laden durch den Text ersetzt wird.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 22:32:36 +02:00
adminandClaude Sonnet 5 f703fe7b66 KI-Feature 4.5.20: Medienvorschlag + Zwischenablage-Prompt für externe Erzeugung
Die KI liefert je Verlaufsplan-Phase optional einen kurzen Vorschlag, was ein
Medium/Material (z.B. Tafelbild) zeigen sollte (AiPhaseStep.MaterialSuggestion),
nur wenn das über das ohnehin genannte Material hinaus einen Mehrwert hätte.
Bewusst nicht ins Domänenmodell übernommen (keine Migration nötig) — nur für
die Review-Anzeige relevant.

AiPlanningService.BuildMaterialPrompt baut daraus rein lokal (kein weiterer
KI-Aufruf) einen vollständigen Prompt aus Fach/Stufe/Gruppe, Einheit, Stunde
und Phase. Im AiAssistDialog landet er per Klick in der Zwischenablage, zum
Einfügen in eine separate Claude-Sitzung für die eigentliche Materialerzeugung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 21:22:12 +02:00
adminandClaude Sonnet 5 da62a08437 TODO.md: Aktualität geprüft — 4.5.19-Reihenfolge, Stand-Datum, Zusammenfassung
4.5.19 stand versehentlich zwischen 4.5.14 und 4.5.15 statt aufsteigend am
Ende von Kapitel 4.5. Stand-Datum war seit mehreren Sessions nicht mehr
aktualisiert. "Empfohlene Reihenfolge" erwähnte den gesamten KI-Feature-Ausbau
(4.5.9-4.5.19) noch nicht und empfahl veraltet Kapitel 10/11 als Nächstes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 16:56:35 +02:00
adminandClaude Sonnet 5 8f2be89133 KI-Feature 4.5.14: Feld-Diff für geänderte Stunden im AiAssistDialog
Statt Vorschläge nur pauschal als "Neu"/"Geändert" zu markieren, zeigt der
Dialog jetzt je geänderter Stunde, was sich konkret unterscheidet (Thema,
Datum, Beginn, Stundennummer, Hausaufgabe/Reflexion, Verlaufsplan) —
AiPlanningService.DescribeChanges vergleicht bestehende Lesson und
KI-Vorschlag feldweise und listet nur echte Unterschiede auf.

Granulare Übernahme einzelner Phasen und Schutz vor Überschreiben eigener
zwischenzeitlicher Änderungen als 4.5.19 zurückgestellt (größerer Umbau).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 16:50:35 +02:00
adminandClaude Sonnet 5 2005b73a16 KI-Backend: deutsches Datumsformat fixen, Fortschrittsanzeige + Nachfassen im Dialog
Live-Test lieferte JsonException: DateOnly/TimeOnly hatten keinen Converter
fürs im Systemprompt dokumentierte deutsche Format (TT.MM.JJJJ/HH:mm), .NET
nutzte stattdessen ISO 8601 in beide Richtungen. Neue Converter mit Fallback
aufs allgemeine Parsen.

AiAssistDialog: indeterminierter ProgressBar statt nur Text während der
Anfrage. Neuer Button "Erneut anfragen" erlaubt Nachfassen mit geänderter
Anweisung, ohne den Dialog neu zu starten — schickt die aktuell angehakten
Vorschläge als Entwurfskontext mit (AiPlanningService.MergeDraft), damit die
KI auf dem noch ungespeicherten Stand aufbaut. Side-by-side-Vergleich beider
Entwürfe als 4.5.18 zurückgestellt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 16:39:46 +02:00
admin 1928916eac KI-Backend: Umfangs-Umschalter + Prompt Caching (4.5.15/4.5.16)
Umfangs-Umschalter: Checkbox "Auch bestehende Stundeninhalte anpassen" im
AiAssistDialog steuert, ob die KI bestehende Stunden inhaltlich ändern darf oder
die Einheit nur um neue Stunden erweitern soll. Zweifach durchgesetzt (Systemprompt
+ hartes client-seitiges Verwerfen in ApplyResponse), nicht nur der KI-Antwort
vertraut. Dabei auch einen Bug gefixt: eine bereits "Durchgeführt" markierte Stunde
wurde durch eine übernommene KI-Änderung stillschweigend auf "Geplant" zurückgesetzt.

Prompt Caching: der Systemprompt ist jetzt vollständig statisch (Voraussetzung für
Caching) und wird von AnthropicProvider.php als "cache_control: ephemeral" markiert
— wiederholte Anfragen innerhalb der 5-Minuten-TTL zahlen nur den reduzierten
Cache-Read-Preis. transactions-Tabelle und Preistabelle um Cache-Token-Spalten
erweitert, Migration für bereits deployte Installationen beigelegt.
2026-08-16 16:06:52 +02:00
admin 4437f951d9 Kompetenzen fast fertig 2026-08-16 02:32:12 +02:00
admin 7c7fe6dfcb KI-Backend: Authorization-Header-Fix + TODO-Ergänzungen (4.5.11-4.5.14)
Login funktionierte, aber jede authentifizierte Folgeanfrage (Guthaben, KI-Anfrage)
meldete fälschlich eine abgelaufene Anmeldung — viele Apache/PHP-FPM-Hosting-Setups
reichen den Authorization-Header standardmäßig nicht an PHP durch. .htaccess erzwingt
jetzt die Weiterleitung, db.php liest zusätzlich REDIRECT_HTTP_AUTHORIZATION/
getallheaders() als Fallback. AiBackendUrl auf die echte deployte Domain gesetzt.

TODO.md: KI-Unterstützung auch für Stundenplanung (4.5.11), Frage zu einem
console.claude.ai-Agent für Standardkontext vs. app-internem Standard-Prompt
(4.5.12/4.5.13), sowie ein Planungsdiff für KI-Vorschläge auf Feldebene statt
grober Neu/Geändert-Markierung (4.5.14).
2026-08-16 02:09:07 +02:00
admin 8546eabbdd KI-Backend: Nutzer-Anlage ohne PHP-CLI (setup-user.php)
scripts/create-user.php setzt SSH/CLI-Zugriff voraus, den viele Shared-Hosting-Pakete
nicht haben. setup-user.php ist die web-erreichbare Alternative (Geheimnis in
config.php, per curl von lokal aufrufbar), muss nach Gebrauch aber wieder vom
Server gelöscht werden — sonst bleibt ein Endpunkt zum Anlegen beliebiger Nutzer
erreichbar.
2026-08-16 01:48:51 +02:00
admin 8495e1b8d0 KI-gestützte Planungsunterstützung (4.5.9) + Kompetenzkatalog-Import (8.1.2)
KI-Unterstützung: neuer Einstellungen-Tab (Anmeldung, Guthaben) und Button im
Planungs-Tab, der Einheiten+Stunden als JSON an ein neues PHP-Backend (ai-backend/)
sendet und die Antwort als prüfbare Vorschlagsliste zurückbringt. Provider-Aufruf,
Guthabenverwaltung und Abrechnung nach echten Token-Kosten laufen serverseitig, der
Desktop-Client sieht nie einen LLM-API-Key. Zentral abgesichert: eine von der KI
zurückgegebene Stunden-Id, die zu keiner echten Lesson der Einheit passt, wird nie
als Update übernommen, sondern immer als neue Stunde behandelt.

Kompetenzkatalog-Import (8.1.2): JSON-Export/Import für Kompetenzkataloge.
2026-08-16 01:31:14 +02:00
admin 2b4fda7bb3 Arbeitszeit: wiederkehrende Aufgaben + Auto-Aufgabe bei Klausurkorrektur (6.1.4/6.1.5)
Damit ist Kapitel 6 (Arbeitszeit & Aufgaben) vollständig abgeschlossen (bis auf den
Export der Auswertung, der an das noch fehlende Kapitel 11 hängt).

- WorkTask.Recurrence (None/Weekly/Monthly): beim Abschließen einer wiederkehrenden
  Aufgabe wird automatisch die nächste Instanz mit verschobenem Fälligkeitsdatum erzeugt.
- GroupDetailViewModel.SetExamStatus legt beim ersten Wechsel einer Klausur von
  "Geplant" auf "Durchgeführt" automatisch eine Korrektur-Aufgabe an.
2026-08-16 00:39:18 +02:00
admin f9e398b225 Kapitel 7 abgeschlossen. 2026-08-16 00:29:01 +02:00
admin da8d2bb1da Neue Funktion Schüler austragen statt aus Kurs löschen. 2026-08-15 23:38:42 +02:00
admin 52bb9c2c2f Arbeitszeit: Auswertung nach Kategorie/Gruppe + Pflichtstunden-Abgleich (Kapitel 6.3)
Dritter Tab "Auswertung": Zeitraum wahlweise Monat oder Schuljahr, Balken je Kategorie
und Gruppe. Neuer WorkloadSettingsService (gleiches JSON-Muster wie PeriodScheduleService)
für die Pflichtstundenzahl pro Woche, bewusst direkt im Auswertungs-Tab editierbar statt
in den Einstellungen, da das Feld nur dort gebraucht wird. Export (6.3.3) bleibt offen,
da er an das noch fehlende Kapitel 11 (Export-Infrastruktur) hängt.
2026-08-15 23:26:06 +02:00
admin d8b05702bc Arbeitszeit: Zeiterfassung (Kapitel 6.2)
Neuer Tab "Zeiterfassung" neben "Aufgaben" (WorkloadViewModel als Tab-Container,
gleiches Muster wie GroupDetailViewModel): Timer mit Zuordnung zu Aufgabe/Kategorie,
manuelle Nacherfassung (Von-Bis oder Dauer), Wochenübersicht nach Kategorie, sowie
"X / Y min erfasst" direkt in der Aufgabenliste als Ist-vs-Soll-Vergleich.
2026-08-15 23:17:44 +02:00
admin 5c0b6854f5 Arbeitszeit: Aufgabenverwaltung (Kapitel 6.1)
Navigationspunkt "Arbeitszeit" zeigt jetzt eine echte Aufgabenliste statt eines
Placeholders: Filter nach Status/Kategorie/Gruppe (Standard blendet Erledigtes aus),
Sortierung nach Fälligkeit, Anlegen/Bearbeiten-Dialog, Status per Klick durchschalten.
2026-08-15 22:33:40 +02:00
admin 0800832ae5 Klausurtermine im Kalender (4.4.3) + Stundenplan: Ausnahme eintragen, Zahnrad-Settings-Link
- 4.4.3: Box "ANSTEHENDE KLAUSUREN" im Heute-Tab, gruppenübergreifend, nächste 21 Tage.
  IExamRepository um GetAll() erweitert.
- Nutzer-Feedback: "Vertretung eintragen" deckt auch Ausfall/Sondereinsatz ab, daher
  umbenannt in "Ausnahme eintragen". "Stundenplan bearbeiten" (redundant zum Bearbeiten-Tab)
  ersetzt durch Zahnrad-Button, der direkt in die Einstellungen springt.
2026-08-15 22:24:04 +02:00
adminandClaude Sonnet 5 d6dee72acc Unterrichtsplanung: Serienerzeugung, Stundenraster, Aufsichten & Vertretung (Kapitel 4.2/4.3 Nachtrag)
Stunden serienweise aus dem Stundenplan erzeugen (4.2.5); Stundenraster
(Uhrzeiten je Stunde) in den Einstellungen mit Zeitbedarf-Rückmeldung im
Verlaufsplan-Editor; wiederkehrende Pausenaufsicht; neuer "Vertretung
eintragen"-Dialog für einmalige Vertretungsaufsicht, Vertretungsstunde,
Sondereinsätze (Ausflüge, Berufsmessen) und schlichten Stundenausfall.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 21:59:07 +02:00
adminandClaude Sonnet 5 99b2de6519 Stundenplan: Wochenraster, Wochennavigation, Ferien/Feiertage (Kapitel 4.3)
Neuer Stundenplan mit "Heute"-Standardansicht (Tagesliste unten angedockt,
gruppenübergreifendes Wochenraster mit Fach/Klasse/Raum/Thema, Vor-/Zurück-
Navigation zwischen Kalenderwochen) und separatem Bearbeiten-Raster für die
wöchentliche Zuordnung. Badges für Ferien-/Klausur-Nähe und ausgegraute
Ferientage direkt im Plan statt einer separaten Liste. Ferien-/Feiertage-
Pflege (Bundesland, Schulferien) sitzt jetzt in den Einstellungen statt im
Stundenplan selbst.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 02:29:18 +02:00
adminandClaude Sonnet 5 ee1a47641e Stundenverlaufsplan: Viewer und Alternativpfad-Katalog (Kapitel 4.2 Nachtrag)
Schreibgeschützter LessonViewerDialog (größere Schrift, ohne Bearbeitungs-/Verlängern-/
Verschieben-Funktion) für den Einsatz während des Unterrichtens, erreichbar über "Anzeigen"
im Stunden-Toolbar.

Alternative Unterrichtsabläufe (z.B. Kurzversion bei Zeitmangel) laufen jetzt über einen
echten Katalog (neues Modell AlternativeLessonPath: Name + Beschreibung) statt Freitext direkt
an der Phase: im Verlaufsplan-Editor eine kompakte, farbig unterstützte Checkbox statt einer
durchgehend sichtbaren Eingabespalte, Zuordnung/Neuanlage über einen eigenen Dialog. Der Viewer
gruppiert Phasen entsprechend und zeigt die hinterlegte Beschreibung. Schema-Migration v3→v4
führt bestehende Freitextwerte verlustfrei in Katalogeinträge über.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 01:53:12 +02:00
adminandClaude Sonnet 5 de6ea001e7 Unterrichtsplanung: Einheiten, Verlaufsplan-Editor, Kürzel-Katalog (Kapitel 4.1/4.2)
Neuer Tab "Planung" in GroupDetailView ersetzt den Platzhalter: Einheiten anlegen/bearbeiten/
als Vorlage in andere Gruppe kopieren, Stunden je Einheit mit Verschieben (inkl. Nachrücken
der Folgestunden). Stundeneditor als tabellarischer Verlaufsplan (Phase/Dauer/Tätigkeit/
Material/Kurzsymbol je Zeile, Uhrzeit aus optionalem Stundenbeginn abgeleitet) statt eines
einzelnen Phase-Felds mit Methoden-/Materialien-Chips — Kurzsymbol als Freitext mit
Vorschlägen aus neuem Kürzel-Katalog (Einstellungen) plus bisher verwendeten Werten.
Schema-Migrationen v1-v3 überführen bestehende Daten verlustfrei. 4.2.5 bewusst offen
gelassen (hängt an Stundenplan, Kapitel 4.3).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 00:50:57 +02:00
admin 27359794f7 Review der Codebase 2026-08-13 19:57:48 +02:00
adminandClaude Sonnet 5 d987b93315 Schülerdokumentation: Einträge, Fehlzeitenbilanz, Datenschutz (Kapitel 5)
Dokumentationseinträge mit Typwahl (Gespräch, Vorkommnis, Förderplan,
Fehlzeit, Elternanruf, Elternbrief), vertrauliche Einträge nur nach
Bestätigung sichtbar, weiche Löschung mit Nachvollziehbarkeit. Fehlzeiten
als Auswertung des bestehenden Anwesenheits-Trackings statt zweiter
Erfassung, mit Schwellenwert-Warnung im Schülerdetail und Dashboard.
Förderplan-Wiedervorlage als Dashboard-Karte. Datenschutz: Löschfristen
mit manueller Bereinigung und DSGVO-Art.-15-Datenauskunft als Export.

Auf Nutzer-Feedback hin ergänzt: Elternanruf mit begleitendem
Gesprächsprotokoll-Dialog (Punkte abhaken, Eindrücke festhalten),
Elternbrief mit Versand-/Rückmeldungs-Tracking, Datei-Anhänge über
LiteDBs Dateispeicher, frei vergebbare Labels zur Nachverfolgung mit
Dringlichkeits-Farbcodierung, sowie eine sichtbare Farblegende für das
bestehende Notenentwicklungs-Diagramm. Dabei einen Absturz behoben:
leere Textfelder lieferten über das Binding null statt "", was beim
Speichern eine NullReferenceException auslöste.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 17:49:03 +02:00
adminandClaude Sonnet 5 629b8d1bf0 Codepflege: CLAUDE.md und UI-Konsolidierung (Kapitel 13.4)
CLAUDE.md mit Projektkonventionen für künftige Claude-Code-Sitzungen.
Wiederkehrende UI-Muster konsolidiert: neue PageHeader-Control für
Titel/Untertitel in den Listen- und Detailansichten, globale Styles für
Dialog-Titel und Leerlisten-Hinweise statt inline wiederholter Werte.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 16:14:40 +02:00
adminandClaude Sonnet 5 48d07a2b99 Datensicherheit: Backup, Verschlüsselung, App-Sperre (Kapitel 13.3)
Automatisches rollierendes Backup der Datenbank beim Start mit
Wiederherstellung über die Einstellungen, versionierte Schema-Migration,
optionale Passwort-Verschlüsselung der LiteDB-Datei und eine App-Sperre
nach Inaktivität mit eigenem Passwort.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 13:07:36 +02:00
adminandClaude Sonnet 5 db1afff7e4 Fehlerbehandlung, Logging und feldbezogene Validierung (Kapitel 13.2)
Zentrale Exception-Behandlung (Dispatcher.UIThread.UnhandledException,
AppDomain, TaskScheduler) verhindert Abstürze und protokolliert Fehler
über AppLogger in eine rotierende Log-Datei im App-Datenverzeichnis.
Toast-Benachrichtigungen zeigen Erfolg/Fehler global an. Alle Dialoge
mit Formularfeldern zeigen Validierungsmeldungen jetzt direkt am
betroffenen Feld statt in einem Sammel-Label.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 12:38:30 +02:00
admin 273f459119 Testabdeckung Kapitel 13.1 (Tests, Fehlerbehandlung)
Vier neue xUnit-Testprojekte (LehrerApp.Tests, .Data.Tests, .Desktop.Tests,
.Sync.Tests) mit zusammen 104 Tests: GradingService, SchoolYearService,
Repositories gegen In-Memory-LiteDB, Mitarbeits-Aggregation (3.2),
Zeugnisnotenberechnung (2.4) und ConflictResolver.

Dabei zwei echte Fehler in der Sync-Schicht gefunden und behoben:
- SyncEvent.EventId fehlte [BsonId], wodurch EventQueue.Acknowledge()
  nie etwas aus der Queue löschte.
- ConflictResolver verglich Zeitstempel unterschiedlicher DateTimeKind
  direkt (LiteDB liefert Local statt Utc zurück), was die Gleichstand-
  Regel außerhalb von UTC+0 verfälschte.

SchoolYearService.CurrentSchoolYear()/RecentSchoolYears() um ein optionales
today-Argument erweitert, um den Schuljahreswechsel deterministisch zu
testen; LiteDbContext um einen Stream-Konstruktor für In-Memory-Tests.
2026-08-13 11:05:06 +02:00
admin a8b8a40b52 Testprojekt LehrerApp.Tests anlegen (13.1.1)
Neues xUnit-Projekt für die Core-Schicht, analog zu LehrerApp.Data.Tests.
Noch ohne Testfälle.
2026-08-13 10:34:41 +02:00
642 changed files with 88191 additions and 1695 deletions
+3
View File
@@ -4,3 +4,6 @@
# Mindestens 32 zufällige Zeichen z.B. generiert mit: # Mindestens 32 zufällige Zeichen z.B. generiert mit:
# openssl rand -base64 32 # openssl rand -base64 32
JWT_SECRET=hier-einen-langen-zufaelligen-wert-eintragen JWT_SECRET=hier-einen-langen-zufaelligen-wert-eintragen
# Identifiziert die Installation gegenüber Nominatim/DWD. Bei eigener Domain bitte anpassen.
GEOCODING_USER_AGENT=LehrerApp-Server/1.0 (+https://example.org)
+24
View File
@@ -0,0 +1,24 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build-and-test:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/dotnet/sdk:10.0
steps:
- uses: actions/checkout@v4
- name: Restore
run: dotnet restore LehrerApp.sln
- name: Build
run: dotnet build LehrerApp.sln --no-restore --configuration Release
- name: Test
run: dotnet test LehrerApp.sln --no-build --configuration Release --logger "console;verbosity=normal"
+5
View File
@@ -2,6 +2,7 @@
bin/ bin/
obj/ obj/
out/ out/
build/
# ── .NET ────────────────────────────────────────────────────────────────────── # ── .NET ──────────────────────────────────────────────────────────────────────
*.user *.user
@@ -42,6 +43,10 @@ server.txt
# ── API / Docker ────────────────────────────────────────────────────────────── # ── API / Docker ──────────────────────────────────────────────────────────────
# Lokale Datenhaltung des Servers # Lokale Datenhaltung des Servers
LehrerApp.Api/data/ LehrerApp.Api/data/
# ── KI-Backend (ai-backend/, TODO 4.5.9) ───────────────────────────────────────
# Echte Zugangsdaten/API-Keys, nie committen — config.example.php bleibt getrackt.
ai-backend/config.php
docker/data/ docker/data/
# Umgebungsvariablen .env.example ins Repo, .env nicht # Umgebungsvariablen .env.example ins Repo, .env nicht
+21 -6
View File
@@ -5,7 +5,7 @@
"name": "Desktop App starten", "name": "Desktop App starten",
"type": "coreclr", "type": "coreclr",
"request": "launch", "request": "launch",
"preLaunchTask": "Desktop bauen", "preLaunchTask": "TemplateDesigner bauen",
"program": "${workspaceFolder}/LehrerApp.Desktop/bin/Debug/net10.0/LehrerApp.Desktop.dll", "program": "${workspaceFolder}/LehrerApp.Desktop/bin/Debug/net10.0/LehrerApp.Desktop.dll",
"args": [], "args": [],
"cwd": "${workspaceFolder}/LehrerApp.Desktop", "cwd": "${workspaceFolder}/LehrerApp.Desktop",
@@ -15,6 +15,20 @@
"DOTNET_ENVIRONMENT": "Development" "DOTNET_ENVIRONMENT": "Development"
} }
}, },
{
"name": "TemplateDesigner App starten",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "Desktop bauen",
"program": "${workspaceFolder}/LehrerApp.TemplateDesigner/bin/Debug/net10.0/LehrerApp.TemplateDesigner.dll",
"args": [],
"cwd": "${workspaceFolder}/LehrerApp.TemplateDesigner",
"stopAtEntry": false,
"console": "internalConsole",
"env": {
"DOTNET_ENVIRONMENT": "Development"
}
},
{ {
"name": "API Server starten", "name": "API Server starten",
"type": "coreclr", "type": "coreclr",
@@ -33,11 +47,12 @@
} }
}, },
{ {
"name": "Desktop + API (gleichzeitig)", "name": "API Tests starten",
"configurations": [ "type": "coreclr",
"Desktop App starten", "request": "launch",
"API Server starten" "preLaunchTask": "API bauen",
] "program": "${workspaceFolder}/LehrerApp.Api.Tests/bin/Debug/net10.0/LehrerApp.Api.Tests.dll",
"args": [],
} }
], ],
"compounds": [ "compounds": [
+13
View File
@@ -27,6 +27,19 @@
"problemMatcher": "$msCompile", "problemMatcher": "$msCompile",
"group": "build" "group": "build"
}, },
{
"label": "TemplateDesigner bauen",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/LehrerApp.TemplateDesigner/LehrerApp.TemplateDesigner.csproj",
"--configuration", "Debug",
"--verbosity", "minimal"
],
"problemMatcher": "$msCompile",
"group": "build"
},
{ {
"label": "Solution bauen", "label": "Solution bauen",
"command": "dotnet", "command": "dotnet",
+130
View File
@@ -0,0 +1,130 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
LehrerApp is a German-language desktop application for teachers (Notenverwaltung, Anwesenheit,
Hausaufgaben, Klausuren, Unterrichtsplanung, Mitarbeitsbewertung). The primary product is the
Avalonia desktop client backed by a local LiteDB file; an optional ASP.NET Core API enables
multi-device sync via an encrypted event queue.
The living roadmap and feature spec is `TODO.md` — check it before starting new work and update
it (with an implementation note, following the existing style) when a checklist item is finished.
`docs/Datenmodell.md` documents the meaning of domain fields where naming alone is ambiguous
(e.g. why `GroupMembership` has no `SchoolYear` of its own).
## Commands
```bash
# Build everything
dotnet build LehrerApp.sln
# Run all tests
dotnet test LehrerApp.sln
# Run one test project
dotnet test LehrerApp.Tests/LehrerApp.Tests.csproj
# Run a single test (xUnit fully-qualified name filter)
dotnet test --filter "FullyQualifiedName~GradingServiceTests.RoundToGrade_Kaufmaennisch"
# Run the desktop app
dotnet run --project LehrerApp.Desktop
# Run the sync API locally (needs JWT_SECRET — copy .env.example to .env first)
dotnet run --project LehrerApp.Api
```
The API can also run via `docker/docker-compose.yml` (reads `JWT_SECRET` from the environment).
## Architecture
### Project layout
- **LehrerApp.Core** — domain models (`Models/`), repository interfaces (`Interfaces/IRepositories.cs`),
and framework-free business services (`Services/`): `GradingService` (rounding, grading-key
validation, report-grade calculation), `SchoolYearService`, `AppLogger`, `BackupService`,
`AppLockService`. No dependency on LiteDB or Avalonia — this is what makes these services
testable without a UI or a real database.
- **LehrerApp.Data** — LiteDB implementation. `LiteDbContext` owns the single `LiteDatabase`
connection and all collection accessors; `Repositories/AllRepositories.cs` implements every
`I*Repository` interface from Core. `DatabaseEncryptionService` handles password-protecting the
DB file.
- **LehrerApp.Desktop** — the Avalonia MVVM client. `AppBootstrapper.BuildServices()` is the
single DI composition root (repositories and `LiteDbContext` are singletons — one LiteDB file
per user/process). `ViewModels/` and `Views/` are split into `Groups/`, `Students/`, `Settings/`
(mirrors the domain, not a strict 1:1 with models).
- **LehrerApp.Sync** — sync client library used by Desktop: `EventQueue` (local outbox),
`ConflictResolver` (last-write-wins with timestamp tie-breaking), `SyncEngine` (push/pull
orchestration, timer-driven), `SnapshotService`, `Crypto/SyncCrypto` (AES-256-GCM payload
encryption — desktop events are encrypted at rest and in transit; Companion/WebApp events are
plaintext, see `PlainSyncEvent` vs `SyncEvent`).
- **LehrerApp.WebUntis** — direkter, serverunabhängiger WebUntis-Client für den Desktop. Hält die
persönliche JSON-RPC-Sitzung lokal und parst den Schülerreport lokal; WebUntis-Zugangsdaten und
personenbezogene Antworten dürfen nicht über `LehrerApp.Api` geleitet werden.
- **LehrerApp.Api** — minimal ASP.NET Core server: JWT auth, an append-only `EventStore` plus
`SnapshotStore`/`ReadableSnapshotStore` per device, mapped in `Endpoints/Endpoints.cs`. Sync is
optional — Desktop only registers `SyncEngine`/`SnapshotService` in DI when a server URL is
configured (`AppBootstrapper.LoadServerUrl`).
- Each library has a matching `*.Tests` project (`LehrerApp.Tests` → Core, `LehrerApp.Data.Tests`
Data, `LehrerApp.Desktop.Tests` → Desktop, `LehrerApp.Sync.Tests` → Sync), all xUnit.
`LehrerApp.WebUntis.Tests` covers the direct WebUntis client and report parser.
### MVVM conventions (Desktop)
- ViewModels use CommunityToolkit.Mvvm source generators: `[ObservableProperty] private T _foo;`
and `[RelayCommand]`. A generated `partial void On{Foo}Changed(...)` is emitted for every
`[ObservableProperty]` — do not name a property/callback so it collides with that generated
name (e.g. a field named `OnFooChanged` next to `[ObservableProperty] Foo` fails with `CS0102`).
- Field-level validation: each invalid input gets its own `{Field}Error` string property
(not one collective `ValidationMessage`), rendered directly under that field in XAML via
`IsVisible="{Binding {Field}Error, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"`.
`Save()` clears all `*Error` properties up front, accumulates failures into a local `valid`
flag instead of returning on the first one, then does `if (!valid) return;` — so multiple
errors can show at once. A message that only makes sense as one combined statement about
several fields (e.g. a weighting scheme's three percentages summing to 100) legitimately stays
a single message near those fields; that's a judgement call, not a bug.
- Enum-backed UI: don't bind a `ComboBox` directly to a raw enum (Avalonia falls back to
`ToString()`, i.e. English member names). Expose a `string[] Options` of German labels plus a
`{Enum}Name` wrapper string property that the ComboBox binds to, converting to/from the actual
enum property — see `NiveauDisplay`/`GradeCategoryDisplay` for the established pattern.
- Dialogs are `Window` subclasses shown via `await dialog.ShowDialog<bool>(owner)`, with
`Close(true)`/`Close(false)` on confirm/cancel and the result read from a `Result` property on
the dialog's ViewModel.
### Avalonia XAML gotchas
- Local attribute values always win over `Style` selectors for the same property. Conditional
`Classes`-based styling only works if the base value also comes from a `Style` selector, not a
local attribute on the element.
- `[SomeAttached.Property] = value` is not valid inside a C# object initializer for an attached
property — use `ToolTip.SetTip(control, value)` as a separate statement.
- `x:DataType` on a `Window`/`UserControl` enables compiled bindings, which validate binding paths
at build time — a clean build is meaningful evidence that new bindings are wired correctly, not
just that the XML parses.
### LiteDB details
- Only a property literally named `Id` is auto-recognized as the primary key. A model with a
differently-named identity property (e.g. `EventId`) needs an explicit `[BsonId]`, or LiteDB
silently assigns an unrelated `_id` and lookups/deletes by that property quietly do nothing.
- `DateTime` round-trips through LiteDB BSON with `Kind` converted `Utc → Local` and the ticks
shifted to preserve the same instant. Since `DateTime` comparison operators compare raw ticks
and ignore `Kind`, comparing a round-tripped value against a freshly-created one is unsafe
outside UTC+0 — always `.ToUniversalTime()` both sides first (see `ConflictResolver`).
LiteDB's `LiteDatabase.Rebuild(new RebuildOptions { Password = ... })` for in-place re-encryption
is broken in the pinned LiteDB version (5.0.21) — it throws even against an unencrypted source
file. `DatabaseEncryptionService` instead copies every collection into a fresh database opened
with the target password.
- `LiteDbContext` tracks a schema version in a `meta` collection (`RunVersionedMigrations`) so
migration steps run once, not on every startup. Add new migrations as another
`if (version < N) { ...; version = N; }` block rather than making old steps re-check their own
idempotency forever.
- `new LiteDbContext(stream)` opens an in-memory database from a `MemoryStream` — used pervasively
in tests for a fast, disk-free `LiteDbContext`.
### Central Package Management
`Directory.Packages.props` pins all NuGet versions; individual `.csproj` files must reference
packages without a `Version` attribute (`<PackageReference Include="Foo" />`).
+1 -1
View File
@@ -4,7 +4,7 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- CS8618: falsch-positiv durch [ObservableProperty] Source Generator --> <!-- CS8618: falsch-positiv durch [ObservableProperty] Source Generator -->
<NoWarn>CS8618</NoWarn> <NoWarn>CS8618</NoWarn>
</PropertyGroup> </PropertyGroup>
+8
View File
@@ -17,9 +17,17 @@
<!-- Datenbank --> <!-- Datenbank -->
<PackageVersion Include="LiteDB" Version="5.0.21" /> <PackageVersion Include="LiteDB" Version="5.0.21" />
<!-- PDF-Erzeugung: Desktop-Exporte und die unabhängige Templating-Library. -->
<PackageVersion Include="QuestPDF" Version="2025.7.0" />
<PackageVersion Include="PDFtoImage" Version="5.4.0" />
<PackageVersion Include="PdfPig" Version="0.1.13" />
<!-- API --> <!-- API -->
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" /> <PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
<!-- MCP-Server (lokal, siehe TODO.md) -->
<PackageVersion Include="ModelContextProtocol.Core" Version="2.2.0" />
<!-- Tests --> <!-- Tests -->
<PackageVersion Include="xunit" Version="2.9.3" /> <PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" /> <PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
+10
View File
@@ -0,0 +1,10 @@
using Xunit;
// LiteDB nutzt einen statischen, geteilten BsonMapper.Global für die Reflection-basierte
// Index-Auflösung (EnsureIndex mit Lambda-Ausdrücken). Bei paralleler Testausführung über
// mehrere Testklassen hinweg (xUnit-Standard) konkurrieren mehrere Threads beim erstmaligen
// Aufbau der Typ-Metadaten für verschiedene Modelle — das führt zu sporadischen
// "Member X not found on BsonMapper"-Fehlern, die mit dem eigentlichen Testinhalt nichts zu
// tun haben (gleiches Muster wie in LehrerApp.Data.Tests/LehrerApp.Desktop.Tests). Tests in
// diesem Projekt laufen deshalb sequenziell.
[assembly: CollectionBehavior(DisableTestParallelization = true)]
@@ -0,0 +1,71 @@
using Xunit;
namespace LehrerApp.Api.Tests;
public sealed class AttachmentStoreTests
{
[Fact]
public async Task StoreAsync_GefolgtVonOpenRead_LiefertByteidentischeDatei()
{
using var temp = new TempDataPath();
var store = new AttachmentStore(temp.Path);
byte[] original = [1, 2, 3, 4, 5, 255, 0, 42];
await store.StoreAsync("user-1", "abc123", new MemoryStream(original));
using var read = store.OpenRead("user-1", "abc123");
Assert.NotNull(read);
using var ms = new MemoryStream();
await read!.CopyToAsync(ms);
Assert.Equal(original, ms.ToArray());
}
[Fact]
public void OpenRead_UnbekannteStorageId_GibtNullZurueck()
{
using var temp = new TempDataPath();
var store = new AttachmentStore(temp.Path);
Assert.Null(store.OpenRead("user-1", "unbekannt"));
}
[Fact]
public async Task StoreAsync_TrenntAnhaengeVerschiedenerNutzer()
{
using var temp = new TempDataPath();
var store = new AttachmentStore(temp.Path);
await store.StoreAsync("user-1", "shared-id", new MemoryStream([1]));
Assert.Null(store.OpenRead("user-2", "shared-id"));
using var user1Attachment = store.OpenRead("user-1", "shared-id");
Assert.NotNull(user1Attachment);
}
[Fact]
public async Task StoreAsync_BereinigtStorageIdMitPathTraversalZeichen()
{
using var temp = new TempDataPath();
var store = new AttachmentStore(temp.Path);
// Darf keinesfalls außerhalb von <root>/attachments/<user> landen.
await store.StoreAsync("user-1", "../../evil", new MemoryStream([1, 2, 3]));
Assert.False(File.Exists(Path.Combine(temp.Path, "evil")));
var withinRoot = Directory.EnumerateFiles(Path.Combine(temp.Path, "attachments"), "*", SearchOption.AllDirectories);
Assert.Contains(withinRoot, f => Path.GetFileName(f) == "evil");
}
private sealed class TempDataPath : IDisposable
{
public string Path { get; } = System.IO.Path.Combine(
System.IO.Path.GetTempPath(), $"lehrerapp-api-tests-attachments-{Guid.NewGuid():N}");
public TempDataPath() => Directory.CreateDirectory(Path);
public void Dispose()
{
if (Directory.Exists(Path)) Directory.Delete(Path, recursive: true);
}
}
}
@@ -0,0 +1,118 @@
using System.IO.Compression;
using System.Text;
using Xunit;
namespace LehrerApp.Api.Tests;
public sealed class DwdWeatherServiceTests
{
[Fact]
public void ParseStationCatalog_LiestFestbreitenformatUndFindetNaechsteStation()
{
const string catalog = """
ID ICAO NAME LAT LON ELEV
----- ---- -------------------- ----- ------- -----
10641 EDDF OFFENBACH-WETTERPARK 50.08 8.78 119
10382 EDDT BERLIN-TEGEL 52.56 13.31 37
""";
var stations = DwdWeatherService.ParseStationCatalog(catalog);
var nearest = DwdWeatherService.FindNearestStation(stations, 50.1, 8.7);
Assert.Equal(2, stations.Count);
Assert.Equal("10641", nearest!.Id);
Assert.Equal("OFFENBACH-WETTERPARK", nearest.Name);
}
[Fact]
public void ParseMosmix_KonvertiertEinheitenUndBehaeltFehlwerteAlsNull()
{
var now = DateTime.UtcNow.AddHours(1).ToString("yyyy-MM-dd'T'HH:00:00.000'Z'");
var kml = $$"""
<?xml version="1.0" encoding="UTF-8"?>
<kml:kml xmlns:kml="http://www.opengis.net/kml/2.2" xmlns:dwd="https://opendata.dwd.de/weather/lib/pointforecast_dwd_extension_V1_0.xsd">
<kml:Document><kml:ExtendedData><dwd:ProductDefinition>
<dwd:IssueTime>2026-08-23T15:00:00Z</dwd:IssueTime>
<dwd:ForecastTimeSteps><dwd:TimeStep>{{now}}</dwd:TimeStep></dwd:ForecastTimeSteps>
</dwd:ProductDefinition></kml:ExtendedData><kml:Placemark><kml:ExtendedData>
<dwd:Forecast dwd:elementName="TTT"><dwd:value>293.15</dwd:value></dwd:Forecast>
<dwd:Forecast dwd:elementName="FF"><dwd:value>5</dwd:value></dwd:Forecast>
<dwd:Forecast dwd:elementName="FX1"><dwd:value>-</dwd:value></dwd:Forecast>
<dwd:Forecast dwd:elementName="RR1c"><dwd:value>1.25</dwd:value></dwd:Forecast>
<dwd:Forecast dwd:elementName="R101"><dwd:value>67.6</dwd:value></dwd:Forecast>
<dwd:Forecast dwd:elementName="SunD1"><dwd:value>1800</dwd:value></dwd:Forecast>
<dwd:Forecast dwd:elementName="Neff"><dwd:value>75</dwd:value></dwd:Forecast>
<dwd:Forecast dwd:elementName="ww"><dwd:value>61</dwd:value></dwd:Forecast>
</kml:ExtendedData></kml:Placemark></kml:Document>
</kml:kml>
""";
var parsed = DwdWeatherService.ParseMosmix(Zip("forecast.kml", kml));
var hour = Assert.Single(parsed.Hours);
Assert.Equal(20, hour.TemperatureC);
Assert.Equal(18, hour.WindSpeedKmh);
Assert.Null(hour.WindGustKmh);
Assert.Equal(1.3, hour.PrecipitationMm);
Assert.Equal(68, hour.PrecipitationProbabilityPercent);
Assert.Equal(30, hour.SunshineDurationMinutes);
Assert.Equal(61, hour.WeatherCode);
}
[Fact]
public void ParseMosmix_LiefertMehrAlsDreiTageFuerFolgewoche()
{
var start = DateTime.UtcNow.AddHours(1);
var timeSteps = string.Join("", Enumerable.Range(0, 100)
.Select(i => $"<dwd:TimeStep>{start.AddHours(i):yyyy-MM-dd'T'HH:mm:ss'Z'}</dwd:TimeStep>"));
var values = string.Join(' ', Enumerable.Repeat("293.15", 100));
var kml = $$"""
<?xml version="1.0" encoding="UTF-8"?>
<kml:kml xmlns:kml="http://www.opengis.net/kml/2.2" xmlns:dwd="https://opendata.dwd.de/weather/lib/pointforecast_dwd_extension_V1_0.xsd">
<kml:Document><kml:ExtendedData><dwd:ProductDefinition>
<dwd:ForecastTimeSteps>{{timeSteps}}</dwd:ForecastTimeSteps>
</dwd:ProductDefinition></kml:ExtendedData><kml:Placemark><kml:ExtendedData>
<dwd:Forecast dwd:elementName="TTT"><dwd:value>{{values}}</dwd:value></dwd:Forecast>
</kml:ExtendedData></kml:Placemark></kml:Document>
</kml:kml>
""";
var parsed = DwdWeatherService.ParseMosmix(Zip("forecast.kml", kml));
Assert.Equal(100, parsed.Hours.Count);
}
[Fact]
public void ParseCapArchive_OrdnetWarnpolygonGeografischZu()
{
var xml = """
<alert xmlns="urn:oasis:names:tc:emergency:cap:1.2">
<identifier>warnung-1</identifier><status>Actual</status>
<info><language>de-DE</language><event>GEWITTER</event><severity>Severe</severity>
<onset>2026-08-23T18:00:00Z</onset><expires>2099-08-23T20:00:00Z</expires>
<headline>Amtliche Warnung</headline><description>Test</description>
<area><polygon>50.0,8.0 50.0,9.0 51.0,9.0 51.0,8.0 50.0,8.0</polygon></area>
</info>
</alert>
""";
var warning = Assert.Single(DwdWeatherService.ParseCapArchive(
Zip("warning.xml", xml), DateTime.UtcNow));
Assert.True(DwdWeatherService.PointInPolygon(warning.Polygons[0], 50.5, 8.5));
Assert.False(DwdWeatherService.PointInPolygon(warning.Polygons[0], 52.0, 8.5));
Assert.Equal("GEWITTER", warning.Warning.Event);
}
private static byte[] Zip(string fileName, string content)
{
using var memory = new MemoryStream();
using (var archive = new ZipArchive(memory, ZipArchiveMode.Create, leaveOpen: true))
{
var entry = archive.CreateEntry(fileName);
using var writer = new StreamWriter(entry.Open(), new UTF8Encoding(false));
writer.Write(content);
}
return memory.ToArray();
}
}
+184
View File
@@ -0,0 +1,184 @@
using LehrerApp.Sync.Models;
using Xunit;
namespace LehrerApp.Api.Tests;
public sealed class EventStoreTests
{
private static SyncEvent MakeEvent(string deviceId, string entityType = "Lesson", string operation = "Save",
string? entityId = null, long? basedOnServerSeq = null) => new()
{
DeviceId = deviceId, DeviceType = DeviceType.Desktop,
EntityType = entityType, EntityId = entityId ?? Guid.NewGuid().ToString(),
Operation = operation, Payload = "encrypted", BasedOnServerSeq = basedOnServerSeq,
};
[Fact]
public void Pull_KeineNeuenEreignisse_LiefertUnverändertesSinceAlsWatermark()
{
using var temp = new TempEventStore();
temp.Events.Push("user", [MakeEvent("device-a")]);
var result = temp.Events.Pull("user", since: 5, requestingDeviceId: "device-b");
Assert.Empty(result.Events);
Assert.Equal(5, result.ServerSequenceNr);
}
[Fact]
public void Pull_LiefertEreignisseAnderesGeraets_WatermarkEntsprichtHoechsterZurueckgegebenerServerSeq()
{
using var temp = new TempEventStore();
temp.Events.Push("user", [MakeEvent("device-a"), MakeEvent("device-a")]);
var result = temp.Events.Pull("user", since: 0, requestingDeviceId: "device-b");
Assert.Equal(2, result.Events.Count);
Assert.Equal(2, result.ServerSequenceNr);
}
/// Regression: das genau beobachtete Symptom — Gerät A pusht erfolgreich (Server bestätigt),
/// Gerät B pullt und bekommt 0 Ereignisse zurück, obwohl Gerät A's Ereignisse eigentlich für
/// Gerät B bestimmt waren. Ursache: Pull() gab bisher IMMER den globalen Höchststand
/// (LastSeq, inkl. der Ereignisse des ANFRAGENDEN Geräts selbst) als neuen "since"-Cursor
/// zurück — SyncEngine.PullAsync übernimmt den 1:1. Hat das anfragende Gerät SELBST kurz vorher
/// etwas gepusht (dessen Ereignisse hier bewusst per "DeviceId != requestingDeviceId"
/// herausgefiltert werden), sprang der Cursor über die noch gar nicht abgeholten Ereignisse
/// eines ANDEREN Geräts hinweg, wenn die eigenen neuer waren — sie wurden dauerhaft verpasst.
[Fact]
public void Pull_AnfragendesGeraetHatSelbstNeuereEreignisseGepusht_UeberspringtFremdeEreignisseNicht()
{
using var temp = new TempEventStore();
// Gerät A pusht zuerst (ServerSeq 1) - das ist die Änderung, die Gerät B eigentlich abholen soll.
temp.Events.Push("user", [MakeEvent("device-a")]);
// Gerät B hat selbst (z.B. im selben Sync-Zyklus, Push läuft immer vor Pull) etwas Neueres
// gepusht (ServerSeq 2) - dieses Ereignis gehört B selbst und wird unten aus der Pull-Antwort
// herausgefiltert.
temp.Events.Push("user", [MakeEvent("device-b")]);
// Gerät B pullt mit einem "since" von VOR seinem eigenen Push (0) - Geräts A's Ereignis
// (ServerSeq 1) muss zurückkommen.
var result = temp.Events.Pull("user", since: 0, requestingDeviceId: "device-b");
var evt = Assert.Single(result.Events);
Assert.Equal("device-a", evt.DeviceId);
// Der Bug: hier stand vorher 2 (globaler Höchststand, inkl. Geräts B eigenem Ereignis) -
// ein zweiter Pull mit since=2 hätte Geräts A's Ereignis (ServerSeq 1) nie wieder gefunden.
Assert.Equal(1, result.ServerSequenceNr);
}
// ── Exakte Kollisionsprüfung (BasedOnServerSeq statt 30s-Heuristik) ─────────
[Fact]
public void Push_NeueEntitaetOhneBasedOnServerSeq_WirdAngenommen()
{
using var temp = new TempEventStore();
var result = temp.Events.Push("user", [MakeEvent("device-a", basedOnServerSeq: null)]);
Assert.Empty(result.ConflictingEventIds);
}
[Fact]
public void Push_ZweitesEreignisMitFalschemBasedOnServerSeq_WirdAbgelehnt()
{
using var temp = new TempEventStore();
var entityId = Guid.NewGuid().ToString();
temp.Events.Push("user", [MakeEvent("device-a", entityId: entityId, basedOnServerSeq: null)]); // -> ServerSeq 1
// Gerät B kennt die Entität noch gar nicht (oder einen veralteten Stand) und behauptet fälschlich, sie sei neu.
var second = MakeEvent("device-b", entityId: entityId, basedOnServerSeq: null);
var result = temp.Events.Push("user", [second]);
Assert.Contains(second.EventId, result.ConflictingEventIds);
}
[Fact]
public void Push_ZweitesEreignisMitKorrektemBasedOnServerSeq_WirdAngenommen()
{
using var temp = new TempEventStore();
var entityId = Guid.NewGuid().ToString();
var first = temp.Events.Push("user", [MakeEvent("device-a", entityId: entityId, basedOnServerSeq: null)]);
var assignedSeq = first.AssignedServerSeqs.Values.Single(); // ServerSeq 1
var second = MakeEvent("device-b", entityId: entityId, basedOnServerSeq: assignedSeq);
var result = temp.Events.Push("user", [second]);
Assert.Empty(result.ConflictingEventIds);
}
[Fact]
public void Push_AkzeptierteEreignisse_LiefernJeEventIdDieVergebeneServerSeq()
{
using var temp = new TempEventStore();
var evt1 = MakeEvent("device-a");
var evt2 = MakeEvent("device-a");
var result = temp.Events.Push("user", [evt1, evt2]);
Assert.Equal(2, result.AssignedServerSeqs.Count);
Assert.True(result.AssignedServerSeqs.ContainsKey(evt1.EventId));
Assert.True(result.AssignedServerSeqs.ContainsKey(evt2.EventId));
Assert.Equal([1L, 2L], result.AssignedServerSeqs.Values.OrderBy(v => v));
}
[Fact]
public void Push_AbgelehntesEreignis_TauchtNichtInAssignedServerSeqsAuf()
{
using var temp = new TempEventStore();
var entityId = Guid.NewGuid().ToString();
temp.Events.Push("user", [MakeEvent("device-a", entityId: entityId, basedOnServerSeq: null)]);
var rejected = MakeEvent("device-b", entityId: entityId, basedOnServerSeq: null);
var result = temp.Events.Push("user", [rejected]);
Assert.False(result.AssignedServerSeqs.ContainsKey(rejected.EventId));
}
// ── GetLatestForEntity (Sofort-Nachladen nach abgelehntem Push) ─────────────
[Fact]
public void GetLatestForEntity_UnbekannteEntitaet_LiefertNull()
{
using var temp = new TempEventStore();
var result = temp.Events.GetLatestForEntity("user", "Lesson", Guid.NewGuid().ToString());
Assert.Null(result);
}
[Fact]
public void GetLatestForEntity_LiefertDenAktuellstenStandDieserEntitaet()
{
using var temp = new TempEventStore();
var entityId = Guid.NewGuid().ToString();
var first = temp.Events.Push("user", [MakeEvent("device-a", entityId: entityId, basedOnServerSeq: null)]);
var seq1 = first.AssignedServerSeqs.Values.Single();
temp.Events.Push("user", [MakeEvent("device-b", entityId: entityId, basedOnServerSeq: seq1)]);
var result = temp.Events.GetLatestForEntity("user", "Lesson", entityId);
Assert.NotNull(result);
Assert.Equal("device-b", result!.DeviceId);
Assert.Equal(seq1 + 1, result.SequenceNr);
}
private sealed class TempEventStore : IDisposable
{
private readonly string _directory = Path.Combine(
Path.GetTempPath(), $"lehrerapp-api-tests-eventstore-{Guid.NewGuid():N}");
public EventStore Events { get; }
public TempEventStore()
{
Directory.CreateDirectory(_directory);
Events = new EventStore(_directory);
}
public void Dispose()
{
Events.Dispose();
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
}
}
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LehrerApp.Api\LehrerApp.Api.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
@@ -0,0 +1,46 @@
using System.Net;
using System.Text;
using LehrerApp.Core.Models;
using Xunit;
namespace LehrerApp.Api.Tests;
public sealed class NominatimGeocoderTests
{
[Fact]
public async Task GeocodeAsync_NutztStrukturierteDeutschlandSucheUndLiestDisplayName()
{
Uri? requestedUri = null;
var handler = new RecordingHandler(request =>
{
requestedUri = request.RequestUri;
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(
"""[{"lat":"50.105","lon":"8.761","display_name":"Testschule, Offenbach, Deutschland"}]""",
Encoding.UTF8, "application/json"),
};
});
var http = new HttpClient(handler) { BaseAddress = new Uri("https://nominatim.openstreetmap.org/") };
var geocoder = new NominatimGeocoder(http);
var result = await geocoder.GeocodeAsync(new SchoolLocationRequest
{
SchoolName = "Testschule", Street = "Schulweg 1", PostalCode = "63000", City = "Offenbach",
}, CancellationToken.None);
Assert.NotNull(result);
Assert.Equal(50.105, result!.Latitude);
Assert.Equal("Testschule, Offenbach, Deutschland", result.DisplayName);
Assert.Contains("countrycodes=de", requestedUri!.Query);
Assert.Contains("street=Schulweg%201", requestedUri.Query);
Assert.Contains("postalcode=63000", requestedUri.Query);
}
private sealed class RecordingHandler(Func<HttpRequestMessage, HttpResponseMessage> response)
: HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken) => Task.FromResult(response(request));
}
}
@@ -0,0 +1,41 @@
using Xunit;
namespace LehrerApp.Api.Tests;
public sealed class PasswordHasherTests
{
[Fact]
public void Verify_RichtigesPasswort_GibtTrueZurueck()
{
var hash = PasswordHasher.Hash("korrektes-passwort-123");
Assert.True(PasswordHasher.Verify("korrektes-passwort-123", hash));
}
[Fact]
public void Verify_FalschesPasswort_GibtFalseZurueck()
{
var hash = PasswordHasher.Hash("korrektes-passwort-123");
Assert.False(PasswordHasher.Verify("falsches-passwort-456", hash));
}
[Fact]
public void Hash_ZweiAufrufeMitGleichemPasswort_ErzeugenUnterschiedlicheHashes()
{
// Zufälliger Salt pro Nutzer -> selbst identische Passwörter dürfen nicht denselben
// gespeicherten Wert ergeben.
var hash1 = PasswordHasher.Hash("dasselbe-passwort");
var hash2 = PasswordHasher.Hash("dasselbe-passwort");
Assert.NotEqual(hash1, hash2);
Assert.True(PasswordHasher.Verify("dasselbe-passwort", hash1));
Assert.True(PasswordHasher.Verify("dasselbe-passwort", hash2));
}
[Fact]
public void Verify_UngueltigesGespeichertesFormat_GibtFalseZurueckStattZuWerfen()
{
Assert.False(PasswordHasher.Verify("irgendwas", "kein-gueltiges-format"));
}
}
@@ -0,0 +1,54 @@
using LehrerApp.Sync.Models;
using Xunit;
namespace LehrerApp.Api.Tests;
public sealed class PlainEventStoreTests
{
[Theory]
[InlineData("Grade")]
[InlineData("ExamResult")]
public void Push_PersonenbezogenerEntityTyp_WirdAbgelehnt(string entityType)
{
using var temp = new TempEventStore();
var evt = new PlainSyncEvent { EntityType = entityType, EntityId = "1", Operation = "Save" };
var result = temp.Plain.Push("user", [evt]);
Assert.Contains(evt.EventId, result.RejectedEventIds);
}
[Theory]
[InlineData("WorkTask")]
[InlineData("Lesson")]
public void Push_UnkritischerEntityTyp_WirdAngenommen(string entityType)
{
using var temp = new TempEventStore();
var evt = new PlainSyncEvent { EntityType = entityType, EntityId = "1", Operation = "Save" };
var result = temp.Plain.Push("user", [evt]);
Assert.DoesNotContain(evt.EventId, result.RejectedEventIds);
}
private sealed class TempEventStore : IDisposable
{
private readonly string _directory = Path.Combine(
Path.GetTempPath(), $"lehrerapp-api-tests-{Guid.NewGuid():N}");
private readonly EventStore _events;
public PlainEventStore Plain { get; }
public TempEventStore()
{
Directory.CreateDirectory(_directory);
_events = new EventStore(_directory);
Plain = new PlainEventStore(_events);
}
public void Dispose()
{
_events.Dispose();
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
}
}
}
@@ -0,0 +1,49 @@
using LehrerApp.Core.Models;
using Xunit;
namespace LehrerApp.Api.Tests;
public sealed class SchoolWeatherStoreTests
{
[Fact]
public void SaveLocation_TrenntNutzerUndErsetztVorhandenenStandort()
{
using var temp = new TempStore();
temp.Store.SaveLocation("eins", Location("Köln", 50.9, 6.9));
temp.Store.SaveLocation("zwei", Location("Berlin", 52.5, 13.4));
temp.Store.SaveLocation("eins", Location("Bonn", 50.7, 7.1));
Assert.Equal("Bonn", temp.Store.GetLocation("eins")!.City);
Assert.Equal("Berlin", temp.Store.GetLocation("zwei")!.City);
}
[Fact]
public void SaveLocation_AenderungEntferntWettercacheDesAltenOrts()
{
using var temp = new TempStore();
temp.Store.SaveLocation("eins", Location("Köln", 50.9, 6.9));
temp.Store.SaveWeather("eins", new WeatherSnapshot { StationId = "alt", RetrievedAt = DateTime.UtcNow });
temp.Store.SaveLocation("eins", Location("Bonn", 50.7, 7.1));
Assert.Null(temp.Store.GetWeather("eins"));
}
private static SchoolLocationProfile Location(string city, double lat, double lon) => new()
{
SchoolName = "Testschule", Street = "Schulweg 1", PostalCode = "12345", City = city,
Latitude = lat, Longitude = lon, ResolvedAddress = city, UpdatedAt = DateTime.UtcNow,
};
private sealed class TempStore : IDisposable
{
private readonly string _path = Path.Combine(Path.GetTempPath(), $"lehrerapp-weather-tests-{Guid.NewGuid():N}");
public SchoolWeatherStore Store { get; }
public TempStore() { Directory.CreateDirectory(_path); Store = new(_path); }
public void Dispose()
{
Store.Dispose();
if (Directory.Exists(_path)) Directory.Delete(_path, recursive: true);
}
}
}
+89
View File
@@ -0,0 +1,89 @@
using LehrerApp.Sync.Models;
using Xunit;
namespace LehrerApp.Api.Tests;
public sealed class SnapshotStoreTests
{
[Fact]
public void Store_ZweiterAufrufOhneCode_ErzeugtNeuenCode()
{
using var temp = new TempSnapshotStore();
var first = temp.Store.Store("user1", new SnapshotUploadRequest { EncryptedPayload = "p1" });
var second = temp.Store.Store("user1", new SnapshotUploadRequest { EncryptedPayload = "p2" });
Assert.NotEqual(first.Code, second.Code);
}
/// Regression: SnapshotService.CreateAndUploadAsync lädt in zwei Schritten hoch — Schritt 1
/// ohne EncryptedSyncKey (holt einen Code vom Server), Schritt 2 mit dem gerade erst mit
/// diesem Code verschlüsselten Schlüssel. Ohne den zurückgereichten Code erzeugte Store() bei
/// jedem Aufruf einen NEUEN Zufallscode — der an den Nutzer angezeigte Code (aus Schritt 2)
/// passte dann nie zu dem Code, mit dem der Schlüssel tatsächlich verschlüsselt wurde, und das
/// Einlösen auf dem zweiten Gerät schlug beim Schlüssel-Entschlüsseln immer fehl.
[Fact]
public void Store_ZweiterAufrufMitCodeDesErstenSchritts_AktualisiertDenselbenEintrag()
{
using var temp = new TempSnapshotStore();
var step1 = temp.Store.Store("user1", new SnapshotUploadRequest { EncryptedPayload = "payload" });
var step2 = temp.Store.Store("user1", new SnapshotUploadRequest
{
EncryptedPayload = "payload", EncryptedSyncKey = "encrypted-key", Code = step1.Code,
});
Assert.Equal(step1.Code, step2.Code);
var retrieved = temp.Store.Retrieve("user1", step2.Code);
Assert.NotNull(retrieved);
Assert.Equal("encrypted-key", retrieved!.EncryptedSyncKey);
}
[Fact]
public void Store_CodeGehoertZuAnderemNutzer_ErzeugtStattdessenNeuenEintrag()
{
using var temp = new TempSnapshotStore();
var step1 = temp.Store.Store("user1", new SnapshotUploadRequest { EncryptedPayload = "payload" });
var result = temp.Store.Store("user2", new SnapshotUploadRequest
{
EncryptedPayload = "payload", EncryptedSyncKey = "encrypted-key", Code = step1.Code,
});
Assert.NotEqual(step1.Code, result.Code);
Assert.NotNull(temp.Store.Retrieve("user2", result.Code));
}
[Fact]
public void Retrieve_LoeschtDenEintragNachDemErstenAbruf()
{
using var temp = new TempSnapshotStore();
var uploaded = temp.Store.Store("user1", new SnapshotUploadRequest { EncryptedPayload = "payload" });
var first = temp.Store.Retrieve("user1", uploaded.Code);
var second = temp.Store.Retrieve("user1", uploaded.Code);
Assert.NotNull(first);
Assert.Null(second);
}
private sealed class TempSnapshotStore : IDisposable
{
private readonly string _directory = Path.Combine(
Path.GetTempPath(), $"lehrerapp-api-tests-{Guid.NewGuid():N}");
public LehrerApp.Api.SnapshotStore Store { get; }
public TempSnapshotStore()
{
Directory.CreateDirectory(_directory);
Store = new LehrerApp.Api.SnapshotStore(_directory);
}
public void Dispose()
{
Store.Dispose();
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
}
}
}
@@ -0,0 +1,81 @@
using LehrerApp.Sync;
using Microsoft.AspNetCore.Http;
using Xunit;
namespace LehrerApp.Api.Tests;
public sealed class SyncProtocolVersionMiddlewareTests
{
[Theory]
[InlineData("/api/sync/push")]
[InlineData("/api/sync/plain/push")]
[InlineData("/api/sync/attachments/datei")]
[InlineData("/api/snapshot/upload")]
public async Task FehlendeVersion_BlockiertAlleSyncKanaeleVorDemEndpoint(string path)
{
var endpointReached = false;
var middleware = Middleware(() => endpointReached = true);
var context = Context(path);
await middleware.InvokeAsync(context);
Assert.False(endpointReached);
Assert.Equal(StatusCodes.Status426UpgradeRequired, context.Response.StatusCode);
Assert.Equal(SyncProtocol.CurrentVersion,
context.Response.Headers[SyncProtocol.VersionHeaderName].ToString());
}
[Fact]
public async Task AbweichendeVersion_BlockiertRequest()
{
var endpointReached = false;
var middleware = Middleware(() => endpointReached = true);
var context = Context("/api/sync/push");
context.Request.Headers[SyncProtocol.VersionHeaderName] = "0";
await middleware.InvokeAsync(context);
Assert.False(endpointReached);
Assert.Equal(StatusCodes.Status426UpgradeRequired, context.Response.StatusCode);
}
[Fact]
public async Task AktuelleVersion_LaesstRequestZumEndpointDurch()
{
var endpointReached = false;
var middleware = Middleware(() => endpointReached = true);
var context = Context("/api/sync/push");
context.Request.Headers[SyncProtocol.VersionHeaderName] = SyncProtocol.CurrentVersion;
await middleware.InvokeAsync(context);
Assert.True(endpointReached);
Assert.NotEqual(StatusCodes.Status426UpgradeRequired, context.Response.StatusCode);
}
[Fact]
public async Task NichtSyncEndpoint_BrauchtKeineVersion()
{
var endpointReached = false;
var middleware = Middleware(() => endpointReached = true);
var context = Context("/api/auth/login");
await middleware.InvokeAsync(context);
Assert.True(endpointReached);
}
private static SyncProtocolVersionMiddleware Middleware(Action reached) => new(_ =>
{
reached();
return Task.CompletedTask;
});
private static DefaultHttpContext Context(string path)
{
var context = new DefaultHttpContext();
context.Request.Path = path;
context.Response.Body = new MemoryStream();
return context;
}
}
+105
View File
@@ -0,0 +1,105 @@
using Xunit;
namespace LehrerApp.Api.Tests;
public sealed class UserStoreTests
{
[Fact]
public void CreateUser_NeuerNutzername_GibtTrueZurueckUndKannSichAnmelden()
{
using var temp = new TempUserStore();
var created = temp.Store.CreateUser("sebastian", "einSicheresPasswort");
Assert.True(created);
Assert.NotNull(temp.Store.Authenticate("sebastian", "einSicheresPasswort"));
}
[Fact]
public void CreateUser_BereitsVorhandenerNutzername_GibtFalseZurueck()
{
using var temp = new TempUserStore();
temp.Store.CreateUser("sebastian", "einSicheresPasswort");
var created = temp.Store.CreateUser("sebastian", "einAnderesPasswort");
Assert.False(created);
}
[Fact]
public void Authenticate_FalschesPasswort_LiefertNull()
{
using var temp = new TempUserStore();
temp.Store.CreateUser("sebastian", "einSicheresPasswort");
Assert.Null(temp.Store.Authenticate("sebastian", "falschesPasswort"));
}
[Fact]
public void Authenticate_UnbekannterNutzer_LiefertNull()
{
using var temp = new TempUserStore();
Assert.Null(temp.Store.Authenticate("unbekannt", "irgendwas"));
}
/// Regression: LiteDBs Standard-Collation vergleicht den eindeutigen Index auf Username
/// case-insensitive - ein Login mit abweichender Groß-/Kleinschreibung authentifiziert also
/// erfolgreich. Würde der Aufrufer (Endpoints./api/auth/login) daraufhin den roh eingegebenen
/// Namen statt des kanonisch gespeicherten als userId für den Server-seitigen Event-Speicher
/// verwenden, würde ein einziger andersartig getippter Login auf einem zweiten Gerät einen
/// komplett getrennten, nicht überlappenden Datenbestand erzeugen (siehe TODO 10.2.5 - genau
/// das vom Nutzer beobachtete Symptom: nur ein Konto im System, aber Client 2 erhielt trotz
/// since=0 keine Ereignisse).
[Fact]
public void Authenticate_AndereGrossKleinschreibung_LiefertDenKanonischGespeichertenNamen()
{
using var temp = new TempUserStore();
temp.Store.CreateUser("sebastian", "einSicheresPasswort");
var user = temp.Store.Authenticate("Sebastian", "einSicheresPasswort");
Assert.NotNull(user);
Assert.Equal("sebastian", user!.Username);
}
[Fact]
public void SetPassword_VorhandenerNutzer_AktualisiertPasswortUndAltesFunktioniertNichtMehr()
{
using var temp = new TempUserStore();
temp.Store.CreateUser("sebastian", "altesPasswort123");
var updated = temp.Store.SetPassword("sebastian", "neuesPasswort123");
Assert.True(updated);
Assert.NotNull(temp.Store.Authenticate("sebastian", "neuesPasswort123"));
Assert.Null(temp.Store.Authenticate("sebastian", "altesPasswort123"));
}
[Fact]
public void SetPassword_UnbekannterNutzer_GibtFalseZurueck()
{
using var temp = new TempUserStore();
Assert.False(temp.Store.SetPassword("unbekannt", "irgendeinPasswort"));
}
private sealed class TempUserStore : IDisposable
{
private readonly string _directory = Path.Combine(
Path.GetTempPath(), $"lehrerapp-api-tests-{Guid.NewGuid():N}");
public UserStore Store { get; }
public TempUserStore()
{
Directory.CreateDirectory(_directory);
Store = new UserStore(_directory);
}
public void Dispose()
{
Store.Dispose();
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
}
}
}
+29
View File
@@ -0,0 +1,29 @@
namespace LehrerApp.Api;
/// <summary>
/// Rohdaten-Ablage für verschlüsselte Datei-Anhänge, getrennt vom Ereignis-/Snapshot-Speicher.
/// Server sieht nur verschlüsselte Bytes, kein LiteDB nötig - einfache Dateien pro Nutzer/Id.
/// </summary>
public class AttachmentStore(string dataPath)
{
private readonly string _root = Path.Combine(dataPath, "attachments");
public async Task StoreAsync(string userId, string storageId, Stream content)
{
var dir = Path.Combine(_root, Safe(userId));
Directory.CreateDirectory(dir);
await using var file = File.Create(Path.Combine(dir, Safe(storageId)));
await content.CopyToAsync(file);
}
public Stream? OpenRead(string userId, string storageId)
{
var path = Path.Combine(_root, Safe(userId), Safe(storageId));
return File.Exists(path) ? File.OpenRead(path) : null;
}
// storageId kommt als Routen-Parameter vom Client - nie ungeprüft in einen Dateipfad
// übernehmen (Path-Traversal).
private static string Safe(string value) =>
string.Concat(value.Where(c => char.IsLetterOrDigit(c) || c == '-'));
}
+85
View File
@@ -0,0 +1,85 @@
using System.Text;
namespace LehrerApp.Api;
internal static class Cli
{
public static Task<int> RunCreateUserAsync(string dataPath, string[] args)
{
if (args.Length < 2)
{
Console.Error.WriteLine("Verwendung: create-user <benutzername> [--password <passwort>]");
return Task.FromResult(1);
}
var username = args[1];
var password = ParsePassword(args);
if (password is null) return Task.FromResult(1);
Directory.CreateDirectory(dataPath);
using var store = new UserStore(dataPath);
if (!store.CreateUser(username, password))
{
Console.Error.WriteLine($"Nutzer '{username}' existiert bereits.");
return Task.FromResult(1);
}
Console.WriteLine($"Nutzer '{username}' angelegt.");
return Task.FromResult(0);
}
public static Task<int> RunSetPasswordAsync(string dataPath, string[] args)
{
if (args.Length < 2)
{
Console.Error.WriteLine("Verwendung: set-password <benutzername> [--password <passwort>]");
return Task.FromResult(1);
}
var username = args[1];
var password = ParsePassword(args);
if (password is null) return Task.FromResult(1);
Directory.CreateDirectory(dataPath);
using var store = new UserStore(dataPath);
if (!store.SetPassword(username, password))
{
Console.Error.WriteLine($"Nutzer '{username}' existiert nicht.");
return Task.FromResult(1);
}
Console.WriteLine($"Passwort für '{username}' aktualisiert.");
return Task.FromResult(0);
}
private static string? ParsePassword(string[] args)
{
string? password = null;
for (var i = 2; i < args.Length - 1; i++)
if (args[i] == "--password") password = args[i + 1];
password ??= ReadPassword("Passwort (mind. 12 Zeichen): ");
if (string.IsNullOrWhiteSpace(password) || password.Length < 12)
{
Console.Error.WriteLine("Passwort muss mindestens 12 Zeichen lang sein.");
return null;
}
return password;
}
// docker exec ohne -it liefert kein TTY -> ReadKey wäre nicht möglich, dann normal lesen.
private static string ReadPassword(string prompt)
{
Console.Write(prompt);
if (Console.IsInputRedirected) return Console.ReadLine() ?? "";
var sb = new StringBuilder();
ConsoleKeyInfo key;
while ((key = Console.ReadKey(intercept: true)).Key != ConsoleKey.Enter)
{
if (key.Key == ConsoleKey.Backspace && sb.Length > 0) { sb.Length--; Console.Write("\b \b"); }
else if (!char.IsControl(key.KeyChar)) { sb.Append(key.KeyChar); Console.Write('*'); }
}
Console.WriteLine();
return sb.ToString();
}
}
+272
View File
@@ -0,0 +1,272 @@
using System.Globalization;
using System.IO.Compression;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using LehrerApp.Core.Models;
namespace LehrerApp.Api;
/// <summary>Lädt DWD-MOSMIX-Punktvorhersagen und CAP-Warnungen. Alle externen Antworten werden
/// pro Serverinstanz gecacht; pro Benutzer wird zusätzlich der letzte erfolgreiche Snapshot im
/// SchoolWeatherStore persistiert.</summary>
public sealed class DwdWeatherService(HttpClient http)
{
private const string StationCatalogUrl =
"https://www.dwd.de/DE/leistungen/met_verfahren_mosmix/mosmix_stationskatalog.cfg?view=nasPublication";
private const string ForecastUrl =
"https://opendata.dwd.de/weather/local_forecasts/mos/MOSMIX_L/single_stations/{0}/kml/MOSMIX_L_LATEST_{0}.kmz";
private const string WarningsUrl =
"https://opendata.dwd.de/weather/alerts/cap/COMMUNEUNION_DWD_STAT/Z_CAP_C_EDZW_LATEST_PVW_STATUS_PREMIUMDWD_COMMUNEUNION_DE.zip";
private readonly SemaphoreSlim _gate = new(1, 1);
private List<MosmixStation>? _stations;
private DateTime _stationsLoadedAt;
private readonly Dictionary<string, CachedForecast> _forecastCache = [];
private CachedWarnings? _warningsCache;
public async Task<WeatherSnapshot> GetAsync(SchoolLocationProfile location,
CancellationToken cancellationToken)
{
await _gate.WaitAsync(cancellationToken);
try
{
var stations = await GetStationsAsync(cancellationToken);
var station = FindNearestStation(stations, location.Latitude, location.Longitude)
?? throw new InvalidOperationException("Der DWD-Stationskatalog enthält keine Stationen.");
var forecast = await GetForecastAsync(station, cancellationToken);
var warnings = await GetWarningsAsync(cancellationToken);
return new WeatherSnapshot
{
StationId = station.Id,
StationName = station.Name,
StationDistanceKm = Math.Round(DistanceKm(
location.Latitude, location.Longitude, station.Latitude, station.Longitude), 1),
ForecastIssuedAt = forecast.IssuedAt,
RetrievedAt = DateTime.UtcNow,
Forecast = forecast.Hours,
Warnings = warnings.Where(w => Contains(w.Polygons, location.Latitude, location.Longitude))
.Select(w => w.Warning)
.OrderByDescending(w => SeverityRank(w.Severity))
.ThenBy(w => w.Onset)
.ToList(),
};
}
finally { _gate.Release(); }
}
private async Task<List<MosmixStation>> GetStationsAsync(CancellationToken cancellationToken)
{
if (_stations is not null && DateTime.UtcNow - _stationsLoadedAt < TimeSpan.FromDays(7))
return _stations;
var text = await http.GetStringAsync(StationCatalogUrl, cancellationToken);
_stations = ParseStationCatalog(text);
_stationsLoadedAt = DateTime.UtcNow;
return _stations;
}
private async Task<ParsedForecast> GetForecastAsync(MosmixStation station,
CancellationToken cancellationToken)
{
if (_forecastCache.TryGetValue(station.Id, out var cached) &&
DateTime.UtcNow - cached.LoadedAt < TimeSpan.FromMinutes(45))
return cached.Forecast;
var bytes = await http.GetByteArrayAsync(string.Format(CultureInfo.InvariantCulture,
ForecastUrl, station.Id), cancellationToken);
var parsed = ParseMosmix(bytes);
_forecastCache[station.Id] = new(DateTime.UtcNow, parsed);
return parsed;
}
private async Task<List<ParsedWarning>> GetWarningsAsync(CancellationToken cancellationToken)
{
if (_warningsCache is not null &&
DateTime.UtcNow - _warningsCache.LoadedAt < TimeSpan.FromMinutes(5))
return _warningsCache.Warnings;
var bytes = await http.GetByteArrayAsync(WarningsUrl, cancellationToken);
var warnings = ParseCapArchive(bytes, DateTime.UtcNow);
_warningsCache = new(DateTime.UtcNow, warnings);
return warnings;
}
public static List<MosmixStation> ParseStationCatalog(string text)
{
var regex = new Regex(@"^(\S+)\s+\S+\s+(.{20})\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s+", RegexOptions.Compiled);
var result = new List<MosmixStation>();
foreach (var line in text.Split('\n'))
{
var match = regex.Match(line.TrimEnd('\r'));
if (!match.Success ||
!double.TryParse(match.Groups[3].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var lat) ||
!double.TryParse(match.Groups[4].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var lon))
continue;
result.Add(new(match.Groups[1].Value, match.Groups[2].Value.Trim(), lat, lon));
}
return result;
}
public static MosmixStation? FindNearestStation(IEnumerable<MosmixStation> stations,
double latitude, double longitude) => stations.MinBy(s =>
DistanceKm(latitude, longitude, s.Latitude, s.Longitude));
public static ParsedForecast ParseMosmix(byte[] kmz)
{
using var memory = new MemoryStream(kmz);
using var archive = new ZipArchive(memory, ZipArchiveMode.Read);
var entry = archive.Entries.FirstOrDefault(x => x.Name.EndsWith(".kml", StringComparison.OrdinalIgnoreCase))
?? throw new InvalidDataException("Die MOSMIX-Datei enthält kein KML-Dokument.");
using var stream = entry.Open();
var document = XDocument.Load(stream);
XNamespace dwd = "https://opendata.dwd.de/weather/lib/pointforecast_dwd_extension_V1_0.xsd";
var times = document.Descendants(dwd + "TimeStep")
.Select(x => ParseDate(x.Value))
.Where(x => x.HasValue)
.Select(x => x!.Value)
.ToList();
if (times.Count == 0) throw new InvalidDataException("MOSMIX enthält keine Zeitschritte.");
var values = document.Descendants(dwd + "Forecast")
.Where(x => x.Attribute(dwd + "elementName") is not null)
.ToDictionary(
x => x.Attribute(dwd + "elementName")!.Value,
x => ParseValues(x.Element(dwd + "value")?.Value ?? ""),
StringComparer.Ordinal);
double? Value(string key, int i) => values.TryGetValue(key, out var series) && i < series.Count
? series[i] : null;
var hours = new List<WeatherForecastHour>();
var earliest = DateTime.UtcNow.AddHours(-1);
// MOSMIX_L reicht bis +240 h. Die vollständige Spanne ist für die Unterrichtsplanung
// sinnvoll, weil das Wochenraster auch in die Folgewoche geblättert werden kann.
for (var i = 0; i < times.Count && hours.Count < 240; i++)
{
if (times[i] < earliest) continue;
hours.Add(new WeatherForecastHour
{
ValidAt = times[i],
TemperatureC = Round(Value("TTT", i) - 273.15, 1),
WindSpeedKmh = Round(Value("FF", i) * 3.6, 1),
WindGustKmh = Round(Value("FX1", i) * 3.6, 1),
PrecipitationMm = Round(Value("RR1c", i), 1),
// Beide Zusatzwerte stammen aus derselben MOSMIX-L-Datei. R101 bezeichnet die
// Wahrscheinlichkeit für >0,1 mm in der letzten Stunde, SunD1 liegt in Sekunden vor.
PrecipitationProbabilityPercent = Round(Value("R101", i), 0),
SunshineDurationMinutes = Round(Value("SunD1", i) / 60, 0),
CloudCoverPercent = Round(Value("Neff", i), 0),
WeatherCode = Value("ww", i) is { } code ? (int)Math.Round(code) : null,
});
}
return new(ParseDate(document.Descendants(dwd + "IssueTime").FirstOrDefault()?.Value), hours);
}
public static List<ParsedWarning> ParseCapArchive(byte[] zip, DateTime nowUtc)
{
var result = new List<ParsedWarning>();
using var memory = new MemoryStream(zip);
using var archive = new ZipArchive(memory, ZipArchiveMode.Read);
foreach (var entry in archive.Entries.Where(x => x.Name.EndsWith(".xml", StringComparison.OrdinalIgnoreCase)))
{
using var stream = entry.Open();
var document = XDocument.Load(stream);
XNamespace cap = "urn:oasis:names:tc:emergency:cap:1.2";
var root = document.Root;
if (root is null || root.Element(cap + "status")?.Value != "Actual") continue;
var info = root.Elements(cap + "info").FirstOrDefault(x =>
x.Element(cap + "language")?.Value.StartsWith("de", StringComparison.OrdinalIgnoreCase) == true)
?? root.Elements(cap + "info").FirstOrDefault();
if (info is null) continue;
var expires = ParseDate(info.Element(cap + "expires")?.Value);
if (expires is { } until && until < nowUtc) continue;
var polygons = info.Elements(cap + "area")
.SelectMany(x => x.Elements(cap + "polygon"))
.Select(x => ParsePolygon(x.Value))
.Where(x => x.Count >= 3)
.ToList();
// Warngebiete ohne Polygon (z.B. Seegebiete) können einer Schulkoordinate nicht
// zuverlässig zugeordnet werden und werden daher bewusst nicht global angezeigt.
if (polygons.Count == 0) continue;
result.Add(new ParsedWarning(new WeatherWarning
{
Identifier = root.Element(cap + "identifier")?.Value ?? "",
Event = info.Element(cap + "event")?.Value ?? "",
Headline = info.Element(cap + "headline")?.Value ?? "",
Description = info.Element(cap + "description")?.Value ?? "",
Instruction = info.Element(cap + "instruction")?.Value ?? "",
Severity = info.Element(cap + "severity")?.Value ?? "Unknown",
Onset = ParseDate(info.Element(cap + "onset")?.Value),
Expires = expires,
}, polygons));
}
return result;
}
private static List<double?> ParseValues(string raw) => raw
.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
.Select(x => double.TryParse(x, NumberStyles.Float, CultureInfo.InvariantCulture, out var value)
? (double?)value : null)
.ToList();
private static List<GeoPoint> ParsePolygon(string raw) => raw
.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
.Select(x => x.Split(','))
.Where(x => x.Length >= 2 &&
double.TryParse(x[0], NumberStyles.Float, CultureInfo.InvariantCulture, out _) &&
double.TryParse(x[1], NumberStyles.Float, CultureInfo.InvariantCulture, out _))
.Select(x => new GeoPoint(
double.Parse(x[0], CultureInfo.InvariantCulture),
double.Parse(x[1], CultureInfo.InvariantCulture)))
.ToList();
public static bool PointInPolygon(IReadOnlyList<GeoPoint> polygon, double latitude, double longitude)
{
var inside = false;
for (int i = 0, j = polygon.Count - 1; i < polygon.Count; j = i++)
{
var a = polygon[i];
var b = polygon[j];
if ((a.Latitude > latitude) != (b.Latitude > latitude) &&
longitude < (b.Longitude - a.Longitude) * (latitude - a.Latitude) /
(b.Latitude - a.Latitude) + a.Longitude)
inside = !inside;
}
return inside;
}
private static bool Contains(IEnumerable<IReadOnlyList<GeoPoint>> polygons,
double latitude, double longitude) => polygons.Any(p => PointInPolygon(p, latitude, longitude));
private static double DistanceKm(double lat1, double lon1, double lat2, double lon2)
{
const double earthRadiusKm = 6371.0;
static double Rad(double degrees) => degrees * Math.PI / 180.0;
var dLat = Rad(lat2 - lat1);
var dLon = Rad(lon2 - lon1);
var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) +
Math.Cos(Rad(lat1)) * Math.Cos(Rad(lat2)) *
Math.Sin(dLon / 2) * Math.Sin(dLon / 2);
return earthRadiusKm * 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
}
private static DateTime? ParseDate(string? value) =>
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces,
out var parsed) ? parsed.UtcDateTime : null;
private static double? Round(double? value, int digits) => value is null
? null : Math.Round(value.Value, digits, MidpointRounding.AwayFromZero);
private static int SeverityRank(string severity) => severity switch
{
"Extreme" => 4, "Severe" => 3, "Moderate" => 2, "Minor" => 1, _ => 0,
};
public sealed record MosmixStation(string Id, string Name, double Latitude, double Longitude);
public sealed record ParsedForecast(DateTime? IssuedAt, List<WeatherForecastHour> Hours);
public sealed record GeoPoint(double Latitude, double Longitude);
public sealed record ParsedWarning(WeatherWarning Warning, List<List<GeoPoint>> Polygons);
private sealed record CachedForecast(DateTime LoadedAt, ParsedForecast Forecast);
private sealed record CachedWarnings(DateTime LoadedAt, List<ParsedWarning> Warnings);
}
+165 -13
View File
@@ -1,9 +1,11 @@
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using System.Text; using System.Text;
using System.Globalization;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Sync.Models; using LehrerApp.Sync.Models;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
namespace LehrerApp.Api; namespace LehrerApp.Api;
@@ -14,21 +16,20 @@ public static class Endpoints
public static void MapAuthEndpoints(this WebApplication app, string secret) public static void MapAuthEndpoints(this WebApplication app, string secret)
{ {
app.MapPost("/api/auth/login", (LoginRequest req) => app.MapPost("/api/auth/login", (LoginRequest req, UserStore store) =>
{ {
if (string.IsNullOrWhiteSpace(req.Username) || string.IsNullOrWhiteSpace(req.Password)) if (string.IsNullOrWhiteSpace(req.Username) || string.IsNullOrWhiteSpace(req.Password))
return Results.Unauthorized(); return Results.Unauthorized();
// TODO: Passwort gegen DB prüfen var user = store.Authenticate(req.Username, req.Password);
return Results.Ok(new { token = Jwt(req.Username, secret), userId = req.Username }); // Der kanonisch gespeicherte Username aus UserStore.Authenticate (nicht req.Username!)
}); // wird als JWT-userId verwendet - LiteDBs case-insensitive Standard-Collation lässt
// einen Login mit abweichender Groß-/Kleinschreibung erfolgreich durch; würde man
app.MapPost("/api/auth/register", (RegisterRequest req) => // stattdessen req.Username übernehmen, würde jede andersartig getippte Anmeldung einen
{ // eigenen, komplett getrennten Server-seitigen Event-Speicher erzeugen (siehe
if (req.Password.Length < 12) // UserStore.Authenticate).
return Results.BadRequest("Passwort mind. 12 Zeichen."); if (user is null) return Results.Unauthorized();
// TODO: User anlegen, Passwort hashen (BCrypt) return Results.Ok(new { token = Jwt(user.Username, secret), userId = user.Username });
return Results.Ok(new { token = Jwt(req.Username, secret), userId = req.Username }); }).RequireRateLimiting("login");
});
} }
// ── Sync ────────────────────────────────────────────────────────────────── // ── Sync ──────────────────────────────────────────────────────────────────
@@ -53,6 +54,41 @@ public static class Endpoints
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
return uid is null ? Results.Unauthorized() : Results.Ok(new { userId = uid, timestamp = DateTime.UtcNow }); return uid is null ? Results.Unauthorized() : Results.Ok(new { userId = uid, timestamp = DateTime.UtcNow });
}); });
// Für Clients, deren Push wegen eines neueren Server-Stands abgelehnt wurde (10.3.4-
// Nachtrag: exakte Kollisionsprüfung statt 30s-Heuristik) — sofortiges Nachladen des
// aktuellen Stands EINER Entität, statt auf den nächsten regulären Pull zu warten.
g.MapGet("/entity/{entityType}/{entityId}", (string entityType, string entityId,
ClaimsPrincipal user, EventStore store) =>
{
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (uid is null) return Results.Unauthorized();
var result = store.GetLatestForEntity(uid, entityType, entityId);
return result is null ? Results.NotFound() : Results.Ok(result);
});
}
// ── Anhänge (eigener Binärkanal, getrennt vom JSON-Ereigniskanal) ──────────
public static void MapAttachmentEndpoints(this WebApplication app)
{
var g = app.MapGroup("/api/sync/attachments").RequireAuthorization();
g.MapPost("/{storageId}", async (string storageId, HttpRequest req,
ClaimsPrincipal user, AttachmentStore store) =>
{
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (uid is null) return Results.Unauthorized();
if (req.ContentLength is null or > LehrerApp.Core.Interfaces.IAttachmentStorage.MaxSizeBytes)
return Results.BadRequest("Datei zu groß oder Content-Length fehlt.");
await store.StoreAsync(uid, storageId, req.Body);
return Results.Ok();
});
g.MapGet("/{storageId}", (string storageId, ClaimsPrincipal user, AttachmentStore store) =>
{
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (uid is null) return Results.Unauthorized();
var stream = store.OpenRead(uid, storageId);
return stream is null ? Results.NotFound() : Results.Stream(stream, "application/octet-stream");
});
} }
// ── Snapshot (Device-Pairing) ───────────────────────────────────────────── // ── Snapshot (Device-Pairing) ─────────────────────────────────────────────
@@ -114,6 +150,123 @@ public static class Endpoints
}); });
} }
// ── Schulstandort und DWD-Wetter ─────────────────────────────────────────
public static void MapSchoolWeatherEndpoints(this WebApplication app)
{
var g = app.MapGroup("/api/school").RequireAuthorization();
g.MapGet("/location", (ClaimsPrincipal user, SchoolWeatherStore store) =>
{
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (uid is null) return Results.Unauthorized();
var location = store.GetLocation(uid);
return location is null
? Results.NotFound("Noch kein Schulstandort hinterlegt.")
: Results.Ok(location);
});
g.MapPut("/location", async ([FromBody] SchoolLocationRequest request,
ClaimsPrincipal user, SchoolWeatherStore store, IGeocoder geocoder,
CancellationToken cancellationToken) =>
{
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (uid is null) return Results.Unauthorized();
var errors = ValidateLocation(request);
if (errors.Count > 0) return Results.ValidationProblem(errors);
var existing = store.GetLocation(uid);
if (existing is not null && SameLocation(existing, request))
return Results.Ok(existing);
GeocodingResult? result;
try { result = await geocoder.GeocodeAsync(request, cancellationToken); }
catch (HttpRequestException)
{
return Results.Problem("Der Geocoding-Dienst ist momentan nicht erreichbar.",
statusCode: StatusCodes.Status503ServiceUnavailable);
}
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return Results.Problem("Die Adressprüfung hat zu lange gedauert.",
statusCode: StatusCodes.Status503ServiceUnavailable);
}
if (result is null)
return Results.UnprocessableEntity(new
{
detail = "Die Schuladresse konnte nicht eindeutig gefunden werden. Bitte Schreibweise, PLZ und Ort prüfen.",
});
var profile = new SchoolLocationProfile
{
SchoolName = request.SchoolName.Trim(),
Street = request.Street.Trim(),
PostalCode = request.PostalCode.Trim(),
City = request.City.Trim(),
State = request.State,
Latitude = result.Latitude,
Longitude = result.Longitude,
ResolvedAddress = result.DisplayName,
UpdatedAt = DateTime.UtcNow,
};
store.SaveLocation(uid, profile);
return Results.Ok(profile);
});
g.MapGet("/weather", async (ClaimsPrincipal user, SchoolWeatherStore store,
DwdWeatherService weather, CancellationToken cancellationToken) =>
{
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (uid is null) return Results.Unauthorized();
var location = store.GetLocation(uid);
if (location is null) return Results.NotFound("Noch kein Schulstandort hinterlegt.");
var cached = store.GetWeather(uid);
if (cached is not null && DateTime.UtcNow - cached.RetrievedAt.ToUniversalTime() < TimeSpan.FromMinutes(5))
return Results.Ok(cached);
try
{
var current = await weather.GetAsync(location, cancellationToken);
store.SaveWeather(uid, current);
return Results.Ok(current);
}
catch (Exception ex) when (ex is HttpRequestException or InvalidDataException or InvalidOperationException)
{
if (cached is not null)
{
cached.IsStale = true;
return Results.Ok(cached);
}
return Results.Problem("Der DWD ist momentan nicht erreichbar und es liegt noch kein Wetter-Cache vor.",
statusCode: StatusCodes.Status503ServiceUnavailable);
}
});
}
private static Dictionary<string, string[]> ValidateLocation(SchoolLocationRequest request)
{
var errors = new Dictionary<string, string[]>();
if (string.IsNullOrWhiteSpace(request.SchoolName))
errors[nameof(request.SchoolName)] = ["Bitte den Namen der Schule angeben."];
if (string.IsNullOrWhiteSpace(request.Street))
errors[nameof(request.Street)] = ["Bitte Straße und Hausnummer angeben."];
if (!System.Text.RegularExpressions.Regex.IsMatch(request.PostalCode?.Trim() ?? "", @"^\d{5}$"))
errors[nameof(request.PostalCode)] = ["Bitte eine fünfstellige deutsche PLZ angeben."];
if (string.IsNullOrWhiteSpace(request.City))
errors[nameof(request.City)] = ["Bitte den Ort angeben."];
if (!Enum.IsDefined(request.State))
errors[nameof(request.State)] = ["Das Bundesland ist ungültig."];
return errors;
}
private static bool SameLocation(SchoolLocationProfile existing, SchoolLocationRequest request) =>
string.Equals(existing.SchoolName, request.SchoolName.Trim(), StringComparison.OrdinalIgnoreCase) &&
string.Equals(existing.Street, request.Street.Trim(), StringComparison.OrdinalIgnoreCase) &&
string.Equals(existing.PostalCode, request.PostalCode.Trim(), StringComparison.Ordinal) &&
string.Equals(existing.City, request.City.Trim(), StringComparison.OrdinalIgnoreCase) &&
existing.State == request.State;
// ── JWT ─────────────────────────────────────────────────────────────────── // ── JWT ───────────────────────────────────────────────────────────────────
private static string Jwt(string userId, string secret) private static string Jwt(string userId, string secret)
@@ -130,4 +283,3 @@ public static class Endpoints
} }
public record LoginRequest(string Username, string Password); public record LoginRequest(string Username, string Password);
public record RegisterRequest(string Username, string Password, string DisplayName);
+52 -8
View File
@@ -1,4 +1,5 @@
using LiteDB; using LiteDB;
using LehrerApp.Sync;
using LehrerApp.Sync.Models; using LehrerApp.Sync.Models;
namespace LehrerApp.Api; namespace LehrerApp.Api;
@@ -11,37 +12,80 @@ public class EventStore(string dataPath) : IDisposable
private readonly Dictionary<string, LiteDatabase> _dbs = new(); private readonly Dictionary<string, LiteDatabase> _dbs = new();
private readonly Lock _lock = new(); private readonly Lock _lock = new();
/// <summary>
/// Nimmt Ereignisse an, wenn ihre <see cref="SyncEvent.BasedOnServerSeq"/> exakt der aktuellen
/// ServerSeq der jeweiligen Entität entspricht (null == Entität hier noch nie gesehen, z.B.
/// Neuanlage) — echte optimistische Nebenläufigkeitskontrolle statt der früheren 30-Sekunden-
/// Heuristik ("hat ein anderes Gerät kürzlich dieselbe Entität angefasst"), die sowohl falsch-
/// positiv (zwei Geräte bearbeiten zufällig kurz hintereinander verschiedene Felder) als auch
/// falsch-negativ (echter Konflikt liegt außerhalb des 30s-Fensters) sein konnte.
/// Mehrere Ereignisse derselben Entität IM SELBEN Aufruf bauen bewusst aufeinander auf (das
/// zweite prüft gegen den vom ersten gerade neu vergebenen Stand) — der Client schickt ohnehin
/// nur noch das jüngste ausstehende Ereignis je Entität (siehe SyncEngine.PushAsync).
/// </summary>
public PushResponse Push(string userId, List<SyncEvent> events) public PushResponse Push(string userId, List<SyncEvent> events)
{ {
var col = GetCol(userId); var col = GetCol(userId);
var seq = LastSeq(col); var seq = LastSeq(col);
var rejects = new List<Guid>(); var rejects = new List<Guid>();
var assigned = new Dictionary<Guid, long>();
foreach (var e in events.OrderBy(e => e.Timestamp)) foreach (var e in events.OrderBy(e => e.Timestamp))
{ {
var recent = col.FindOne(x => var currentSeq = LatestForEntity(col, e.EntityType, e.EntityId)?.ServerSeq;
x.EntityType == e.EntityType && x.EntityId == e.EntityId && if (currentSeq != e.BasedOnServerSeq) { rejects.Add(e.EventId); continue; }
x.DeviceId != e.DeviceId && x.Timestamp > e.Timestamp.AddSeconds(-30)); var newSeq = ++seq;
if (recent is not null) { rejects.Add(e.EventId); continue; }
col.Insert(new ServerEvent { EventId = e.EventId, DeviceId = e.DeviceId, col.Insert(new ServerEvent { EventId = e.EventId, DeviceId = e.DeviceId,
DeviceType = e.DeviceType, Timestamp = e.Timestamp, DeviceType = e.DeviceType, Timestamp = e.Timestamp,
ClientSeq = e.SequenceNr, ServerSeq = ++seq, ClientSeq = e.SequenceNr, ServerSeq = newSeq,
EntityType = e.EntityType, EntityId = e.EntityId, EntityType = e.EntityType, EntityId = e.EntityId,
Operation = e.Operation, Payload = e.Payload }); Operation = e.Operation, Payload = e.Payload });
assigned[e.EventId] = newSeq;
} }
return new() { Success = true, ServerSequenceNr = seq, ConflictingEventIds = rejects }; return new() { Success = true, ServerSequenceNr = seq, ConflictingEventIds = rejects,
AssignedServerSeqs = assigned };
} }
/// <summary>Aktuellstes Ereignis einer einzelnen Entität — für Clients, deren Push wegen eines
/// neueren Server-Stands abgelehnt wurde (siehe <see cref="Push"/>), um sofort den aktuellen
/// Stand nachzuladen, statt auf den nächsten regulären Pull zu warten.</summary>
public SyncEvent? GetLatestForEntity(string userId, string entityType, string entityId)
{
var col = GetCol(userId);
var e = LatestForEntity(col, entityType, entityId);
// SequenceNr trägt hier (wie bei Pull) die ServerSeq — dieselbe Konvention wie sonst im
// Sync-Protokoll: bei vom Server stammenden Ereignissen ist SequenceNr immer die ServerSeq.
return e is null ? null : new SyncEvent { EventId = e.EventId, DeviceId = e.DeviceId,
DeviceType = e.DeviceType, Timestamp = e.Timestamp, SequenceNr = e.ServerSeq,
EntityType = e.EntityType, EntityId = e.EntityId, Operation = e.Operation,
Payload = e.Payload };
}
private static ServerEvent? LatestForEntity(ILiteCollection<ServerEvent> col, string entityType, string entityId) =>
col.Find(x => x.EntityType == entityType && x.EntityId == entityId)
.OrderByDescending(x => x.ServerSeq).FirstOrDefault();
public PullResponse Pull(string userId, long since, string requestingDeviceId) public PullResponse Pull(string userId, long since, string requestingDeviceId)
{ {
var col = GetCol(userId); var col = GetCol(userId);
var events = col.Find(e => e.ServerSeq > since && e.DeviceId != requestingDeviceId) var events = col.Find(e => e.ServerSeq > since && e.DeviceId != requestingDeviceId)
.OrderBy(e => e.ServerSeq).Take(500) .OrderBy(e => e.ServerSeq).Take(SyncProtocol.PullBatchSize)
.Select(e => new SyncEvent { EventId = e.EventId, DeviceId = e.DeviceId, .Select(e => new SyncEvent { EventId = e.EventId, DeviceId = e.DeviceId,
DeviceType = e.DeviceType, Timestamp = e.Timestamp, DeviceType = e.DeviceType, Timestamp = e.Timestamp,
SequenceNr = e.ServerSeq, EntityType = e.EntityType, SequenceNr = e.ServerSeq, EntityType = e.EntityType,
EntityId = e.EntityId, Operation = e.Operation, Payload = e.Payload }) EntityId = e.EntityId, Operation = e.Operation, Payload = e.Payload })
.ToList(); .ToList();
return new() { Events = events, ServerSequenceNr = LastSeq(col) }; // ServerSequenceNr MUSS die höchste ServerSeq unter den tatsächlich zurückgegebenen
// Ereignissen sein, NICHT der globale Höchststand (LastSeq(col)) — der schließt auch
// Ereignisse ANDERER Geräte ein, die z.B. gerade erst (nach dem obigen Find-Aufruf, aber
// vor dieser Zeile) eingetroffen sind, oder — der Bug, der hier tatsächlich beobachtet
// wurde — Ereignisse des anfragenden Geräts selbst, die oben bewusst per
// "DeviceId != requestingDeviceId" herausgefiltert wurden. SyncEngine.PullAsync übernimmt
// ServerSequenceNr 1:1 als neuen "since"-Cursor für den nächsten Pull; mit dem globalen
// Höchststand würde der Client seinen Cursor über Ereignisse hinweg vorrücken, die er nie
// erhalten hat, und sie dauerhaft verpassen — genau das vom Nutzer beobachtete Symptom
// (Push meldet Erfolg, Pull liefert 0 Ereignisse, obwohl welche ausstehen).
var newWatermark = events.Count > 0 ? events.Max(e => e.SequenceNr) : since;
return new() { Events = events, ServerSequenceNr = newWatermark };
} }
private ILiteCollection<ServerEvent> GetCol(string userId) private ILiteCollection<ServerEvent> GetCol(string userId)
+78
View File
@@ -0,0 +1,78 @@
using System.Globalization;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using LehrerApp.Core.Models;
namespace LehrerApp.Api;
public interface IGeocoder
{
Task<GeocodingResult?> GeocodeAsync(SchoolLocationRequest address, CancellationToken cancellationToken);
}
public sealed record GeocodingResult(double Latitude, double Longitude, string DisplayName);
/// <summary>Einmalige, explizit ausgelöste Adressauflösung über den öffentlichen OSM-Nominatim-
/// Dienst. Kein Autocomplete; die dauerhafte Ergebnisspeicherung übernimmt SchoolWeatherStore.</summary>
public sealed class NominatimGeocoder(HttpClient http) : IGeocoder
{
private static readonly SemaphoreSlim RequestGate = new(1, 1);
private static DateTime _lastRequestAt = DateTime.MinValue;
public async Task<GeocodingResult?> GeocodeAsync(SchoolLocationRequest address,
CancellationToken cancellationToken)
{
var structured = Query(new Dictionary<string, string>
{
["street"] = address.Street,
["postalcode"] = address.PostalCode,
["city"] = address.City,
});
var result = await SearchRespectingRateLimitAsync(structured, cancellationToken);
if (result is not null) return result;
// Manche Schulen/Ortsteile sind bei OSM anders adressiert. Ein kontrollierter zweiter
// Versuch mit der gesamten Suchzeile ist robuster, bleibt aber weit weg vom 1-rps-Limit.
var freeForm = string.Join(", ", new[]
{
address.SchoolName, address.Street, $"{address.PostalCode} {address.City}", "Deutschland",
}.Where(x => !string.IsNullOrWhiteSpace(x)));
return await SearchRespectingRateLimitAsync(
Query(new Dictionary<string, string> { ["q"] = freeForm }), cancellationToken);
}
private async Task<GeocodingResult?> SearchRespectingRateLimitAsync(string query,
CancellationToken cancellationToken)
{
await RequestGate.WaitAsync(cancellationToken);
try
{
var wait = TimeSpan.FromSeconds(1) - (DateTime.UtcNow - _lastRequestAt);
if (wait > TimeSpan.Zero) await Task.Delay(wait, cancellationToken);
_lastRequestAt = DateTime.UtcNow;
var candidates = await http.GetFromJsonAsync<List<NominatimCandidate>>(
$"search?format=jsonv2&limit=1&countrycodes=de{query}", cancellationToken);
var candidate = candidates?.FirstOrDefault();
if (candidate is null ||
!double.TryParse(candidate.Lat, NumberStyles.Float, CultureInfo.InvariantCulture, out var latitude) ||
!double.TryParse(candidate.Lon, NumberStyles.Float, CultureInfo.InvariantCulture, out var longitude))
return null;
return new(latitude, longitude, candidate.DisplayName ?? "");
}
finally { RequestGate.Release(); }
}
private static string Query(IReadOnlyDictionary<string, string> values) => string.Concat(
values.Where(x => !string.IsNullOrWhiteSpace(x.Value))
.Select(x => $"&{Uri.EscapeDataString(x.Key)}={Uri.EscapeDataString(x.Value.Trim())}"));
private sealed class NominatimCandidate
{
[JsonPropertyName("lat")]
public string? Lat { get; set; }
[JsonPropertyName("lon")]
public string? Lon { get; set; }
[JsonPropertyName("display_name")]
public string? DisplayName { get; set; }
}
}
+27
View File
@@ -0,0 +1,27 @@
using System.Security.Cryptography;
namespace LehrerApp.Api;
public static class PasswordHasher
{
private const int Iterations = 100_000;
private const int SaltSize = 16;
private const int HashSize = 32;
public static string Hash(string password)
{
var salt = RandomNumberGenerator.GetBytes(SaltSize);
var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, HashAlgorithmName.SHA256, HashSize);
return $"{Convert.ToBase64String(salt)}:{Convert.ToBase64String(hash)}";
}
public static bool Verify(string password, string stored)
{
var parts = stored.Split(':');
if (parts.Length != 2) return false;
var salt = Convert.FromBase64String(parts[0]);
var expected = Convert.FromBase64String(parts[1]);
var actual = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, HashAlgorithmName.SHA256, HashSize);
return CryptographicOperations.FixedTimeEquals(actual, expected);
}
}
+4 -2
View File
@@ -4,8 +4,10 @@ namespace LehrerApp.Api;
public class PlainEventStore(EventStore eventStore) public class PlainEventStore(EventStore eventStore)
{ {
private static readonly HashSet<string> Allowed = // Grade/ExamResult bewusst NICHT erlaubt: PlainSyncEvent.Payload ist Klartext-JSON (siehe
["Grade", "ExamResult", "WorkTask", "Lesson"]; // SyncModels.cs), beide Typen tragen aber eine StudentId plus Notenwert/-kommentar -
// personenbezogene Daten dürfen laut TODO.md 10.3.3 nicht unverschlüsselt übertragen werden.
private static readonly HashSet<string> Allowed = ["WorkTask", "Lesson"];
public PlainPushResponse Push(string userId, List<PlainSyncEvent> events) public PlainPushResponse Push(string userId, List<PlainSyncEvent> events)
{ {
+75 -1
View File
@@ -1,6 +1,9 @@
using System.Text; using System.Text;
using System.Threading.RateLimiting;
using LehrerApp.Api; using LehrerApp.Api;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -9,10 +12,22 @@ builder.WebHost.UseKestrel(o =>
{ {
var port = builder.Configuration.GetValue<int>("Api:Port", 5000); var port = builder.Configuration.GetValue<int>("Api:Port", 5000);
o.ListenAnyIP(port); o.ListenAnyIP(port);
// Anhänge sind clientseitig auf 10 MB begrenzt (IAttachmentStorage.MaxSizeBytes) - etwas
// Puffer für Verschlüsselungs-Overhead/JSON-Ereignisse, aber trotzdem eine harte Obergrenze.
o.Limits.MaxRequestBodySize = 15 * 1024 * 1024;
}); });
var data = builder.Configuration["Api:DataPath"] ?? "./data";
if (args.Length > 0 && args[0] == "create-user")
return await Cli.RunCreateUserAsync(data, args);
if (args.Length > 0 && args[0] == "set-password")
return await Cli.RunSetPasswordAsync(data, args);
var secret = builder.Configuration["JWT_SECRET"] var secret = builder.Configuration["JWT_SECRET"]
?? throw new InvalidOperationException("JWT_SECRET nicht konfiguriert."); ?? throw new InvalidOperationException("JWT_SECRET nicht konfiguriert.");
var outboundUserAgent = builder.Configuration["Geocoding:UserAgent"]
?? "LehrerApp-Server/1.0 (+https://science-teaching.de)";
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o => o.TokenValidationParameters = new() .AddJwtBearer(o => o.TokenValidationParameters = new()
@@ -24,19 +39,78 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
}); });
builder.Services.AddAuthorization(); builder.Services.AddAuthorization();
var data = builder.Configuration["Api:DataPath"] ?? "./data"; // Läuft hinter einem Reverse Proxy (Dokploy/Traefik) - ohne diese Weiterleitung sähe Kestrel für
// JEDE Anfrage dieselbe interne Proxy-IP als Connection.RemoteIpAddress, wodurch das unten
// definierte Pro-IP-Rate-Limit faktisch zu einem einzigen globalen Limit für die gesamte
// Bereitstellung würde (Bug, siehe TODO.md 10.2.2 Nachtrag). KnownNetworks/KnownProxies bewusst
// geleert: der Container ist nur über den Reverse Proxy erreichbar, nie direkt aus dem Internet,
// daher ist der unmittelbare Absender von X-Forwarded-For hier immer vertrauenswürdig.
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
});
// Rate Limiting (10.2.2): striktes Limit gezielt gegen Brute-Force auf /api/auth/login, plus ein
// grobes globales Limit pro IP als einfacher Schutz vor Überlastung der übrigen Endpunkte.
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddFixedWindowLimiter("login", o =>
{
o.PermitLimit = 5;
o.Window = TimeSpan.FromMinutes(1);
o.QueueLimit = 0;
});
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 120,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
}));
});
builder.Services.AddSingleton<UserStore>(_ => new UserStore(data));
builder.Services.AddSingleton<AttachmentStore>(_ => new AttachmentStore(data));
builder.Services.AddSingleton<EventStore>(_ => new EventStore(data)); builder.Services.AddSingleton<EventStore>(_ => new EventStore(data));
builder.Services.AddSingleton<SnapshotStore>(_ => new SnapshotStore(data)); builder.Services.AddSingleton<SnapshotStore>(_ => new SnapshotStore(data));
builder.Services.AddSingleton<ReadableSnapshotStore>(_ => new ReadableSnapshotStore(data)); builder.Services.AddSingleton<ReadableSnapshotStore>(_ => new ReadableSnapshotStore(data));
builder.Services.AddSingleton<PlainEventStore>(sp => builder.Services.AddSingleton<PlainEventStore>(sp =>
new PlainEventStore(sp.GetRequiredService<EventStore>())); new PlainEventStore(sp.GetRequiredService<EventStore>()));
builder.Services.AddSingleton<SchoolWeatherStore>(_ => new SchoolWeatherStore(data));
builder.Services.AddHttpClient<IGeocoder, NominatimGeocoder>(client =>
{
client.BaseAddress = new Uri("https://nominatim.openstreetmap.org/");
client.DefaultRequestHeaders.UserAgent.ParseAdd(outboundUserAgent);
client.Timeout = TimeSpan.FromSeconds(15);
});
builder.Services.AddHttpClient("dwd", client =>
{
client.DefaultRequestHeaders.UserAgent.ParseAdd(outboundUserAgent);
client.Timeout = TimeSpan.FromSeconds(30);
});
builder.Services.AddSingleton(sp => new DwdWeatherService(
sp.GetRequiredService<IHttpClientFactory>().CreateClient("dwd")));
var app = builder.Build(); var app = builder.Build();
app.UseForwardedHeaders();
app.UseRateLimiter();
// Muss vor Auth/Endpoints laufen: inkompatible Clients dürfen keinen Sync-Store erreichen.
app.UseMiddleware<SyncProtocolVersionMiddleware>();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.MapAuthEndpoints(secret); app.MapAuthEndpoints(secret);
app.MapSyncEndpoints(); app.MapSyncEndpoints();
app.MapAttachmentEndpoints();
app.MapSnapshotEndpoints(); app.MapSnapshotEndpoints();
app.MapReadableSnapshotEndpoints(); app.MapReadableSnapshotEndpoints();
app.MapPlainSyncEndpoints(); app.MapPlainSyncEndpoints();
app.MapSchoolWeatherEndpoints();
app.Run(); app.Run();
return 0;
+83
View File
@@ -0,0 +1,83 @@
using LehrerApp.Core.Models;
using LiteDB;
namespace LehrerApp.Api;
/// <summary>Unverschlüsselte Serverdaten, die der Server zur Abfrage standortbezogener öffentlicher
/// Wetterdaten benötigt. Bewusst getrennt vom clientseitig verschlüsselten EventStore.</summary>
public sealed class SchoolWeatherStore : IDisposable
{
private readonly LiteDatabase _db;
public SchoolWeatherStore(string dataPath)
{
Directory.CreateDirectory(dataPath);
_db = new LiteDatabase(Path.Combine(dataPath, "school-weather.db"));
}
private ILiteCollection<LocationEntry> Locations
{
get
{
var col = _db.GetCollection<LocationEntry>("locations");
col.EnsureIndex(x => x.UserId, unique: true);
return col;
}
}
private ILiteCollection<WeatherEntry> Weather
{
get
{
var col = _db.GetCollection<WeatherEntry>("weather");
col.EnsureIndex(x => x.UserId, unique: true);
return col;
}
}
public SchoolLocationProfile? GetLocation(string userId) =>
Locations.FindOne(x => x.UserId == userId)?.Profile;
public void SaveLocation(string userId, SchoolLocationProfile profile)
{
var existing = Locations.FindOne(x => x.UserId == userId);
Locations.Upsert(new LocationEntry
{
Id = existing?.Id ?? ObjectId.NewObjectId(),
UserId = userId,
Profile = profile,
});
// Ein geänderter Standort darf niemals den Wetterstand des alten Orts liefern.
Weather.DeleteMany(x => x.UserId == userId);
}
public WeatherSnapshot? GetWeather(string userId) =>
Weather.FindOne(x => x.UserId == userId)?.Snapshot;
public void SaveWeather(string userId, WeatherSnapshot snapshot)
{
var existing = Weather.FindOne(x => x.UserId == userId);
Weather.Upsert(new WeatherEntry
{
Id = existing?.Id ?? ObjectId.NewObjectId(),
UserId = userId,
Snapshot = snapshot,
});
}
public void Dispose() => _db.Dispose();
private sealed class LocationEntry
{
public ObjectId Id { get; set; } = ObjectId.NewObjectId();
public string UserId { get; set; } = "";
public SchoolLocationProfile Profile { get; set; } = new();
}
private sealed class WeatherEntry
{
public ObjectId Id { get; set; } = ObjectId.NewObjectId();
public string UserId { get; set; } = "";
public WeatherSnapshot Snapshot { get; set; } = new();
}
}
+16
View File
@@ -19,6 +19,22 @@ public class SnapshotStore(string dataPath) : IDisposable
public SnapshotUploadResponse Store(string userId, SnapshotUploadRequest req) public SnapshotUploadResponse Store(string userId, SnapshotUploadRequest req)
{ {
// Zweiter Upload-Schritt (Schlüssel nachreichen, mit dem im ersten Schritt vergebenen Code
// verschlüsselt): denselben Eintrag aktualisieren statt einen neuen mit neuem Code
// anzulegen — sonst würde der Schlüssel dauerhaft mit dem falschen Code verknüpft bleiben
// und die Entschlüsselung auf dem Empfängergerät schlägt fehl (Bug, siehe TODO.md 10.3.1).
if (!string.IsNullOrEmpty(req.Code))
{
var existing = Col.FindOne(e => e.UserId == userId && e.Code == req.Code.ToUpperInvariant());
if (existing is not null)
{
existing.EncryptedPayload = req.EncryptedPayload;
existing.EncryptedSyncKey = req.EncryptedSyncKey;
Col.Update(existing);
return new() { Code = existing.Code, ExpiresAt = existing.ExpiresAt };
}
}
Col.DeleteMany(e => e.UserId == userId); Col.DeleteMany(e => e.UserId == userId);
var code = NewCode(); var code = NewCode();
var entry = new SnapshotEntry { Code = code, UserId = userId, var entry = new SnapshotEntry { Code = code, UserId = userId,
@@ -0,0 +1,44 @@
using System.Text.Json;
using LehrerApp.Sync;
namespace LehrerApp.Api;
/// <summary>
/// Stoppt veraltete Sync-Clients vor Authentifizierung und insbesondere vor jedem Store-Zugriff.
/// Die Prüfung gilt auch für Anhänge, Plain-Sync und Device-Pairing-Snapshots.
/// </summary>
public sealed class SyncProtocolVersionMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context)
{
if (!IsProtectedSyncPath(context.Request.Path))
{
await next(context);
return;
}
context.Response.Headers[SyncProtocol.VersionHeaderName] = SyncProtocol.CurrentVersion;
var clientVersion = context.Request.Headers[SyncProtocol.VersionHeaderName].ToString();
if (string.Equals(clientVersion, SyncProtocol.CurrentVersion, StringComparison.Ordinal))
{
await next(context);
return;
}
context.Response.StatusCode = StatusCodes.Status426UpgradeRequired;
context.Response.ContentType = "application/problem+json";
await JsonSerializer.SerializeAsync(context.Response.Body, new
{
type = "https://lehrerapp.local/problems/sync-version-mismatch",
title = "Inkompatible Sync-Version",
status = StatusCodes.Status426UpgradeRequired,
detail = $"Der Server erwartet Sync-Version {SyncProtocol.CurrentVersion}.",
expectedVersion = SyncProtocol.CurrentVersion,
receivedVersion = string.IsNullOrEmpty(clientVersion) ? null : clientVersion,
});
}
private static bool IsProtectedSyncPath(PathString path) =>
path.StartsWithSegments("/api/sync", StringComparison.OrdinalIgnoreCase) ||
path.StartsWithSegments("/api/snapshot", StringComparison.OrdinalIgnoreCase);
}
+61
View File
@@ -0,0 +1,61 @@
using LiteDB;
namespace LehrerApp.Api;
public class UserStore(string dataPath) : IDisposable
{
private readonly LiteDatabase _db = new(Path.Combine(dataPath, "users.db"));
private ILiteCollection<UserEntry> Col
{
get
{
var col = _db.GetCollection<UserEntry>("users");
col.EnsureIndex(x => x.Username, unique: true);
return col;
}
}
public bool CreateUser(string username, string password)
{
if (Col.Exists(x => x.Username == username)) return false;
Col.Insert(new UserEntry { Username = username, PasswordHash = PasswordHasher.Hash(password) });
return true;
}
public bool SetPassword(string username, string password)
{
var user = Col.FindOne(x => x.Username == username);
if (user is null) return false;
user.PasswordHash = PasswordHasher.Hash(password);
Col.Update(user);
return true;
}
/// <summary>
/// Prüft die Anmeldedaten und liefert bei Erfolg den GESPEICHERTEN Nutzereintrag zurück -
/// nicht bloß true/false. Wichtig: LiteDBs Standard-Collation vergleicht Strings (und damit
/// auch den eindeutigen Index auf Username) standardmäßig case-insensitive, d.h. ein Login mit
/// abweichender Groß-/Kleinschreibung authentifiziert erfolgreich. Würde der Aufrufer daraufhin
/// den roh eingegebenen Benutzernamen als userId für den Server-seitigen Event-Speicher
/// verwenden (siehe EventStore.GetCol), würde ein einziger andersartig getippter Login auf
/// einem zweiten Gerät einen komplett getrennten, nicht überlappenden Datenbestand erzeugen,
/// obwohl es sich aus Nutzersicht um dasselbe Konto handelt. Der kanonisch gespeicherte Name
/// aus diesem Rückgabewert stellt sicher, dass die userId unabhängig von der beim Login
/// eingegebenen Schreibweise stets identisch ist.
/// </summary>
public UserEntry? Authenticate(string username, string password)
{
var user = Col.FindOne(x => x.Username == username);
return user is not null && PasswordHasher.Verify(password, user.PasswordHash) ? user : null;
}
public void Dispose() => _db.Dispose();
}
public class UserEntry
{
public ObjectId Id { get; set; } = ObjectId.NewObjectId();
public string Username { get; set; } = "";
public string PasswordHash { get; set; } = "";
}
+234
View File
@@ -0,0 +1,234 @@
namespace LehrerApp.Core.AiPlanning;
/// <summary>
/// Wire-Vertrag mit dem externen KI-Backend (siehe ai-backend/, TODO 4.5.9). Bewusst getrennt von
/// den internen Domänenmodellen in Core/Models: dieser Vertrag muss unabhängig von internen
/// Domänen-Refactors abwärtskompatibel zum deployten PHP-Backend bleiben.
/// </summary>
public class AiPlanningRequest
{
public string Instruction { get; set; } = "";
public AiUnitContext Unit { get; set; } = new();
// Steuert, ob die KI bestehende Lessons inhaltlich ändern darf, oder nur neue Lessons
// vorschlagen soll (Einheit umplanen ohne vs. mit Ändern bestehender Stunden). Wird sowohl im
// Systemprompt des Backends durchgesetzt als auch client-seitig in ApplyResponse defensiv
// geprüft — die KI-Antwort wird dafür nicht blind vertraut.
public bool AllowModifyingExistingLessons { get; set; } = true;
// Gesetzt, wenn die Anfrage aus dem Editor einer einzelnen Stunde heraus gestartet wurde
// (statt aus der Einheiten-Übersicht): die KI soll dann AUSSCHLIESSLICH diese eine Stunde
// bearbeiten, keine neuen Stunden vorschlagen und keine andere bestehende Stunde anfassen.
// Wie AllowModifyingExistingLessons sowohl im Systemprompt erbeten als auch client-seitig
// hart durchgesetzt (siehe AiAssistDialogViewModel.Send/AiPlanningService.ApplyResponse).
public Guid? FocusLessonId { get; set; }
}
public class AiUnitContext
{
public Guid Id { get; set; }
public string Title { get; set; } = "";
public DateOnly? StartDate { get; set; }
public DateOnly? EndDate { get; set; }
public List<string> Competencies { get; set; } = [];
public string? Notes { get; set; }
// Nur Kontext für die KI, wird nicht zurückerwartet.
public string SubjectName { get; set; } = "";
public int GradeLevel { get; set; }
public string GroupName { get; set; } = "";
public List<AiCompetencyDomain> CompetencyCatalog { get; set; } = [];
public List<AiAlternativePath> AlternativePathCatalog { get; set; } = [];
public List<AiLesson> Lessons { get; set; } = [];
}
public class AiCompetencyDomain
{
public string Name { get; set; } = "";
public List<AiCompetencyItem> Items { get; set; } = [];
}
public class AiCompetencyItem
{
public string Code { get; set; } = "";
public string Description { get; set; } = "";
}
public class AiAlternativePath
{
public Guid Id { get; set; }
public string Name { get; set; } = "";
}
/// <summary>
/// <see cref="Id"/> ist der einzige Unterscheidungsmechanismus neu/geändert: gesetzt und einer
/// tatsächlich im Request gesendeten Lesson zugehörig = Änderung; null oder eine unbekannte Id =
/// neue Lesson. Eine unbekannte Id wird beim Import NIE als Update einer bestehenden Lesson
/// interpretiert (siehe AiPlanningService.ApplyResponse) — sonst könnte eine halluzinierte Id im
/// schlimmsten Fall eine fremde Lesson überschreiben.
/// </summary>
public class AiLesson
{
public Guid? Id { get; set; }
public DateOnly? Date { get; set; }
public int? LessonNumber { get; set; }
public string Topic { get; set; } = "";
public TimeOnly? StartTime { get; set; }
public List<AiPhaseStep> Phases { get; set; } = [];
public string? Homework { get; set; }
public string? Reflection { get; set; }
/// Rohentwurf/erste Ideen der Lehrkraft vor der Feinplanung (siehe Lesson.PlanningIdeas) — reiner
/// Kontext für die KI wie Homework/Reflection, wird symmetrisch übernommen/zurückgegeben.
public string? PlanningIdeas { get; set; }
}
public class AiPhaseStep
{
public string Name { get; set; } = "";
public int DurationMinutes { get; set; }
public string Activity { get; set; } = "";
public string Material { get; set; } = "";
public string Shorthand { get; set; } = "";
// Name statt Guid: LLMs erfinden/verändern Guids unzuverlässig, ein Name ist beim Import
// gegen den Katalog abgleichbar (kein Treffer -> null = Hauptweg, kein Fehler).
public string? AlternativePathName { get; set; }
// Kurzer Vorschlag für ein konkretes Medium/Material zu dieser Phase (z.B. Tafelbild,
// Arbeitsblatt), nur gesetzt, wenn eines wirklich sinnvoll wäre — nicht bei jeder Phase.
// Wird NICHT in Lesson.Phases übernommen (nur für die Review-Anzeige/Prompt-Erzeugung
// relevant, siehe AiPlanningService.BuildMaterialPrompt), daher kein Feld auf
// LessonPhaseStep nötig.
public string? MaterialSuggestion { get; set; }
}
public class AiPlanningResponse
{
public List<AiLesson> Lessons { get; set; } = [];
public string? Summary { get; set; }
// Vom Backend zusätzlich mitgelieferte, unveränderte Modellantwort. Wird nur benötigt, falls
// die typisierte Deserialisierung scheitert; bei einer regulär verarbeiteten Antwort wird sie
// weder angezeigt noch gespeichert.
public string? RawResponse { get; set; }
}
/// <summary>
/// Wire-Vertrag für den "Schattenfeld"-Endpunkt (4.5.21, ai-backend/explain.php): fragt nur auf
/// Nachfrage einen didaktischen Hintergrund zu einer bereits vorgeschlagenen/geplanten Lesson ab
/// (Warum dieser Aufbau, mögliche Stolpersteine, Differenzierung) — ändert nichts an der Planung,
/// eigener Endpunkt statt Zusatzfeld in jeder plan.php-Antwort, damit nur bei tatsächlicher
/// Nutzung abgerechnet wird.
/// </summary>
public class AiExplainRequest
{
public AiUnitContext Unit { get; set; } = new();
public AiLesson Lesson { get; set; } = new();
}
public class AiExplainResponse
{
public string Explanation { get; set; } = "";
}
/// <summary>
/// Wire-Vertrag für den Gefährdungsbeurteilungs-Entwurf (ai-backend/gbu.php, Nutzerwunsch neben
/// 4.2 "Anhänge je Stunde"): erkennt aus Thema/Verlaufsplan der Lesson das Experiment und liefert
/// dafür einen strukturierten Entwurf. Eigener Endpunkt statt Zusatzfeld in plan.php/explain.php,
/// da inhaltlich unabhängig von der Unterrichtsplanung selbst.
/// </summary>
public class AiHazardAssessmentRequest
{
public AiUnitContext Unit { get; set; } = new();
public AiLesson Lesson { get; set; } = new();
}
public class AiHazardAssessmentResponse
{
/// "Lehrerversuch" | "Schuelerversuch" | "Demonstrationsversuch" (siehe ExperimentKind).
public string ExperimentKind { get; set; } = "";
public string Procedure { get; set; } = "";
public List<AiHazardSubstance> Substances { get; set; } = [];
public List<string> Hazards { get; set; } = [];
public List<string> ProtectiveMeasures { get; set; } = [];
public string FirstAid { get; set; } = "";
public string Disposal { get; set; } = "";
}
public class AiHazardSubstance
{
public string Name { get; set; } = "";
public string Amount { get; set; } = "";
/// GHS-Piktogramm-Codes als Text, z.B. "GHS05" — siehe GhsPictogram für die Zuordnung.
public List<string> GhsPictograms { get; set; } = [];
public string HStatements { get; set; } = "";
public string PStatements { get; set; } = "";
}
/// <summary>
/// Wire-Vertrag für die Chemikalien-Recherche (ai-backend/substance.php, Nutzerwunsch neben 4.2):
/// ein reiner Datenbank-Lookup gegen die lokale RiSU-Stoffliste, kein KI-Aufruf (siehe
/// ai-backend/stoffliste.php) — der Name "Ai..." bleibt trotzdem bestehen, da der Aufruf über
/// dieselbe Anmeldung/denselben Host wie die echten KI-Endpunkte läuft. Eigener, sehr schmaler
/// Wire-Vertrag statt Wiederverwendung von AiHazardSubstance — die Recherche liefert
/// zusätzliche, nur hier relevante Felder (CAS, Signalwort, Tätigkeitsbeschränkung, Quelle).
/// Enthält bewusst KEIN Entsorgungsfeld — die RiSU-Stoffliste führt keine Entsorgungshinweise.
/// </summary>
public class AiSubstanceResearchResponse
{
public string Name { get; set; } = "";
public string Cas { get; set; } = "";
public List<string> GhsPictograms { get; set; } = [];
public string SignalWord { get; set; } = "";
public string HStatements { get; set; } = "";
public string PStatements { get; set; } = "";
public string ActivityRestriction { get; set; } = "";
public string Source { get; set; } = "";
}
/// <summary>
/// Wire-Vertrag für den Fehlzeiten-Statusvorschlag (ai-backend/untis-status.php, Nutzer-Feedback
/// zum Untis-Hub: der aus WebUntis abgeleitete Zielstatus war über den Anzeigetext oft nicht
/// nachvollziehbar). Bewusst OHNE jeden Personenbezug - <see cref="AiUntisStatusRow.Id"/> ist eine
/// rein technische, für die KI bedeutungslose Kennung, über die der Desktop-Client die Antwort
/// zurückordnet; kein Name, keine Klasse, kein Datum verlässt damit die App. Mehrere Zeilen eines
/// Abgleichslaufs werden in einer Anfrage gebündelt statt je Zeile einzeln (Kosten/Latenz).
/// </summary>
public class AiUntisStatusRequest
{
public List<AiUntisStatusRow> Rows { get; set; } = [];
}
public class AiUntisStatusRow
{
public string Id { get; set; } = "";
public string ReasonText { get; set; } = "";
public int AbsentMinutes { get; set; }
/// True, wenn WebUntis für den Eintrag bereits ein Bearbeitungsdatum führt (unabhängig vom
/// tatsächlichen Datum, das aus Datenschutzgründen nicht mitgeschickt wird).
public bool HandledOn { get; set; }
/// Schulinterne Konvention: Klammerung der Entschuldigungsnummer bedeutet unentschuldigt, ohne
/// Klammern entschuldigt; null, wenn keine Nummer hinterlegt ist (siehe UntisDiffService-Analog
/// in WebUntisLessonAbsenceComparisonViewModel.MapStatus).
public bool? ExternKeyInParentheses { get; set; }
/// Bereits regelbasiert ermittelter Status (siehe MapStatus) - der Systemprompt bittet die KI,
/// nur bei eindeutigem Widerspruch im Freitext davon abzuweichen, statt bei Unsicherheit zu raten.
public string CurrentGuess { get; set; } = "";
}
/// <summary>
/// Enthält absichtlich GENAU eine Zeile je gesendeter <see cref="AiUntisStatusRow.Id"/> - der
/// Client verwirft die gesamte Antwort, wenn die zurückgegebene Id-Menge nicht exakt der
/// gesendeten entspricht (siehe AiPlanningService.RequestUntisStatusSuggestionsAsync), statt sich
/// auf die Reihenfolge zu verlassen. So bleibt eine Verwechslung zwischen Vorschlag und Zeile
/// strukturell ausgeschlossen, nicht nur im Regelfall vermieden.
/// </summary>
public class AiUntisStatusResponse
{
public List<AiUntisStatusSuggestion> Suggestions { get; set; } = [];
}
public class AiUntisStatusSuggestion
{
public string Id { get; set; } = "";
/// Einer von "Present"/"Late"/"LeftDuringClass"/"ExcusePending"/"Excused"/"Unexcused" (siehe
/// Systemprompt in ai-backend/untis-status.php) - wird client-seitig gegen genau diese Menge
/// geprüft, bevor er als AttendanceStatus interpretiert wird.
public string Status { get; set; } = "";
}
@@ -0,0 +1,77 @@
using System.Text;
namespace LehrerApp.Core.Importing;
internal sealed record DelimitedRecord(int LineNumber, IReadOnlyList<string> Fields);
internal static class DelimitedTextParser
{
public static bool TryParse(
string text,
char delimiter,
out List<DelimitedRecord> records,
out string? error)
{
records = [];
error = null;
var fields = new List<string>();
var field = new StringBuilder();
var inQuotes = false;
var line = 1;
var recordStartLine = 1;
for (var index = 0; index < text.Length; index++)
{
var current = text[index];
if (current == '"')
{
if (inQuotes && index + 1 < text.Length && text[index + 1] == '"')
{
field.Append('"');
index++;
}
else
{
inQuotes = !inQuotes;
}
continue;
}
if (current == delimiter && !inQuotes)
{
fields.Add(field.ToString());
field.Clear();
continue;
}
if ((current == '\r' || current == '\n') && !inQuotes)
{
if (current == '\r' && index + 1 < text.Length && text[index + 1] == '\n')
index++;
fields.Add(field.ToString());
field.Clear();
records.Add(new DelimitedRecord(recordStartLine, fields.ToList()));
fields.Clear();
line++;
recordStartLine = line;
continue;
}
if (current == '\n') line++;
field.Append(current);
}
if (inQuotes)
{
error = $"Ein Textfeld ab Zeile {recordStartLine} besitzt kein schließendes Anführungszeichen.";
return false;
}
if (field.Length > 0 || fields.Count > 0)
{
fields.Add(field.ToString());
records.Add(new DelimitedRecord(recordStartLine, fields.ToList()));
}
return true;
}
}
@@ -0,0 +1,210 @@
using System.Collections.ObjectModel;
namespace LehrerApp.Core.Importing;
/// <summary>
/// Stabile, typsichere Kennung eines Importformats. Werte werden normalisiert, damit
/// Groß-/Kleinschreibung oder versehentliche Leerzeichen nicht zu Tippfehlern führen.
/// </summary>
public readonly record struct ImportFormatId
{
public string Value { get; }
public ImportFormatId(string value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
Value = value.Trim().ToLowerInvariant();
}
public override string ToString() => Value ?? "";
}
/// <summary>Eine vollständig in den Speicher geladene Importdatei.</summary>
public sealed class ImportFile
{
public string FileName { get; }
public ReadOnlyMemory<byte> Content { get; }
public string Extension => Path.GetExtension(FileName).ToLowerInvariant();
public ImportFile(string fileName, ReadOnlyMemory<byte> content)
{
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
FileName = Path.GetFileName(fileName);
Content = content;
}
}
public enum ImportMessageSeverity { Information, Warning, Error }
public sealed record ImportMessage(
ImportMessageSeverity Severity,
string Code,
string Message,
string? SourceReference = null);
/// <summary>
/// Ergebnis einer schnellen Formaterkennung. 0 bedeutet „kein Treffer“, 100 einen
/// eindeutigen Treffer. Die eigentliche vollständige Prüfung erfolgt erst beim Parsen.
/// </summary>
public sealed record ImportDetection
{
public int Confidence { get; }
public string? Reason { get; }
public bool IsMatch => Confidence > 0;
public ImportDetection(int confidence, string? reason = null)
{
if (confidence is < 0 or > 100)
throw new ArgumentOutOfRangeException(nameof(confidence));
Confidence = confidence;
Reason = reason;
}
public static ImportDetection NoMatch(string? reason = null) => new(0, reason);
public static ImportDetection Match(int confidence, string? reason = null) =>
new(confidence, reason);
}
public sealed class ImportParseResult<T>
{
public IReadOnlyList<T> Items { get; }
public IReadOnlyList<ImportMessage> Messages { get; }
public bool IsValid => Messages.All(message => message.Severity != ImportMessageSeverity.Error);
public ImportParseResult(IEnumerable<T> items, IEnumerable<ImportMessage>? messages = null)
{
ArgumentNullException.ThrowIfNull(items);
Items = ReadOnly(items);
Messages = ReadOnly(messages ?? []);
}
private static IReadOnlyList<TItem> ReadOnly<TItem>(IEnumerable<TItem> values) =>
new ReadOnlyCollection<TItem>(values.ToList());
}
/// <summary>
/// Ein registrierbarer, UI-unabhängiger Handler für genau ein Dateiformat und ein
/// normalisiertes fachliches Importmodell.
/// </summary>
public interface IImportHandler<T>
{
ImportFormatId FormatId { get; }
string DisplayName { get; }
IReadOnlyCollection<string> SupportedExtensions { get; }
ValueTask<ImportDetection> DetectAsync(
ImportFile file,
CancellationToken cancellationToken = default);
ValueTask<ImportParseResult<T>> ParseAsync(
ImportFile file,
CancellationToken cancellationToken = default);
}
public sealed record DetectedImportHandler<T>(
IImportHandler<T> Handler,
ImportDetection Detection);
public abstract class ImportFormatException(string message) : Exception(message);
public sealed class UnsupportedImportFormatException(string fileName)
: ImportFormatException($"Für die Datei „{fileName}“ wurde kein passendes Importformat gefunden.");
public sealed class AmbiguousImportFormatException(string fileName, IEnumerable<string> formats)
: ImportFormatException(
$"Die Datei „{fileName}“ passt zu mehreren Importformaten: {string.Join(", ", formats)}.");
/// <summary>
/// Verwaltet Handler eines fachlichen Importziels und wählt anhand des Dateiinhalts
/// den besten Treffer. Die Dateiendung dient bei gleicher Erkennungsqualität als Hinweis.
/// </summary>
public sealed class ImportHandlerCatalog<T>
{
private readonly IReadOnlyList<IImportHandler<T>> _handlers;
public ImportHandlerCatalog(IEnumerable<IImportHandler<T>> handlers)
{
ArgumentNullException.ThrowIfNull(handlers);
var materialized = handlers.ToList();
var duplicate = materialized
.GroupBy(handler => handler.FormatId)
.FirstOrDefault(group => group.Count() > 1);
if (duplicate is not null)
throw new ArgumentException(
$"Das Importformat „{duplicate.Key}“ wurde mehrfach registriert.", nameof(handlers));
_handlers = new ReadOnlyCollection<IImportHandler<T>>(materialized);
}
public IReadOnlyList<IImportHandler<T>> Handlers => _handlers;
public IReadOnlyList<string> SupportedExtensions => _handlers
.SelectMany(handler => handler.SupportedExtensions)
.Select(NormalizeExtension)
.Where(extension => extension.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(extension => extension, StringComparer.OrdinalIgnoreCase)
.ToList()
.AsReadOnly();
public async ValueTask<DetectedImportHandler<T>> DetectAsync(
ImportFile file,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(file);
var matches = new List<Candidate>();
foreach (var handler in _handlers)
{
cancellationToken.ThrowIfCancellationRequested();
var detection = await handler.DetectAsync(file, cancellationToken).ConfigureAwait(false);
if (detection.IsMatch)
matches.Add(new Candidate(handler, detection, SupportsExtension(handler, file.Extension)));
}
if (matches.Count == 0)
throw new UnsupportedImportFormatException(file.FileName);
var highestConfidence = matches.Max(match => match.Detection.Confidence);
var best = matches.Where(match => match.Detection.Confidence == highestConfidence).ToList();
if (best.Count > 1 && best.Any(match => match.ExtensionMatches))
best = best.Where(match => match.ExtensionMatches).ToList();
if (best.Count != 1)
throw new AmbiguousImportFormatException(
file.FileName,
best.Select(match => match.Handler.DisplayName));
return new DetectedImportHandler<T>(best[0].Handler, best[0].Detection);
}
private static bool SupportsExtension(IImportHandler<T> handler, string extension) =>
handler.SupportedExtensions
.Select(NormalizeExtension)
.Contains(extension, StringComparer.OrdinalIgnoreCase);
private static string NormalizeExtension(string extension)
{
if (string.IsNullOrWhiteSpace(extension)) return "";
var trimmed = extension.Trim().ToLowerInvariant();
return trimmed.StartsWith('.') ? trimmed : $".{trimmed}";
}
private sealed record Candidate(
IImportHandler<T> Handler,
ImportDetection Detection,
bool ExtensionMatches);
}
public sealed record ImportConflictOption(string Id, string Label, string? Description = null);
public sealed record ImportConflict(
string Id,
string Title,
string Description,
string ImportedValue,
string? ExistingValue,
IReadOnlyList<ImportConflictOption> Options,
string DefaultOptionId,
string? SourceReference = null);
/// <summary>Vom UI getroffene, für den Core rein datenförmige Konfliktentscheidung.</summary>
public sealed record ImportDecision(string ConflictId, string SelectedOptionId);
@@ -0,0 +1,121 @@
using System.Text;
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Importing;
/// <summary>Importiert den tabulatorgetrennten Export „Teilnehmerliste“ eines Unterrichts.</summary>
public sealed class LessonStudentListCsvImportHandler : IImportHandler<ImportedStudent>
{
private static readonly string[] SignatureHeaders =
["Langname", "Vorname", "Klasse", "Geschlecht", "Eintrittsdatum", "Austrittsdatum",
"E-Mail Adresse", "Mobiltelefon", "Telefonnummer"];
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
public ImportFormatId FormatId => StudentImportFormats.LessonStudentListCsv;
public string DisplayName => "Unterrichts-Teilnehmerliste";
public IReadOnlyCollection<string> SupportedExtensions { get; } = [".csv"];
public ValueTask<ImportDetection> DetectAsync(ImportFile file, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (!TryDecode(file, out var text))
return ValueTask.FromResult(ImportDetection.NoMatch("Die Datei ist kein gültiges UTF-8."));
var headers = FirstLine(text).Split('\t').Select(x => x.Trim())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
return ValueTask.FromResult(SignatureHeaders.All(headers.Contains)
? ImportDetection.Match(file.Extension.Equals(".csv", StringComparison.OrdinalIgnoreCase) ? 100 : 95,
"Die charakteristischen Spalten der Unterrichts-Teilnehmerliste wurden gefunden.")
: ImportDetection.NoMatch("Die erwarteten Spalten der Unterrichts-Teilnehmerliste fehlen."));
}
public ValueTask<ImportParseResult<ImportedStudent>> ParseAsync(ImportFile file, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var messages = new List<ImportMessage>();
if (!TryDecode(file, out var text))
return Result([], [Error("lesson-students.encoding", "Die Teilnehmerliste ist nicht als gültiges UTF-8 gespeichert.")]);
if (!DelimitedTextParser.TryParse(text, '\t', out var records, out var parseError))
return Result([], [Error("lesson-students.structure", parseError ?? "Die Teilnehmerliste ist syntaktisch ungültig.")]);
if (records.Count == 0)
return Result([], [Error("lesson-students.empty", "Die Teilnehmerliste ist leer.")]);
var header = records[0].Fields.Select((name, index) => (Name: name.Trim().TrimStart('\uFEFF'), Index: index)).ToList();
var duplicate = header.GroupBy(x => x.Name, StringComparer.OrdinalIgnoreCase).FirstOrDefault(x => x.Count() > 1);
if (duplicate is not null)
messages.Add(Error("lesson-students.header.duplicate", $"Die Spalte „{duplicate.Key}“ kommt mehrfach vor.", "Kopfzeile"));
var columns = header.GroupBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
.ToDictionary(x => x.Key, x => x.First().Index, StringComparer.OrdinalIgnoreCase);
foreach (var required in SignatureHeaders.Where(x => !columns.ContainsKey(x)))
messages.Add(Error("lesson-students.header.missing", $"Die erforderliche Spalte „{required}“ fehlt.", "Kopfzeile"));
if (messages.Any(x => x.Severity == ImportMessageSeverity.Error)) return Result([], messages);
var students = new List<ImportedStudent>();
foreach (var record in records.Skip(1))
{
cancellationToken.ThrowIfCancellationRequested();
if (record.Fields.All(string.IsNullOrWhiteSpace)) continue;
var source = $"Zeile {record.LineNumber}";
if (record.Fields.Count != header.Count)
{
messages.Add(Error("lesson-students.columns",
$"Die Zeile enthält {record.Fields.Count} statt {header.Count} Spalten.", source));
continue;
}
students.Add(new ImportedStudent
{
LastName = Value(record, columns, "Langname"),
FirstName = Value(record, columns, "Vorname"),
GroupName = NullIfEmpty(Value(record, columns, "Klasse")),
Gender = ParseGender(Value(record, columns, "Geschlecht"), source, messages),
Contact = new ImportedStudentContact
{
Email = NullIfEmpty(Value(record, columns, "E-Mail Adresse")),
MobilePhone = NullIfEmpty(Value(record, columns, "Mobiltelefon")),
Phone = NullIfEmpty(Value(record, columns, "Telefonnummer")),
},
SourceReference = source,
});
}
return Result(students, messages);
}
private static Gender? ParseGender(string value, string source, ICollection<ImportMessage> messages)
{
if (string.IsNullOrWhiteSpace(value)) return null;
return value.Trim().ToLowerInvariant() switch
{
"m" or "männlich" or "maennlich" => Gender.M,
"w" or "weiblich" => Gender.W,
"d" or "divers" => Gender.D,
_ => UnknownGender(value, source, messages),
};
}
private static Gender? UnknownGender(string value, string source, ICollection<ImportMessage> messages)
{
messages.Add(new ImportMessage(ImportMessageSeverity.Warning, "lesson-students.gender.unknown",
$"Das Geschlecht „{value.Trim()}“ ist unbekannt und wird nicht übernommen.", source));
return null;
}
private static bool TryDecode(ImportFile file, out string text)
{
try { text = StrictUtf8.GetString(file.Content.Span); return true; }
catch (DecoderFallbackException) { text = ""; return false; }
}
private static string FirstLine(string text)
{
var end = text.IndexOfAny(['\r', '\n']);
return (end >= 0 ? text[..end] : text).TrimStart('\uFEFF');
}
private static string Value(DelimitedRecord row, IReadOnlyDictionary<string, int> columns, string name) =>
columns.TryGetValue(name, out var index) && index < row.Fields.Count ? row.Fields[index].Trim() : "";
private static string? NullIfEmpty(string value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static ImportMessage Error(string code, string message, string? source = null) =>
new(ImportMessageSeverity.Error, code, message, source);
private static ValueTask<ImportParseResult<ImportedStudent>> Result(
IReadOnlyList<ImportedStudent> students, IReadOnlyList<ImportMessage> messages) =>
ValueTask.FromResult(new ImportParseResult<ImportedStudent>(students, messages));
}
@@ -0,0 +1,40 @@
using System.Text;
namespace LehrerApp.Core.Importing;
/// <summary>Erkennt den Notenlistenexport und erklärt dessen fehlende Teilnehmerdaten.</summary>
public sealed class MarksPerLessonCsvImportHandler : IImportHandler<ImportedStudent>
{
private static readonly string[] SignatureHeaders =
["Datum", "Name", "Klasse", "Fach", "Prüfungsart", "Note", "Bemerkung", "Benutzer", "Schlüssel (extern)", "Gesamtnote"];
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
public ImportFormatId FormatId => StudentImportFormats.MarksPerLessonCsv;
public string DisplayName => "Unterrichts-Notenliste";
public IReadOnlyCollection<string> SupportedExtensions { get; } = [".csv"];
public ValueTask<ImportDetection> DetectAsync(ImportFile file, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
string text;
try { text = StrictUtf8.GetString(file.Content.Span); }
catch (DecoderFallbackException) { return ValueTask.FromResult(ImportDetection.NoMatch()); }
var end = text.IndexOfAny(['\r', '\n']);
var firstLine = (end >= 0 ? text[..end] : text).TrimStart('\uFEFF');
var headers = firstLine.Split('\t').Select(x => x.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase);
return ValueTask.FromResult(SignatureHeaders.All(headers.Contains)
? ImportDetection.Match(file.Extension.Equals(".csv", StringComparison.OrdinalIgnoreCase) ? 100 : 95)
: ImportDetection.NoMatch());
}
public ValueTask<ImportParseResult<ImportedStudent>> ParseAsync(ImportFile file, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
IReadOnlyList<ImportMessage> messages =
[
new(ImportMessageSeverity.Error, "marks-per-lesson.no-participants",
"Die Notenliste enthält keine verlässlichen Teilnehmerdaten. Bitte exportiere die echte Kurs-/Teilnehmerliste.")
];
return ValueTask.FromResult(new ImportParseResult<ImportedStudent>([], messages));
}
}
@@ -0,0 +1,104 @@
using System.Collections.ObjectModel;
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Importing;
public sealed record ImportedStudentContact
{
public string? Email { get; init; }
public string? MobilePhone { get; init; }
public string? Phone { get; init; }
public string? Street { get; init; }
public string? PostalCode { get; init; }
public string? City { get; init; }
public bool HasData => new[] { Email, MobilePhone, Phone, Street, PostalCode, City }
.Any(value => !string.IsNullOrWhiteSpace(value));
}
/// <summary>Formatunabhängige Schülerdaten, die ein konkreter Handler erzeugt.</summary>
public sealed record ImportedStudent
{
public required string FirstName { get; init; }
public required string LastName { get; init; }
public DateOnly? DateOfBirth { get; init; }
public Gender? Gender { get; init; }
public string? ExternalId { get; init; }
public string? GroupName { get; init; }
public ImportedStudentContact? Contact { get; init; }
public string? SourceReference { get; init; }
}
public static class StudentImportFormats
{
public static readonly ImportFormatId MasterDataCsv = new("student-master-data.csv");
public static readonly ImportFormatId LessonStudentListCsv = new("lesson-student-list.csv");
public static readonly ImportFormatId MarksPerLessonCsv = new("marks-per-lesson.csv");
}
public enum StudentImportResolutionKind { CreateNew, UseExisting, Skip }
public sealed record StudentImportResolution(
StudentImportResolutionKind Kind,
Guid? ExistingStudentId = null);
public sealed record StudentImportEntry(
string Id,
ImportedStudent Student,
StudentImportResolution? AutomaticResolution,
string? ConflictId,
IReadOnlyList<string> FieldsToSupplement);
public sealed record StudentImportGroupAssignment(
string SourceGroupName,
Guid? AutomaticGroupId,
string? ConflictId);
public sealed class StudentImportPreview
{
public ImportFormatId FormatId { get; }
public string FormatName { get; }
public IReadOnlyList<StudentImportEntry> Entries { get; }
public IReadOnlyList<ImportMessage> Messages { get; }
public IReadOnlyList<ImportConflict> Conflicts { get; }
public IReadOnlyList<StudentImportGroupAssignment> GroupAssignments { get; }
public Guid? TargetGroupId { get; }
internal string ExistingStudentsFingerprint { get; }
internal string GroupStateFingerprint { get; }
public bool CanApply =>
Entries.Count > 0 && Messages.All(message => message.Severity != ImportMessageSeverity.Error);
internal StudentImportPreview(
ImportFormatId formatId,
string formatName,
IEnumerable<StudentImportEntry> entries,
IEnumerable<ImportMessage> messages,
IEnumerable<ImportConflict> conflicts,
IEnumerable<StudentImportGroupAssignment> groupAssignments,
Guid? targetGroupId,
string existingStudentsFingerprint,
string groupStateFingerprint)
{
FormatId = formatId;
FormatName = formatName;
Entries = ReadOnly(entries);
Messages = ReadOnly(messages);
Conflicts = ReadOnly(conflicts);
GroupAssignments = ReadOnly(groupAssignments);
TargetGroupId = targetGroupId;
ExistingStudentsFingerprint = existingStudentsFingerprint;
GroupStateFingerprint = groupStateFingerprint;
}
private static IReadOnlyList<T> ReadOnly<T>(IEnumerable<T> values) =>
new ReadOnlyCollection<T>(values.ToList());
}
public sealed record StudentImportApplyResult(
int CreatedStudents,
int UpdatedStudents,
int MatchedExistingStudents,
int SkippedStudents,
int CreatedMemberships,
IReadOnlyList<Guid> CreatedStudentIds);
@@ -0,0 +1,225 @@
using System.Globalization;
using System.Text;
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Importing;
/// <summary>
/// Liest den tabulatorgetrennten Stammdatenexport mit Spalten wie „longName“,
/// „foreName“, „birthDate“ und „klasse.name“. Die Datei trägt historisch die
/// Endung .csv, obwohl als Trennzeichen ein Tabulator verwendet wird.
/// </summary>
public sealed class StudentMasterDataCsvImportHandler : IImportHandler<ImportedStudent>
{
private static readonly string[] SignatureHeaders =
["longName", "foreName", "gender", "birthDate", "klasse.name", "externKey"];
private static readonly UTF8Encoding StrictUtf8 = new(
encoderShouldEmitUTF8Identifier: false,
throwOnInvalidBytes: true);
public ImportFormatId FormatId => StudentImportFormats.MasterDataCsv;
public string DisplayName => "Schüler-Stammdatenliste";
public IReadOnlyCollection<string> SupportedExtensions { get; } = [".csv"];
public ValueTask<ImportDetection> DetectAsync(
ImportFile file,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
string text;
try
{
text = StrictUtf8.GetString(file.Content.Span);
}
catch (DecoderFallbackException)
{
return ValueTask.FromResult(ImportDetection.NoMatch("Die Datei ist kein gültiges UTF-8."));
}
var firstLineEnd = text.IndexOfAny(['\r', '\n']);
var firstLine = (firstLineEnd >= 0 ? text[..firstLineEnd] : text).TrimStart('\uFEFF');
var headers = firstLine.Split('\t')
.Select(header => header.Trim())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (!SignatureHeaders.All(headers.Contains))
return ValueTask.FromResult(ImportDetection.NoMatch("Die erwarteten Stammdatenspalten fehlen."));
return ValueTask.FromResult(ImportDetection.Match(
file.Extension.Equals(".csv", StringComparison.OrdinalIgnoreCase) ? 100 : 95,
"Die charakteristischen Stammdatenspalten wurden gefunden."));
}
public ValueTask<ImportParseResult<ImportedStudent>> ParseAsync(
ImportFile file,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var messages = new List<ImportMessage>();
string text;
try
{
text = StrictUtf8.GetString(file.Content.Span);
}
catch (DecoderFallbackException)
{
messages.Add(new ImportMessage(
ImportMessageSeverity.Error,
"master-data.encoding",
"Die Stammdatenliste ist nicht als gültiges UTF-8 gespeichert."));
return ValueTask.FromResult(new ImportParseResult<ImportedStudent>([], messages));
}
if (!DelimitedTextParser.TryParse(text, '\t', out var records, out var parseError))
{
messages.Add(new ImportMessage(
ImportMessageSeverity.Error,
"master-data.structure",
parseError ?? "Die Stammdatenliste ist syntaktisch ungültig."));
return ValueTask.FromResult(new ImportParseResult<ImportedStudent>([], messages));
}
if (records.Count == 0)
{
messages.Add(new ImportMessage(
ImportMessageSeverity.Error,
"master-data.empty",
"Die Stammdatenliste ist leer."));
return ValueTask.FromResult(new ImportParseResult<ImportedStudent>([], messages));
}
var headers = records[0].Fields
.Select((header, index) => (Name: header.Trim().TrimStart('\uFEFF'), Index: index))
.ToList();
var duplicateHeader = headers
.GroupBy(header => header.Name, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(group => group.Count() > 1);
if (duplicateHeader is not null)
messages.Add(new ImportMessage(
ImportMessageSeverity.Error,
"master-data.header.duplicate",
$"Die Spalte „{duplicateHeader.Key}“ kommt mehrfach vor.",
"Kopfzeile"));
var columns = headers
.GroupBy(header => header.Name, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First().Index, StringComparer.OrdinalIgnoreCase);
foreach (var required in SignatureHeaders.Where(required => !columns.ContainsKey(required)))
messages.Add(new ImportMessage(
ImportMessageSeverity.Error,
"master-data.header.missing",
$"Die erforderliche Spalte „{required}“ fehlt.",
"Kopfzeile"));
if (messages.Any(message => message.Severity == ImportMessageSeverity.Error))
return ValueTask.FromResult(new ImportParseResult<ImportedStudent>([], messages));
var students = new List<ImportedStudent>();
foreach (var record in records.Skip(1))
{
cancellationToken.ThrowIfCancellationRequested();
if (record.Fields.All(string.IsNullOrWhiteSpace)) continue;
var source = $"Zeile {record.LineNumber}";
if (record.Fields.Count != headers.Count)
{
messages.Add(new ImportMessage(
ImportMessageSeverity.Error,
"master-data.columns",
$"Die Zeile enthält {record.Fields.Count} statt {headers.Count} Spalten.",
source));
continue;
}
var gender = ParseGender(Value(record, columns, "gender"), source, messages);
var dateOfBirth = ParseDate(Value(record, columns, "birthDate"), source, messages);
var externalId = FirstNotEmpty(
Value(record, columns, "externKey"),
Value(record, columns, "id"));
students.Add(new ImportedStudent
{
FirstName = Value(record, columns, "foreName"),
LastName = Value(record, columns, "longName"),
Gender = gender,
DateOfBirth = dateOfBirth,
ExternalId = externalId,
GroupName = NullIfWhiteSpace(Value(record, columns, "klasse.name")),
Contact = new ImportedStudentContact
{
Email = NullIfWhiteSpace(Value(record, columns, "address.email")),
MobilePhone = NullIfWhiteSpace(Value(record, columns, "address.mobile")),
Phone = NullIfWhiteSpace(Value(record, columns, "address.phone")),
City = NullIfWhiteSpace(Value(record, columns, "address.city")),
PostalCode = NullIfWhiteSpace(Value(record, columns, "address.postCode")),
Street = NullIfWhiteSpace(Value(record, columns, "address.street")),
},
SourceReference = source,
});
}
return ValueTask.FromResult(new ImportParseResult<ImportedStudent>(students, messages));
}
private static Gender? ParseGender(
string value,
string source,
ICollection<ImportMessage> messages)
{
if (string.IsNullOrWhiteSpace(value)) return null;
return value.Trim().ToLowerInvariant() switch
{
"m" or "männlich" or "maennlich" => Gender.M,
"w" or "weiblich" => Gender.W,
"d" or "divers" => Gender.D,
_ => UnknownGender(value, source, messages),
};
}
private static Gender? UnknownGender(
string value,
string source,
ICollection<ImportMessage> messages)
{
messages.Add(new ImportMessage(
ImportMessageSeverity.Warning,
"master-data.gender.unknown",
$"Das Geschlecht „{value.Trim()}“ ist unbekannt und wird nicht übernommen.",
source));
return null;
}
private static DateOnly? ParseDate(
string value,
string source,
ICollection<ImportMessage> messages)
{
if (string.IsNullOrWhiteSpace(value)) return null;
if (DateOnly.TryParseExact(
value.Trim(),
"dd.MM.yyyy",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var date))
return date;
messages.Add(new ImportMessage(
ImportMessageSeverity.Error,
"master-data.birth-date.invalid",
$"Das Geburtsdatum „{value.Trim()}“ besitzt nicht das erwartete Format TT.MM.JJJJ.",
source));
return null;
}
private static string Value(
DelimitedRecord record,
IReadOnlyDictionary<string, int> columns,
string column) =>
columns.TryGetValue(column, out var index) && index < record.Fields.Count
? record.Fields[index].Trim()
: "";
private static string? FirstNotEmpty(params string[] values) =>
values.Select(NullIfWhiteSpace).FirstOrDefault(value => value is not null);
private static string? NullIfWhiteSpace(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
@@ -0,0 +1,17 @@
namespace LehrerApp.Core.Interfaces;
/// <summary>
/// Dateianhänge an Dokumentationseinträgen (z.B. der versendete Elternbrief als PDF).
/// Implementiert über den LiteDB-Dateispeicher — kein eigenes Dateisystem-Layout nötig.
/// </summary>
public interface IAttachmentStorage
{
/// Größte zulässige Anhangsgröße. Bewusst strikt begrenzt, damit die LiteDB-Datei
/// (und damit auch jedes Backup, 13.3.1) nicht durch große Anhänge aufgebläht wird.
const long MaxSizeBytes = 10 * 1024 * 1024;
/// Lädt den Inhalt hoch und gibt die Speicher-ID zurück (in <see cref="Models.DocumentAttachment.StorageId"/> zu speichern).
string Upload(string fileName, Stream content);
Stream? OpenRead(string storageId);
void Delete(string storageId);
}
+164
View File
@@ -7,6 +7,7 @@ public interface IStudentRepository
Student? GetById(Guid id); Student? GetById(Guid id);
List<Student> GetAll(bool includeInactive = false); List<Student> GetAll(bool includeInactive = false);
List<Student> GetByGroup(Guid groupId); List<Student> GetByGroup(Guid groupId);
StudentReferenceSummary GetReferenceSummary(Guid studentId);
void Save(Student student); void Save(Student student);
void Delete(Guid id); void Delete(Guid id);
} }
@@ -18,6 +19,16 @@ public interface IGroupRepository
void Save(LearningGroup group); void Save(LearningGroup group);
void Delete(Guid id); void Delete(Guid id);
} }
public interface ISeatingPlanRepository
{
SeatingPlan? GetById(Guid id);
List<SeatingPlan> GetByGroup(Guid groupId);
void Save(SeatingPlan plan);
void Delete(Guid id);
/// <summary>Stellt einen über <see cref="Delete"/> in den Papierkorb verschobenen Sitzplan
/// wieder her (14.3). Tut nichts, falls der Papierkorb-Eintrag nicht (mehr) existiert.</summary>
void Restore(Guid trashId);
}
public interface IGroupMembershipRepository public interface IGroupMembershipRepository
{ {
List<GroupMembership> GetByStudent(Guid studentId); List<GroupMembership> GetByStudent(Guid studentId);
@@ -29,6 +40,7 @@ public interface IGroupMembershipRepository
public interface IExamRepository public interface IExamRepository
{ {
Exam? GetById(Guid id); Exam? GetById(Guid id);
List<Exam> GetAll();
List<Exam> GetByGroup(Guid groupId); List<Exam> GetByGroup(Guid groupId);
void Save(Exam exam); void Save(Exam exam);
void Delete(Guid id); void Delete(Guid id);
@@ -48,6 +60,8 @@ public interface IGradingKeyTemplateRepository
GradingKeyTemplate? GetById(Guid id); GradingKeyTemplate? GetById(Guid id);
void Save(GradingKeyTemplate template); void Save(GradingKeyTemplate template);
void Delete(Guid id); void Delete(Guid id);
/// <summary>Siehe ISeatingPlanRepository.Restore (14.3).</summary>
void Restore(Guid trashId);
} }
public interface IGradeRepository public interface IGradeRepository
{ {
@@ -55,6 +69,8 @@ public interface IGradeRepository
List<Grade> GetByGroup(Guid groupId); List<Grade> GetByGroup(Guid groupId);
void Save(Grade grade); void Save(Grade grade);
void Delete(Guid id); void Delete(Guid id);
/// <summary>Siehe ISeatingPlanRepository.Restore (14.3).</summary>
void Restore(Guid trashId);
} }
public interface IGradingSchemeRepository public interface IGradingSchemeRepository
{ {
@@ -79,25 +95,139 @@ public interface IUnitRepository
} }
public interface ILessonRepository public interface ILessonRepository
{ {
Lesson? GetById(Guid id);
List<Lesson> GetByUnit(Guid unitId); List<Lesson> GetByUnit(Guid unitId);
List<Lesson> GetByGroupAndDate(Guid groupId, DateOnly date); List<Lesson> GetByGroupAndDate(Guid groupId, DateOnly date);
List<Lesson> GetByGroupAndRange(Guid groupId, DateOnly from, DateOnly to); List<Lesson> GetByGroupAndRange(Guid groupId, DateOnly from, DateOnly to);
void Save(Lesson lesson); void Save(Lesson lesson);
void Delete(Guid id); void Delete(Guid id);
} }
public interface ITimetableSlotRepository
{
List<TimetableSlot> GetAll();
List<TimetableSlot> GetByGroup(Guid groupId);
void Save(TimetableSlot slot);
void Delete(Guid id);
}
public interface ISchoolHolidayRepository
{
List<SchoolHoliday> GetAll();
void Save(SchoolHoliday holiday);
void Delete(Guid id);
}
public interface ISupervisionDutyRepository
{
List<SupervisionDuty> GetAll();
void Save(SupervisionDuty duty);
void Delete(Guid id);
}
public interface ISubstitutionEntryRepository
{
List<SubstitutionEntry> GetAll();
List<SubstitutionEntry> GetByDate(DateOnly date);
/// Für den WebUntis-Abgleich (idempotentes Update statt Duplikat je iCal-UID).
SubstitutionEntry? GetByExternalId(string externalId);
void Save(SubstitutionEntry entry);
void Delete(Guid id);
}
/// Zuletzt bekannter Zustand aller WebUntis-iCal-Termine, für den Abgleich bei jedem Abruf.
public interface IUntisSnapshotRepository
{
List<UntisSnapshotEntry> GetAll();
void Save(UntisSnapshotEntry entry);
void Delete(Guid id);
}
/// Lokaler Cache der Fehlzeiten-Berichtszeilen fürs Klassenlehrer-Feature (siehe TODO.md) —
/// bewusst nicht synchronisiert (siehe Implementierung), jedes Gerät ruft WebUntis selbst ab.
public interface IUntisAbsenceCacheRepository
{
List<UntisAbsenceCacheEntry> GetByClassAndRange(string className, int startDate, int endDate);
/// Löscht vorhandene Zeilen im Bereich und fügt die übergebenen neu ein — fürs "heiße" Fenster,
/// dessen Inhalt sich seit dem letzten Abruf geändert haben kann.
void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisAbsenceCacheEntry> entries);
/// Fügt nur ein, ohne zu löschen — fürs Erweitern der als endgültig angenommenen "kalten" Historie.
void InsertRange(IEnumerable<UntisAbsenceCacheEntry> entries);
}
/// Lokaler Cache der Klassenbuch-Berichtszeilen anderer Lehrkräfte fürs Klassenlehrer-Feature —
/// gleiches Muster wie <see cref="IUntisAbsenceCacheRepository"/>.
public interface IUntisClassRegisterCacheRepository
{
List<UntisClassRegisterCacheEntry> GetByClassAndRange(string className, int startDate, int endDate);
void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisClassRegisterCacheEntry> entries);
void InsertRange(IEnumerable<UntisClassRegisterCacheEntry> entries);
}
/// Lokaler Cache der aktuellen Klassenliste (Roster) fürs Klassenlehrer-Feature — anders als
/// Fehlzeiten/Klassenbuch keine Historie, deshalb kein ReplaceRange/InsertRange, sondern immer der
/// komplette Ersatz des zuletzt bekannten Standes einer Klasse.
public interface IUntisStudentRosterCacheRepository
{
List<UntisStudentRosterCacheEntry> GetByClass(string className);
void ReplaceAll(string className, IEnumerable<UntisStudentRosterCacheEntry> entries);
}
/// Merkt sich je Klasse+Berichtsart, wann das heiße Fenster zuletzt aufgefrischt wurde und wie weit
/// die kalte Historie bereits abgedeckt ist (siehe UntisReportCacheService).
public interface IUntisCacheFetchStateRepository
{
UntisCacheFetchState? Get(string className, UntisCacheKind kind);
void Save(UntisCacheFetchState state);
}
/// Zuletzt-Lauf-Status der Untis-Hub-Jobs (siehe TODO.md) - je (Kind, GroupId), GroupId null bei
/// den dashboard-weiten Jobs.
public interface IUntisHubJobStateRepository
{
UntisHubJobState? Get(UntisHubJobKind kind, Guid? groupId);
List<UntisHubJobState> GetAll();
void Save(UntisHubJobState state);
}
/// Vom Nutzer bestätigte Zuordnungen WebUntis-Wochenmuster → LearningGroup.
public interface IUntisSlotMappingRepository
{
List<UntisSlotMapping> GetAll();
void Save(UntisSlotMapping mapping);
void Delete(Guid id);
}
/// Lokal importierter, rein informativer Schuljahresplan. Bewusst getrennt von WebUntis-
/// Stundenplanabweichungen und nicht über den App-Sync repliziert; jedes Gerät bezieht denselben
/// externen Feed selbst.
public interface IAnnualPlanEventRepository
{
List<AnnualPlanEvent> GetAll();
List<AnnualPlanEvent> GetByRange(DateOnly from, DateOnly to);
AnnualPlanEvent? GetByExternalId(string externalId);
void Save(AnnualPlanEvent entry);
void Delete(Guid id);
}
public interface IDocumentationRepository public interface IDocumentationRepository
{ {
List<Documentation> GetByStudent(Guid studentId); List<Documentation> GetByStudent(Guid studentId);
List<Documentation> GetByStudentAndType(Guid studentId, DocumentationType type); List<Documentation> GetByStudentAndType(Guid studentId, DocumentationType type);
/// Alle nicht gelöschten Einträge, über alle Schüler hinweg (z.B. für Dashboard-Auswertungen).
List<Documentation> GetAll();
void Save(Documentation doc); void Save(Documentation doc);
/// Markiert den Eintrag als gelöscht, statt ihn hart zu entfernen (5.1.4).
void Delete(Guid id);
/// Entfernt den Eintrag endgültig — nur für die Löschfristen-Bereinigung (5.4.2).
void HardDelete(Guid id);
}
public interface IVorgangRepository
{
List<Vorgang> GetAll();
List<Vorgang> GetByStudent(Guid studentId);
Vorgang? GetById(Guid id);
void Save(Vorgang vorgang);
/// Markiert den Vorgang als gelöscht, statt ihn hart zu entfernen (gleiches Muster wie Documentation).
void Delete(Guid id); void Delete(Guid id);
} }
public interface IWorkTaskRepository public interface IWorkTaskRepository
{ {
List<WorkTask> GetByStatus(WorkTaskStatus status); List<WorkTask> GetByStatus(WorkTaskStatus status);
List<WorkTask> GetAll(); List<WorkTask> GetAll();
/// Für das "Anstehende Aufgaben"-Widget im Kurs-Dashboard (9.2/7.2).
List<WorkTask> GetByGroup(Guid groupId);
void Save(WorkTask task); void Save(WorkTask task);
void Delete(Guid id); void Delete(Guid id);
/// <summary>Siehe ISeatingPlanRepository.Restore (14.3).</summary>
void Restore(Guid trashId);
} }
public interface ITimeEntryRepository public interface ITimeEntryRepository
{ {
@@ -106,11 +236,16 @@ public interface ITimeEntryRepository
List<TimeEntry> GetByTask(Guid taskId); List<TimeEntry> GetByTask(Guid taskId);
void Save(TimeEntry entry); void Save(TimeEntry entry);
void Delete(Guid id); void Delete(Guid id);
/// <summary>Siehe ISeatingPlanRepository.Restore (14.3).</summary>
void Restore(Guid trashId);
} }
public interface IParticipationSessionRepository public interface IParticipationSessionRepository
{ {
List<ParticipationSession> GetByGroup(Guid groupId); List<ParticipationSession> GetByGroup(Guid groupId);
ParticipationSession? GetById(Guid id); ParticipationSession? GetById(Guid id);
// Für Aggregationen über alle Gruppen (z.B. Dashboard-Fehlzeiten-Warnung), die sonst pro
// Eintrag einzeln GetById aufrufen müssten — ein Bulk-Laden vermeidet dieses N+1-Muster.
List<ParticipationSession> GetAll();
void Save(ParticipationSession session); void Save(ParticipationSession session);
void Delete(Guid id); void Delete(Guid id);
} }
@@ -127,6 +262,9 @@ public interface IParticipationAspectRepository
{ {
List<ParticipationAspect> GetDefaults(); List<ParticipationAspect> GetDefaults();
List<ParticipationAspect> GetByGroup(Guid groupId); List<ParticipationAspect> GetByGroup(Guid groupId);
// Wie GetByGroup, aber inkl. deaktivierter Aspekte — für die Verwaltungsansicht (3.1),
// damit ein deaktivierter Aspekt dort sichtbar bleibt und wieder aktiviert werden kann.
List<ParticipationAspect> GetAllByGroup(Guid groupId);
void Save(ParticipationAspect aspect); void Save(ParticipationAspect aspect);
void Delete(Guid id); void Delete(Guid id);
} }
@@ -147,8 +285,34 @@ public interface ISubjectRepository
public interface ICompetencyDomainRepository public interface ICompetencyDomainRepository
{ {
List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel); List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel);
// Über alle Klassenstufen eines Fachs hinweg - für einen Überblick über den gesamten Katalog
// eines Fachs (z.B. MCP-Tool get_competency_catalog ohne gradeLevel-Filter), ohne dass der
// Aufrufer erst jede vorkommende Klassenstufe einzeln erraten/abfragen müsste.
List<CompetencyDomain> GetBySubject(Guid subjectId);
CompetencyDomain? GetById(Guid id); CompetencyDomain? GetById(Guid id);
void Save(CompetencyDomain domain); void Save(CompetencyDomain domain);
void Delete(Guid id); void Delete(Guid id);
void DeleteBySubjectAndGrade(Guid subjectId, int gradeLevel); void DeleteBySubjectAndGrade(Guid subjectId, int gradeLevel);
void ReplaceForSubjectAndGrade(Guid subjectId, int gradeLevel, List<CompetencyDomain> domains);
}
public interface IShorthandCodeRepository
{
List<ShorthandCode> GetAll();
void Save(ShorthandCode code);
void Delete(Guid id);
}
public interface IAlternativeLessonPathRepository
{
List<AlternativeLessonPath> GetAll();
AlternativeLessonPath? GetById(Guid id);
void Save(AlternativeLessonPath path);
void Delete(Guid id);
}
/// <summary>Papierkorb (14.3) — reine Übersicht/Aufräumen; das eigentliche Wiederherstellen läuft
/// über die jeweilige Fach-Repository (z.B. IGradeRepository.Restore), da nur die kennt, wie ein
/// Schnappschuss ihres Modells korrekt gespeichert wird (Validierung, OnChange-Ereignis).</summary>
public interface ITrashRepository
{
List<TrashedItem> GetAll();
void PurgeOlderThan(DateTime cutoffUtc);
} }
+37
View File
@@ -0,0 +1,37 @@
namespace LehrerApp.Core.Mcp;
/// <summary>
/// Named-Pipe-Konvention zwischen dem Avalonia-Hauptprozess (Pipe-Server, siehe
/// LehrerApp.Desktop/Services/Mcp) und LehrerApp.McpBridge (Pipe-Client). Bewusst als einzelne
/// geteilte Konstante statt eigener Bibliothek — die Bridge reicht JSON-RPC-Nachrichten unverändert
/// durch und braucht sonst keine gemeinsamen Typen mit der App (siehe Planungsdokument).
/// </summary>
public static class McpPipeConstants
{
public const string PipeName = "LehrerApp.Mcp";
/// <summary>
/// Unter macOS/Linux emuliert .NET Named Pipes über eine Socket-Datei unter
/// <see cref="Path.GetTempPath"/> (intern "CoreFxPipe_" + Name), und die liest dafür die
/// TMPDIR-Umgebungsvariable des jeweiligen Prozesses. LehrerApp.Desktop (von Finder/Dock
/// gestartet) bekommt das reguläre per-Login-Session-TMPDIR unter /var/folders/.../T,
/// während LehrerApp.McpBridge als Kindprozess von Claude Desktop oft eine reduzierte
/// Umgebung mit TMPDIR=/tmp erbt - beide Prozesse suchen die Pipe-Datei dann an
/// unterschiedlichen Orten und finden sich nie ("MCP nicht erreichbar, obwohl LehrerApp
/// läuft", auch wenn beide Prozesse laufen und die Pipe grundsätzlich offen ist). Fix: TMPDIR
/// für beide Prozesse hart auf denselben Ordner setzen, bevor die erste
/// NamedPipeServerStream/-ClientStream-Instanz entsteht - dieselbe ApplicationData-Basis wie
/// AppBootstrapper (siehe dort) wird anders als TMPDIR zuverlässig an Kindprozesse
/// weitergereicht. Muss als eine der ersten Anweisungen in Main aufgerufen werden (Desktop wie
/// Bridge), auf Windows ein No-op (dort nutzen Named Pipes den Kernel-Namespace, keine
/// Socket-Datei).
/// </summary>
public static void EnsureStableUnixSocketDirectory()
{
if (OperatingSystem.IsWindows()) return;
var dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"LehrerApp", "ipc");
Directory.CreateDirectory(dir);
Environment.SetEnvironmentVariable("TMPDIR", dir);
}
}
+37
View File
@@ -0,0 +1,37 @@
namespace LehrerApp.Core.Models;
/// <summary>
/// Ein aus dem schulweiten Jahresplan importierter Termin. Anders als <see cref="TimetableSlot"/>
/// und <see cref="SubstitutionEntry"/> ist er reine Kalenderinformation: Er verändert weder den
/// regulären Stundenplan noch erzeugt er Vertretungen oder Ausfälle.
/// </summary>
public class AnnualPlanEvent
{
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>Stabile UID aus dem iCal-Feed; fachlicher Schlüssel für idempotente Importe.</summary>
public string ExternalId { get; set; } = "";
public string Title { get; set; } = "";
public string Description { get; set; } = "";
public string Location { get; set; } = "";
/// <summary>Erster beziehungsweise letzter tatsächlich belegter lokaler Kalendertag.</summary>
public DateOnly StartDate { get; set; }
public DateOnly EndDate { get; set; }
/// <summary>
/// Bei Ganztagsterminen null. Zeitgebundene UTC-Werte aus dem Feed werden beim Import in die
/// lokale Schulzeit Europe/Berlin umgerechnet.
/// </summary>
public TimeOnly? StartTime { get; set; }
public TimeOnly? EndTime { get; set; }
public bool IsAllDay { get; set; }
/// <summary>ClassyPlan-Gruppe, z.B. "Lehrkräfte" oder "Öffentlich".</summary>
public string CalendarGroup { get; set; } = "";
public string Color { get; set; } = "";
public string Status { get; set; } = "CONFIRMED";
public int Sequence { get; set; }
public DateTime? SourceLastModifiedUtc { get; set; }
}
+6
View File
@@ -13,6 +13,12 @@ public class Exam
public DateOnly? ReturnedAt { get; set; } public DateOnly? ReturnedAt { get; set; }
public Niveau? Niveau { get; set; } public Niveau? Niveau { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
public DateOnly? ApprovalGrantedAt { get; set; }
public DateOnly? AnnouncedAt { get; set; }
/// Verknüpft Parallelklausuren (gleiche Arbeit, mehrere Kurse) für den Kurs-Umschalter
/// auf der Klausuren-Hauptseite — Id der "Ursprungs"-Klausur, gesetzt beim Duplizieren
/// (siehe ExamDialogViewModel.Save).
public Guid? SharedExamGroupId { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
} }
+62
View File
@@ -0,0 +1,62 @@
namespace LehrerApp.Core.Models;
/// <summary>
/// Gefährdungsbeurteilung zu einem Experiment (fachspezifisch, v.a. Chemie/Naturwissenschaften).
/// Lebt NICHT als eigene LiteDB-Collection/Repository, sondern wird als JSON serialisiert und
/// über die bestehende <see cref="DocumentAttachment"/>-Infrastruktur an eine <see cref="Lesson"/>
/// angehängt (Dateiname endet auf ".gbu.json", siehe TODO.md 4.2) — das reicht für Bearbeiten,
/// Anzeigen und PDF-Export, ohne eine weitere synchronisierte Entität samt Repository/DI-Wiring
/// einzuführen (bewusste Scope-Entscheidung, siehe TODO.md).
/// </summary>
public class HazardAssessment
{
public string Title { get; set; } = "";
/// Klassenstufe/Kurs, z.B. "10c" — vorbelegt aus der zugehörigen Lerngruppe, frei änderbar.
public string GroupLabel { get; set; } = "";
public DateOnly? Date { get; set; }
public ExperimentKind Kind { get; set; } = ExperimentKind.Lehrerversuch;
/// Kurzbeschreibung der Durchführung.
public string Procedure { get; set; } = "";
public List<HazardSubstance> Substances { get; set; } = [];
public List<string> Hazards { get; set; } = [];
public List<string> ProtectiveMeasures { get; set; } = [];
public string FirstAid { get; set; } = "";
public string Disposal { get; set; } = "";
public string Notes { get; set; } = "";
/// Ob ein KI-Entwurf beteiligt war — steuert den Rechtssicherheits-Hinweis im PDF-Export.
public bool IsAiAssisted { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class HazardSubstance
{
public string Name { get; set; } = "";
/// CAS-Nummer, falls über die Chemikalien-Recherche (TODO.md 4.2-Nachtrag) ermittelt oder von
/// Hand eingetragen — optional, nur zur eindeutigeren Wiedererkennung/Recherche.
public string Cas { get; set; } = "";
/// Typische Einsatzmenge, z.B. "ca. 2 g" — Freitext statt strukturierter Einheit, da der
/// Rahmen (fest/Lösung/Gas, Konzentration ...) zu unterschiedlich für ein starres Feld ist.
public string Amount { get; set; } = "";
public List<GhsPictogram> GhsPictograms { get; set; } = [];
/// "Gefahr" oder "Achtung" (GHS-Signalwort) — Freitext statt Enum, da GESTIS-Recherchen auch
/// mal ohne eindeutiges Signalwort zurückkommen können.
public string SignalWord { get; set; } = "";
/// H-Sätze (Gefahrenhinweise) als Freitext, z.B. "H314, H290" oder mit Kurztext — bewusst kein
/// eingebauter Katalog amtlicher H-/P-Satz-Texte in der App (Fehlerrisiko bei
/// sicherheitsrelevanten Angaben), die Lehrkraft prüft/ergänzt gegen das Sicherheitsdatenblatt.
public string HStatements { get; set; } = "";
public string PStatements { get; set; } = "";
/// Tätigkeitsbeschränkung laut RiSU-Stoffliste (Freigabe für alle/Schulversuche/nur
/// Lehrkraft, Altersgrenzen, Ersatzstoffprüfung nötig, verboten, ...) — Freitext, da die
/// tatsächlichen Formulierungen zu unterschiedlich für ein starres Feld/Enum sind.
public string ActivityRestriction { get; set; } = "";
}
public enum ExperimentKind { Lehrerversuch, Schuelerversuch, Demonstrationsversuch }
/// Die neun GHS-Gefahrenpiktogramme (Global Harmonisiertes System).
public enum GhsPictogram
{
Explosive, Flammable, Oxidizing, CompressedGas, Corrosive,
Toxic, Harmful, HealthHazard, Environmental,
}
+28
View File
@@ -13,6 +13,34 @@ public class LearningGroup
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
public bool IsOwnClass { get; set; } public bool IsOwnClass { get; set; }
public bool IsDifferentiated { get; set; } public bool IsDifferentiated { get; set; }
/// <summary>
/// Default true (alte Datensätze ohne dieses Feld verhalten sich damit unverändert wie bisher
/// - LiteDB liefert den C#-Default für fehlende Felder). Für Gruppen wie Klassenrat oder
/// Willkommenskreis, die keinen inhaltlichen Verlaufsplan brauchen, in den Stammdaten
/// deaktivierbar - blendet die Erinnerung "Ungeplante Stunden" im Dashboard für diese Gruppe aus.
/// </summary>
public bool RequiresLessonPlanning { get; set; } = true;
/// <summary>
/// WebUntis-interne Unterrichtsnummer (lsid) dieser Lerngruppe, Grundlage für den Abruf des
/// "Fehlzeiten pro Unterricht"-Berichts. Wird von WebUntis pro Schuljahr neu vergeben und muss
/// deshalb händisch je Lerngruppe/Schuljahr gepflegt werden - keine JSON-RPC-Methode liefert sie.
/// </summary>
public int? WebUntisLessonId { get; set; }
/// <summary>
/// Blendet diese Lerngruppe im UntisHub (siehe <see cref="Services"/>-Layer im Desktop-Projekt:
/// UntisHubService) aus, auch wenn <see cref="WebUntisLessonId"/> gesetzt ist - z.B. für
/// Klassenrat/AGs, die zwar eine WebUntis-Unterrichtsnummer haben, aber nicht auf
/// Fehlzeiten/offene Periods hin überwacht werden sollen. Default false (alte Datensätze
/// verhalten sich unverändert wie bisher).
/// </summary>
public bool ExcludedFromUntisHub { get; set; }
/// <summary>
/// Vorgängergruppe aus dem Hochstufen (<see cref="Services.GroupRolloverService"/>) —
/// ermöglicht einen Schuljahresvergleich (z.B. Klausurschnitt) über die Kette hinweg.
/// Wird ausschließlich beim Hochstufen automatisch gesetzt; vor Einführung dieses Felds
/// hochgestufte Gruppen bleiben unverknüpft (kein rückwirkendes Verknüpfen vorgesehen).
/// </summary>
public Guid? PreviousGroupId { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
} }
+88
View File
@@ -24,9 +24,26 @@ public class ParticipationEntry
// Bleibt für bereits gespeicherte Daten erhalten. Neue Schreibvorgänge setzen beide Felder. // Bleibt für bereits gespeicherte Daten erhalten. Neue Schreibvorgänge setzen beide Felder.
public bool HomeworkMissing { get; set; } public bool HomeworkMissing { get; set; }
public AttendanceStatus? Attendance { get; set; } public AttendanceStatus? Attendance { get; set; }
// Strichliste im Sitzplan (Nutzer-Feedback): RaisedHandCount zählt jede beobachtete Meldung,
// CalledOnCount die Teilmenge davon, bei der der Schüler auch drangekommen ist —
// "drangekommen" ist immer auch eine Meldung und erhöht daher immer beide Zähler zugleich,
// nie CalledOnCount allein. Ergibt nebenbei eine Aufrufgerechtigkeits-Quote
// (CalledOnCount / RaisedHandCount) und schlägt die Quantitätsbewertung vor, siehe
// ParticipationCountSuggestion.
public int RaisedHandCount { get; set; }
public int CalledOnCount { get; set; }
/// <summary>
/// Tagesflagge (Nutzer-Feedback): markiert eine session-bezogene Bewertung als besonders
/// herausragend oder besonders schwach, unabhängig von der Richtung ("egal in welche
/// Richtung, herausragend festhalten"). Rein deskriptiv — fließt nirgends in eine Berechnung
/// (Mitarbeitsnote 3.2, Aufrufgerechtigkeit) ein, nur zur Erinnerung/Dokumentation.
/// </summary>
public DayHighlightKind? DayHighlight { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
} }
public enum DayHighlightKind { Standout, Sleepy, Rough }
/// <summary> /// <summary>
/// Hausaufgabenstatus einer Sitzung; null bedeutet, dass für diesen Termin keine /// Hausaufgabenstatus einer Sitzung; null bedeutet, dass für diesen Termin keine
/// Hausaufgabe erfasst wurde. MissingOpen kann später in MissingOverdue oder SubmittedLate /// Hausaufgabe erfasst wurde. MissingOpen kann später in MissingOverdue oder SubmittedLate
@@ -60,6 +77,11 @@ public enum AttendanceStatus
OtherSchoolEvent, OtherSchoolEvent,
// Hinten angefügt, damit die numerischen Werte bereits gespeicherter LiteDB-Einträge stabil bleiben. // Hinten angefügt, damit die numerischen Werte bereits gespeicherter LiteDB-Einträge stabil bleiben.
Present, Present,
Late,
SignificantlyLate,
LeftDuringClass,
LearningIsland,
Suspended,
} }
/// <summary> /// <summary>
@@ -96,6 +118,9 @@ public class ParticipationAspect
public string Key { get; set; } = ""; public string Key { get; set; } = "";
public string Label { get; set; } = ""; public string Label { get; set; } = "";
public AspectValueType ValueType { get; set; } = AspectValueType.Scale5; public AspectValueType ValueType { get; set; } = AspectValueType.Scale5;
// Nur bei ValueType.Points relevant: die Rohbewertung ist dann ein echter Punktwert
// 0..MaxPoints (nicht auf der -2..+2-Achse), siehe ParticipationRatingScale.
public int MaxPoints { get; set; } = 5;
public double Weight { get; set; } = 1.0; public double Weight { get; set; } = 1.0;
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
public int SortOrder { get; set; } public int SortOrder { get; set; }
@@ -113,3 +138,66 @@ public static class DefaultParticipationAspects
new() { Key = "workphase", Label = "Arbeitsphase", ValueType = AspectValueType.Scale5, SortOrder = 2 }, new() { Key = "workphase", Label = "Arbeitsphase", ValueType = AspectValueType.Scale5, SortOrder = 2 },
]; ];
} }
/// <summary>
/// Definiert je <see cref="AspectValueType"/>, welche Rohwerte eine Bewertung annehmen kann und
/// wie sie sich auf die gemeinsame Qualitätsachse (-2..+2, wie <see cref="AspectValueType.Scale5"/>)
/// abbilden, auf der die Gewichtung/Mittelwertbildung zur Mitarbeitsnote (3.2) rechnet.
/// Scale3/Binary liegen bewusst direkt AUF dieser Achse (nur mit weniger Zwischenschritten)
/// statt einer eigenen Wertemenge zu bekommen — dadurch bleibt die bestehende Aggregation
/// unverändert kompatibel, ohne pro Bewertung erst umrechnen zu müssen. Nur "Points" hat eine
/// eigene, von der Lehrkraft festgelegte Obergrenze (<see cref="ParticipationAspect.MaxPoints"/>)
/// und wird für die Aggregation linear auf dieselbe Achse normiert (0 → -2, MaxPoints → +2).
/// </summary>
public static class ParticipationRatingScale
{
/// Feste Rohwert/Label-Paare für Typen mit einer kleinen, festen Stufenzahl — Grundlage für
/// Raster-Buttons und Schnelleingabe-Zifferntasten. Leer für Points (freie Zahleneingabe).
public static IReadOnlyList<(int Value, string Label)> Steps(AspectValueType type) => type switch
{
AspectValueType.Scale5 => [(-2, "−−"), (-1, ""), (0, ""), (1, "+"), (2, "++")],
AspectValueType.Scale3 => [(-2, ""), (0, ""), (2, "+")],
AspectValueType.Binary => [(-2, "Nein"), (2, "Ja")],
_ => [],
};
public static string DisplayLabel(AspectValueType type, int? value)
{
if (value is null) return "·";
if (type == AspectValueType.Points) return value.Value.ToString();
var match = Steps(type).FirstOrDefault(s => s.Value == value.Value);
return match.Label ?? "·";
}
/// Bildet einen Rohwert auf die gemeinsame -2..+2-Achse ab, für die Mittelwertbildung in 3.2.
public static double Normalize(AspectValueType type, int value, int maxPoints) => type switch
{
AspectValueType.Points when maxPoints > 0 => Math.Clamp(value, 0, maxPoints) / (double)maxPoints * 4.0 - 2.0,
AspectValueType.Points => 0, // keine sinnvolle Obergrenze konfiguriert -> neutral werten
_ => value,
};
}
/// <summary>
/// Nutzer-Feedback: die Strichliste im Sitzplan (<see cref="ParticipationEntry.RaisedHandCount"/>)
/// schlägt direkt eine Quantitätsbewertung vor — wie oft sich jemand meldet, ist ja bereits die
/// Quantität. Nur ein Richtwert, kein fester Automatismus: die Lehrkraft übernimmt den Vorschlag
/// per Klick oder ignoriert ihn. Bewusst nur für <see cref="AspectValueType.Scale5"/> (der
/// Standardtyp des "quantity"-Aspekts) — für individuell umkonfigurierte Aspekttypen gäbe es keine
/// sinnvolle, unmissverständliche Abbildung.
/// </summary>
public static class ParticipationCountSuggestion
{
public static int? SuggestQuantity(AspectValueType type, int raisedHandCount)
{
if (type != AspectValueType.Scale5) return null;
return raisedHandCount switch
{
0 => -2,
1 => -1,
2 or 3 => 0,
4 or 5 => 1,
_ => 2,
};
}
}
+203 -5
View File
@@ -43,7 +43,7 @@ public class Unit
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
} }
public class Lesson public class Lesson : IHasAttachments
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
public Guid UnitId { get; set; } public Guid UnitId { get; set; }
@@ -51,16 +51,196 @@ public class Lesson
public DateOnly Date { get; set; } public DateOnly Date { get; set; }
public int? LessonNumber { get; set; } public int? LessonNumber { get; set; }
public string Topic { get; set; } = ""; public string Topic { get; set; } = "";
public string? Phase { get; set; } /// Optionaler Stundenbeginn — solange kein Stundenplan (Kapitel 4.3) existiert, manuell
public List<string> Methods { get; set; } = []; /// gepflegt. Dient nur der abgeleiteten Uhrzeit-Anzeige je Phase in <see cref="Phases"/>.
public List<string> Materials { get; set; } = []; public TimeOnly? StartTime { get; set; }
/// Grobe Ideen/erster Entwurf, festgehalten lange bevor der Verlaufsplan (<see cref="Phases"/>)
/// feingeplant wird (Nutzer-Feedback: die eigentliche Ideenfindung liegt zeitlich oft weit vor
/// der Feinplanung) — bewusst ein eigenes Feld statt Zweckentfremdung von <see cref="Reflection"/>
/// (die ist nach der Stunde) oder <see cref="Unit.Notes"/> (die ist auf Einheitenebene, nicht je
/// Stunde). Wird auch der KI-Planungsunterstützung (4.5.9) als Kontext mitgegeben, damit erste
/// eigene Ideen bei der KI-gestützten Weiterplanung nicht erneut abgetippt werden müssen.
public string? PlanningIdeas { get; set; }
public List<LessonPhaseStep> Phases { get; set; } = [];
public TeachingTimelineState? TeachingTimeline { get; set; }
public string? Homework { get; set; } public string? Homework { get; set; }
/// Markiert, dass die hier eingetragene Hausaufgabe in einer Folgestunde besprochen/kontrolliert
/// wurde — treibt das Stundenplan-Badge "Hausaufgabe kontrollieren" (4.5.4).
public bool HomeworkChecked { get; set; }
/// Bewusstes Abschalten der Erinnerung, ohne eine tatsächliche Kontrolle vorzutäuschen (z.B.
/// wenn die Hausaufgabe absichtlich nicht mehr kontrolliert wird).
public bool HomeworkCheckDismissed { get; set; }
public string? Reflection { get; set; } public string? Reflection { get; set; }
/// Kompetenzcodes (<see cref="CompetencyItem.Code"/>) aus dem Katalog, die dieser Stunde als
/// Ganzes zugeordnet sind — analog zu <see cref="Unit.Competencies"/>, aber auf Stundenebene statt
/// Einheitenebene. Bewusst noch keine Verknüpfung je einzelner <see cref="LessonPhaseStep"/>
/// (siehe TODO.md 4.5.8, weiterhin offen); diese Liste deckt nur die gröbere Zuordnung ab.
public List<string> Competencies { get; set; } = [];
public LessonStatus Status { get; set; } = LessonStatus.Planned; public LessonStatus Status { get; set; } = LessonStatus.Planned;
/// Material/Arbeitsblätter sowie fachspezifische Anhänge (z.B. Experiment- und
/// Gefährdungsbeurteilungs-Dokumente im Chemieunterricht) — dieselbe Anhang-Infrastruktur wie
/// bei <see cref="Documentation"/>.
public List<DocumentAttachment> Attachments { get; set; } = [];
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
} }
/// <summary>
/// Eine Zeile im tabellarischen Stundenverlaufsplan (Phase/Dauer/Tätigkeit/Material/Kurzsymbol).
/// <see cref="DurationMinutes"/> ist die primäre Eingabe (man plant in Zeitblöcken); die Uhrzeit
/// je Phase wird im Editor daraus abgeleitet, wenn <see cref="Lesson.StartTime"/> gesetzt ist.
/// <see cref="Shorthand"/> ist bewusst ein einzelnes Freitextfeld statt einer erzwungenen
/// Von/Nach-Struktur: manchmal ist es ein Materialfluss-Pfeil ("AB001->S"), manchmal nur eine
/// Sozialform ohne Pfeil ("Plenum", "LDE"). Vorschläge kommen sowohl aus dem in den Einstellungen
/// gepflegten Kürzel-Katalog (<see cref="ShorthandCode"/>) als auch aus bereits in anderen Stunden
/// verwendeten Werten.
/// </summary>
public class LessonPhaseStep
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = "";
public int DurationMinutes { get; set; }
public string Activity { get; set; } = "";
public string Material { get; set; } = "";
public string Shorthand { get; set; } = "";
/// Der über <see cref="AiPlanning.AiPlanningService.BuildMaterialPrompt"/> erzeugte, vollständige
/// Prompt für diese Phase (4.5.20), sofern die KI beim letzten "Übernehmen" einen Medienvorschlag
/// gemacht hatte — anders als der reine Vorschlagstext (<c>AiPhaseStep.MaterialSuggestion</c>,
/// nur transient während der Review) bewusst hier persistiert (Nutzer-Feedback: der Prompt soll
/// auch nach dem Übernehmen noch abrufbar/erneut kopierbar bleiben, statt nur einmalig im
/// Review-Dialog verfügbar zu sein). Rein informativ, kein Datenmodell-Bezug zu einem separaten
/// "Material"-Konzept, das es weiterhin nicht gibt — siehe TODO.md 4.5.26.
public string? MaterialPrompt { get; set; }
/// <summary>
/// null = Hauptweg. Sonst Verweis auf einen benannten <see cref="AlternativeLessonPath"/> aus
/// dem Katalog, über den Phasen alternativer Unterrichtsverläufe zusammengehören —
/// Verlaufsplan-Editor und -Ansicht gruppieren danach. Bewusst kein Fork-Punkt-Bezug zum
/// Hauptweg: eine Alternative beginnt in der Anzeige unabhängig ab dem Stundenbeginn, nicht ab
/// einer gemeinsamen Verzweigungsstelle — für den schnellen Überblick ausreichend, siehe TODO.md.
/// </summary>
public Guid? AlternativePathId { get; set; }
}
public enum UnitStatus { Planned, Active, Completed } public enum UnitStatus { Planned, Active, Completed }
public enum LessonStatus { Planned, Conducted } // Planned=0 und Conducted=1 bleiben absichtlich an ihren bisherigen numerischen Positionen:
// LiteDB hat diese Werte bereits gespeichert. Die neuen Zustände werden nur angehängt, damit
// vorhandene Daten ohne Migration weiterhin korrekt gelesen werden.
public enum LessonStatus { Planned = 0, Conducted = 1, Draft = 2, Ready = 3, Cancelled = 4 }
/// <summary>
/// Katalogeintrag für einen wiederverwendbaren "alternativen Ablauf" (z.B. "Kurzversion" bei
/// Zeitmangel), dem einzelne <see cref="LessonPhaseStep"/>-Zeilen über
/// <see cref="LessonPhaseStep.AlternativePathId"/> zugeordnet werden. Wird direkt beim Zuweisen im
/// Verlaufsplan-Editor angelegt (kein separates Verwaltungs-UI in den Einstellungen bisher).
/// </summary>
public class AlternativeLessonPath
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = "";
/// Freitext, wann/wofür dieser Ablauf gedacht ist, z.B. "Bei Zeitmangel, wenn Aufgabe 3 nicht
/// mehr passt." Absichtlich kein strukturiertes Bedingungsfeld (z.B. "wenn an Stelle X weniger
/// als Y Minuten übrig sind") — das setzt eine Live-Zeiterfassung während des Unterrichtens
/// voraus, die es noch nicht gibt (siehe Live-Unterrichtsmodus-Ideensammlung in TODO.md).
public string? Description { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
/// <summary>
/// Ein fester Termin im wöchentlichen Stundenplan (4.3): eine Lerngruppe trifft sich an einem
/// Wochentag zu einer bestimmten Stunde. Wiederkehrendes Muster, kein konkretes Datum — für
/// tatsächlich gehaltene Einzelstunden siehe <see cref="Lesson"/>. Pro Wochentag/Stunde ist
/// höchstens eine Gruppe eingetragen (ein Lehrer kann nicht gleichzeitig an zwei Orten sein).
/// </summary>
public class TimetableSlot
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid GroupId { get; set; }
public DayOfWeek Weekday { get; set; }
public int PeriodNumber { get; set; }
public string? Room { get; set; }
}
/// <summary>
/// Unterrichtsfreier Zeitraum (4.3.5): Schulferien werden manuell gepflegt (nicht algorithmisch
/// herleitbar, jährlich neu von den Bundesländern festgelegt). Gesetzliche Feiertage werden
/// dagegen berechnet (PublicHolidayService) und nicht in der Datenbank gespeichert.
/// </summary>
public class SchoolHoliday
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = "";
public DateOnly StartDate { get; set; }
public DateOnly EndDate { get; set; }
}
public enum GermanState
{
BW, BY, BE, BB, HB, HH, HE, MV, NI, NW, RP, SL, SN, ST, SH, TH
}
/// <summary>
/// Wiederkehrende Pausenaufsicht (kein Bezug zu einer Lerngruppe, anders als
/// <see cref="TimetableSlot"/>). <see cref="AfterPeriod"/> = 0 bedeutet "vor der 1. Stunde"
/// (Frühaufsicht), sonst "in der Pause nach dieser Stunde". Pro Wochentag/Pause höchstens eine
/// Aufsicht (ein Lehrer kann nicht an zwei Orten gleichzeitig Aufsicht führen).
/// </summary>
public class SupervisionDuty
{
public Guid Id { get; set; } = Guid.NewGuid();
public DayOfWeek Weekday { get; set; }
public int AfterPeriod { get; set; }
public string Location { get; set; } = "";
}
public enum SubstitutionKind { Supervision, Lesson, SpecialAssignment, Cancelled }
/// <summary>
/// Einmalige Ausnahme vom regulären Plan an einem konkreten Datum: eine Vertretungsaufsicht
/// (zusätzlich zur oder anstelle einer regulären <see cref="SupervisionDuty"/>), eine
/// Vertretungsstunde in einer eigenen oder fremden Lerngruppe, ein Sondereinsatz (Ausflug,
/// Berufsmesse, Exkursion, ...), der einen Teil des Tages oder den ganzen Tag blockiert, oder ein
/// schlichter Stundenausfall ohne Ersatz (z.B. weil die betroffene Gruppe selbst auf Klassenfahrt
/// ist). Bewusst getrennt von <see cref="Lesson"/>: die meisten dieser Einträge sind nicht Teil der
/// durchgeplanten Einheiten-Reihenfolge (siehe TODO.md, Nachtrag zu 4.3) — nur bei
/// <see cref="SubstitutionKind.Lesson"/> kann beim Anlegen ausdrücklich "als Stunde in der Einheit
/// übernehmen" gewählt werden, was zusätzlich eine echte <see cref="Lesson"/> erzeugt; dieser
/// Eintrag hier bleibt trotzdem bestehen, da er (unabhängig von der Einheiten-Fortschrittsanzeige)
/// das, was tatsächlich im Plan an diesem Tag stattfand, festhält.
/// </summary>
public class SubstitutionEntry
{
public Guid Id { get; set; } = Guid.NewGuid();
public DateOnly Date { get; set; }
public SubstitutionKind Kind { get; set; }
/// Bei <see cref="SubstitutionKind.Lesson"/> und <see cref="SubstitutionKind.Cancelled"/>
/// gesetzt — bei Cancelled wird Gruppe/Fach zur Anzeige aus dem an dieser Stelle regulär
/// eingetragenen <see cref="TimetableSlot"/> abgeleitet, nicht hier gespeichert.
public int? PeriodNumber { get; set; }
/// Nur bei <see cref="SubstitutionKind.Supervision"/> gesetzt — wie bei
/// <see cref="SupervisionDuty.AfterPeriod"/>, 0 = vor der 1. Stunde.
public int? AfterPeriod { get; set; }
/// Nur bei <see cref="SubstitutionKind.SpecialAssignment"/> und nicht <see cref="IsAllDay"/>:
/// der belegte Stundenbereich (inklusive).
public int? FromPeriod { get; set; }
public int? ToPeriod { get; set; }
/// Nur bei <see cref="SubstitutionKind.SpecialAssignment"/>: blockiert den ganzen Tag statt
/// nur <see cref="FromPeriod"/><see cref="ToPeriod"/>.
public bool IsAllDay { get; set; }
/// Gesetzt, wenn es sich um eine eigene Lerngruppe handelt — bei fremden/unbekannten Gruppen
/// oder Sondereinsätzen ohne Gruppenbezug (z.B. Berufsmesse) bleibt es null und nur
/// <see cref="GroupLabel"/> beschreibt ggf., um welche Gruppe es ging.
public Guid? GroupId { get; set; }
public string GroupLabel { get; set; } = "";
/// Thema (bei Lesson), Grund/Ort (bei Supervision, z.B. "Vertretung für Hr. Müller") bzw.
/// Bezeichnung (bei SpecialAssignment, z.B. "Ausflug ins Museum", "Berufsmesse").
public string Description { get; set; } = "";
public string? Notes { get; set; }
/// Gesetzt, wenn dieser Eintrag automatisch aus dem WebUntis-iCal-Abgleich entstanden ist —
/// trägt die stabile iCal-UID des auslösenden Termins. Macht wiederholte Abgleich-Läufe
/// idempotent (Update statt Duplikat, siehe ISubstitutionEntryRepository.GetByExternalId) und
/// unterscheidet automatisch erzeugte von von Hand eingetragenen Ausnahmen. Bei Handeinträgen
/// (SubstitutionEntryDialogViewModel) bleibt es null.
public string? ExternalId { get; set; }
}
/// <summary> /// <summary>
/// Zeugnisnote eines Schülers in einer Lerngruppe für einen Zeitraum (Halbjahr/Gesamtjahr). /// Zeugnisnote eines Schülers in einer Lerngruppe für einen Zeitraum (Halbjahr/Gesamtjahr).
@@ -80,3 +260,21 @@ public class ReportGrade
public bool IsLocked { get; set; } public bool IsLocked { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
} }
/// <summary>Live timing is separate from the original lesson plan.</summary>
public class TeachingTimelineState
{
public DateTime StartUtc { get; set; }
public DateTime EndUtc { get; set; }
public DateTime? HeldSinceUtc { get; set; }
public Guid? HeldPhaseId { get; set; }
public List<TeachingPhaseTiming> Phases { get; set; } = [];
public List<Guid> TransferredPhaseIds { get; set; } = [];
}
public class TeachingPhaseTiming
{
public Guid PhaseId { get; set; }
public double Minutes { get; set; }
public bool ExplicitlyStarted { get; set; }
}
+48
View File
@@ -0,0 +1,48 @@
namespace LehrerApp.Core.Models;
/// <summary>
/// Ein Sitzplan einer Lerngruppe. Eine Gruppe kann mehrere Pläne besitzen,
/// beispielsweise für unterschiedliche Unterrichtsräume.
/// </summary>
public class SeatingPlan
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid GroupId { get; set; }
public string Name { get; set; } = "";
public string Room { get; set; } = "";
public int Rows { get; set; } = 4;
public int Columns { get; set; } = 4;
/// <summary>
/// Zeigt die Tafel unterhalb des Sitzrasters an. Der Standardwert <c>false</c>
/// erhält für bestehende Pläne die bisherige Darstellung oberhalb des Rasters.
/// </summary>
public bool IsBoardAtBottom { get; set; }
/// <summary>
/// Zusätzlicher horizontaler Abstand nach jeder Spalte, in Pixeln. Der Eintrag mit Index 0
/// liegt zwischen der ersten und zweiten Spalte. Fehlende Einträge bedeuten keinen Abstand.
/// </summary>
public List<double> ColumnGapWidths { get; set; } = [];
public List<SeatAssignment> Assignments { get; set; } = [];
/// <summary>
/// Plätze, für die im Raum physisch kein Tisch existiert (z. B. eine Reihe mit einer Lücke) —
/// werden im Sitzplan ausgeblendet, bleiben aber Teil des Rasters, damit Reihen/Spalten
/// weiterhin korrekt ausgerichtet sind. Ein ausgeblendeter Platz kann nie belegt sein, siehe
/// SeatingPlanRepository.Save.
/// </summary>
public List<HiddenSeat> HiddenSeats { get; set; } = [];
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class SeatAssignment
{
public int Row { get; set; }
public int Column { get; set; }
public Guid StudentId { get; set; }
}
public class HiddenSeat
{
public int Row { get; set; }
public int Column { get; set; }
}
+29
View File
@@ -0,0 +1,29 @@
namespace LehrerApp.Core.Models;
/// <summary>
/// Ein Eintrag im Kürzel-Katalog (Einstellungen), z.B. Code="Tb", Label="Tafelbild". Wird im
/// Stundeneditor als Autovervollständigungs-Vorschlag für das freie Kurzsymbol-Feld
/// <see cref="LessonPhaseStep.Shorthand"/> angeboten.
/// </summary>
public class ShorthandCode
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Code { get; set; } = "";
public string Label { get; set; } = "";
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
/// Startwerte für die Von/Nach-Vorschläge, solange der Katalog in den Einstellungen leer ist —
/// kein DB-Seed, analog zu <see cref="DefaultParticipationAspects"/>.
public static class DefaultShorthandCodes
{
public static readonly IReadOnlyList<ShorthandCode> All =
[
new() { Code = "L", Label = "Lehrer" },
new() { Code = "S", Label = "Schüler" },
new() { Code = "Tb", Label = "Tafelbild" },
new() { Code = "SH", Label = "Schülerheft" },
new() { Code = "AB", Label = "Arbeitsblatt" },
new() { Code = "GA", Label = "Gruppenarbeit" },
];
}
+18
View File
@@ -8,6 +8,8 @@ public class Student
public string FullName => $"{LastName}, {FirstName}"; public string FullName => $"{LastName}, {FirstName}";
public DateOnly? DateOfBirth { get; set; } public DateOnly? DateOfBirth { get; set; }
public Gender? Gender { get; set; } public Gender? Gender { get; set; }
/// <summary>Quellsystembezogene Kennungen, z.B. für wiederholbare Stammdatenimporte.</summary>
public Dictionary<string, string> ExternalIds { get; set; } = [];
public List<Contact> Contacts { get; set; } = []; public List<Contact> Contacts { get; set; } = [];
public string? Notes { get; set; } public string? Notes { get; set; }
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
@@ -19,7 +21,10 @@ public class Contact
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = ""; public string Name { get; set; } = "";
public string Relation { get; set; } = ""; public string Relation { get; set; } = "";
/// <summary>Vollständige Anrede für Briefe, z.B. „Sehr geehrte Frau Mustermann,“.</summary>
public string? LetterSalutation { get; set; }
public string? Phone { get; set; } public string? Phone { get; set; }
public string? MobilePhone { get; set; }
public string? Email { get; set; } public string? Email { get; set; }
public string? Street { get; set; } public string? Street { get; set; }
public string? PostalCode { get; set; } public string? PostalCode { get; set; }
@@ -40,3 +45,16 @@ public enum ContactInvalidReason
} }
public enum Gender { M, W, D } public enum Gender { M, W, D }
public sealed record StudentReferenceSummary(
int Memberships,
int ExamResults,
int Grades,
int ReportGrades,
int ParticipationEntries,
int DocumentationEntries)
{
public int Total => Memberships + ExamResults + Grades + ReportGrades
+ ParticipationEntries + DocumentationEntries;
public bool HasReferences => Total > 0;
}
+21
View File
@@ -0,0 +1,21 @@
namespace LehrerApp.Core.Models;
/// <summary>
/// Papierkorb-Eintrag (14.3): ein gelöschter Datensatz, als vollständiger JSON-Schnappschuss
/// aufbewahrt, damit er wiederhergestellt werden kann. Deckt bewusst nur Löschvorgänge OHNE
/// Kaskade ab (z.B. eine einzelne Note, keine ganze Lerngruppe) — Details siehe
/// <c>LiteDbContext.MoveToTrash</c>/<c>RestoreFromTrash</c> und TODO.md 14.3.
/// </summary>
public class TrashedItem
{
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>Modellname (<c>nameof(Grade)</c> etc.) — bestimmt, wie der Schnappschuss beim
/// Wiederherstellen deserialisiert und welche Repository-Methode aufgerufen wird.</summary>
public string EntityType { get; set; } = "";
public Guid EntityId { get; set; }
/// <summary>Vollständiger JSON-Schnappschuss der gelöschten Entität.</summary>
public string Snapshot { get; set; } = "";
/// <summary>Kurze, für die Papierkorb-Liste lesbare Beschreibung (z.B. "Note 2 (12.09.2026)").</summary>
public string Summary { get; set; } = "";
public DateTime DeletedAt { get; set; } = DateTime.UtcNow;
}
+79
View File
@@ -0,0 +1,79 @@
namespace LehrerApp.Core.Models;
/// <summary>
/// Lokal zwischengespeicherte Zeile aus dem WebUntis-Bericht "Fehlzeiten pro Schüler*in"
/// (Klassenlehrer-Feature, siehe TODO.md) — 1:1-Abbildung von `UntisClassAbsenceEntryDto`
/// (LehrerApp.Desktop), damit Fehlzeiten nicht bei jedem Öffnen der Ansicht neu abgerufen werden
/// müssen. <c>Date</c> bewusst als <c>int</c> (yyyyMMdd) wie die DTOs statt <c>DateOnly</c>/
/// <c>DateTime</c> — vermeidet die LiteDB-<c>DateTime</c>-Kind-Tücke (siehe CLAUDE.md) und ist
/// direkt für Bereichs-Queries nutzbar.
/// </summary>
public class UntisAbsenceCacheEntry
{
public Guid Id { get; set; } = Guid.NewGuid();
public string ClassName { get; set; } = "";
public string StudentName { get; set; } = "";
public int? ExternKey { get; set; }
public int Date { get; set; }
public int AbsentPeriods { get; set; }
public int AbsentMinutes { get; set; }
public string? TeacherUsernames { get; set; }
public string? Subject { get; set; }
public string? AbsenceReason { get; set; }
public string? Note { get; set; }
public int? EntryId { get; set; }
public string? HandledOn { get; set; }
public bool Counts { get; set; }
public string? ExcuseNote { get; set; }
public int? PeriodNumber { get; set; }
public string? Status { get; set; }
public bool CountsAsFullDay { get; set; }
}
/// <summary>Lokal zwischengespeicherte Zeile aus dem "-alle-"-Klassenbuchbericht, gefiltert auf
/// Einträge anderer Lehrkräfte (Klassenlehrer-Feature) — 1:1-Abbildung von
/// `UntisForeignClassRegisterEventDto` (LehrerApp.Desktop).</summary>
public class UntisClassRegisterCacheEntry
{
public Guid Id { get; set; } = Guid.NewGuid();
public string ClassName { get; set; } = "";
public int Date { get; set; }
public string? Subject { get; set; }
public string StudentName { get; set; } = "";
public string? TeacherUsername { get; set; }
public string? CategoryName { get; set; }
public string? CategoryGroup { get; set; }
public string? Text { get; set; }
}
/// <summary>Lokal zwischengespeicherte Zeile aus dem WebUntis-Schülerreport, eingeschränkt auf die
/// für die Klassenlehrer-Übersicht nötigen Felder (Roster-Namen) — anders als Fehlzeiten/
/// Klassenbuch keine Historie, sondern immer nur der zuletzt abgerufene Stand einer Klasse
/// (siehe <see cref="UntisClassRegisterCacheEntry"/> für den Grund, warum es trotzdem ein eigenes
/// Modell statt Wiederverwendung des vollen Schülerreport-DTOs ist: nur diese zwei Felder werden
/// für den Roster-Abgleich tatsächlich gebraucht).</summary>
public class UntisStudentRosterCacheEntry
{
public Guid Id { get; set; } = Guid.NewGuid();
public string ClassName { get; set; } = "";
public int? ExternKey { get; set; }
public string DisplayName { get; set; } = "";
}
public enum UntisCacheKind { Absences, ClassRegister, Roster }
/// <summary>
/// Ein Datensatz pro (<see cref="ClassName"/>, <see cref="Kind"/>): wann das "heiße" Fenster
/// (die letzten Tage, deren Status sich noch ändern kann) zuletzt aufgefrischt wurde, und wie weit
/// die "kalte", als endgültig angenommene Historie bereits lückenlos zurückreicht. Getrennt von den
/// Cache-Zeilen selbst, weil ein Fenster ohne Treffer (z.B. eine ereignislose Woche) sonst keine
/// Zeile hinterließe, aus der sich "zuletzt abgerufen am…" ableiten ließe.
/// </summary>
public class UntisCacheFetchState
{
public Guid Id { get; set; } = Guid.NewGuid();
public string ClassName { get; set; } = "";
public UntisCacheKind Kind { get; set; }
public DateTime? HotWindowFetchedAt { get; set; }
public int? ColdCoverageStartDate { get; set; }
}
+24
View File
@@ -0,0 +1,24 @@
namespace LehrerApp.Core.Models;
public enum UntisHubJobKind
{
FehlzeitenKurz,
FehlzeitenLang,
OffenePeriods,
Klassenbuchabgleich,
Hausaufgabenabgleich,
}
/// <summary>Wann welcher WebUntis-Abgleich ("Untis-Hub"-Job, siehe TODO.md) zuletzt lief und mit
/// welchem Ergebnis - ein Datensatz pro (<see cref="Kind"/>, <see cref="GroupId"/>). Bei den drei
/// dashboard-weiten Jobs (<see cref="UntisHubJobKind.OffenePeriods"/>,
/// <see cref="UntisHubJobKind.Klassenbuchabgleich"/>, <see cref="UntisHubJobKind.Hausaufgabenabgleich"/>)
/// ist <see cref="GroupId"/> null; die beiden Fehlzeiten-Kadenzen sind je Lerngruppe getrennt.</summary>
public class UntisHubJobState
{
public Guid Id { get; set; } = Guid.NewGuid();
public UntisHubJobKind Kind { get; set; }
public Guid? GroupId { get; set; }
public DateTime? LastRunAt { get; set; }
public string? LastResultSummary { get; set; }
}
+69
View File
@@ -0,0 +1,69 @@
namespace LehrerApp.Core.Models;
/// <summary>
/// Zuletzt bekannter Zustand eines einzelnen WebUntis-iCal-Termins (ein VEVENT) — die lokale
/// "Sicherungskopie", gegen die jeder neue Abruf verglichen wird, um Änderungen zu erkennen
/// (Nutzer-Feedback: "Abgleich mit einem lokalen Backup, um Änderungen zu finden"). <see cref="Uid"/>
/// ist die stabile iCal-UID des Termins (pro Wochen-Slot über das ganze Schuljahr gleich) und
/// dient als fachlicher Schlüssel für den Abgleich — bewusst kein <c>[BsonId]</c> darauf, da
/// LehrerApp.Core absichtlich frei von LiteDB/Avalonia-Abhängigkeiten bleibt (siehe CLAUDE.md);
/// die Suche nach <see cref="Uid"/> läuft stattdessen über eine gefilterte Repository-Abfrage.
/// </summary>
public class UntisSnapshotEntry
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Uid { get; set; } = "";
public DateOnly Date { get; set; }
public TimeOnly StartTime { get; set; }
public TimeOnly EndTime { get; set; }
public string? Summary { get; set; }
public string Location { get; set; } = "";
public string Description { get; set; } = "";
public string Status { get; set; } = "CONFIRMED";
public DateTime LastSeenAt { get; set; } = DateTime.UtcNow;
}
/// <summary>
/// Vom Nutzer bestätigte Zuordnung eines regulären WebUntis-Wochenmusters (Wochentag + Uhrzeit +
/// Fach-Kürzel + Klassen-Token aus der Beschreibung) zu einer bestehenden <see cref="LearningGroup"/>
/// (<see cref="SubstitutionKind.Lesson"/>) oder — für Termine ohne Klassenbezug, z.B. Aufsichten —
/// zu einer festen Pause (<see cref="SubstitutionKind.Supervision"/>, <see cref="AfterPeriod"/>
/// statt <see cref="GroupId"/>/<see cref="PeriodNumber"/>; Nutzer-Feedback: "Zwei Termine sind
/// meine Aufsichten, die nicht zugeordnet werden können [...] vom Zeitraster und von der Dauer her
/// könnten die erfasst werden" — Aufsichten liegen in Pausen, nicht auf einem Unterrichtsstunden-
/// Zeitraster, brauchen also einen eigenen Auflösungsweg statt PeriodNumber). Nur bestätigte
/// (<see cref="Confirmed"/> true) Zuordnungen lösen automatisch geschriebene
/// <see cref="SubstitutionEntry"/>-Einträge aus (Nutzer-Feedback: die erstmalige Zuordnung ist
/// fehleranfällig — falsche Gruppe würde falsche Vertretungen erzeugen — und braucht deshalb eine
/// Bestätigung, bevor sie aktiv wird; siehe UntisMappingReviewDialog).
/// </summary>
public class UntisSlotMapping
{
public Guid Id { get; set; } = Guid.NewGuid();
public DayOfWeek Weekday { get; set; }
public TimeOnly StartTime { get; set; }
public string? Summary { get; set; }
public string ClassToken { get; set; } = "";
public SubstitutionKind Kind { get; set; } = SubstitutionKind.Lesson;
/// Nur bei <see cref="SubstitutionKind.Lesson"/> gesetzt.
public Guid? GroupId { get; set; }
/// Erste/primäre Stunde, zum Bestätigungszeitpunkt über PeriodScheduleService aufgelöst (siehe
/// UntisMatchingService) — hier gespeichert, damit UntisDiffService selbst framework-frei
/// bleibt und keine eigene Uhrzeit→Stunde-Auflösung braucht. Nur bei
/// <see cref="SubstitutionKind.Lesson"/> gesetzt.
public int? PeriodNumber { get; set; }
/// ALLE Stunden, die dieser WebUntis-Termin überdeckt (siehe UntisSlotMatch.CoveredPeriods) —
/// bei einer Doppelstunde mehr als eine. Entscheidet, welche TimetableSlots als "durch WebUntis
/// bestätigt" gelten (Nutzer-Feedback: "ich habe aber jetzt alles zugeordnet, und trotzdem
/// erhalte ich die Warnung, dass 16 Stunden ohne Untis-Zuordnung sind" — ohne diese Liste blieb
/// die zweite Stunde einer Doppelstunde immer "unzugeordnet", weil WebUntis dafür nur einen
/// einzigen, am Anfang beginnenden Termin meldet). Nur bei <see cref="SubstitutionKind.Lesson"/>
/// gesetzt.
public List<int> CoveredPeriods { get; set; } = [];
/// Nur bei <see cref="SubstitutionKind.Supervision"/> gesetzt — wie bei
/// <see cref="SupervisionDuty.AfterPeriod"/>, ebenfalls zum Bestätigungszeitpunkt aufgelöst
/// (die Pause direkt vor der WebUntis-Startzeit, 0 = vor der 1. Stunde).
public int? AfterPeriod { get; set; }
public bool Confirmed { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
+49
View File
@@ -0,0 +1,49 @@
namespace LehrerApp.Core.Models;
/// <summary>
/// Fallmappe für ein konkretes, laufendes Problem mit einer/einem oder mehreren Schüler*innen
/// (z.B. Schuleschwänzen, wiederholte Hausaufgaben-Verweigerung, ein Konflikt) — bündelt Titel,
/// Problembeschreibung, Schlagwörter sowie die dazugehörigen <see cref="Documentation"/>-Einträge
/// und eingefrorene WebUntis-Klassenbucheinträge an einem Ort, statt sie über datumsgefilterte
/// Listen verstreut zu lassen. Bewusst nur Referenzen (<see cref="DocumentationIds"/>) statt
/// gecachtem Inhalt — die Datenmenge pro Lehrkraft ist klein genug, dass ein Cache nur
/// Invalidierungs-Komplexität einführen würde, ohne echten Nutzen (siehe TODO.md, Präzedenzfall
/// 5.3.3: eine Plan-ID-Gruppierung für Förderpläne wurde aus demselben Grund verworfen).
/// </summary>
public class Vorgang
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Title { get; set; } = "";
public string Description { get; set; } = "";
public List<Guid> StudentIds { get; set; } = [];
/// Freie Labels, z.B. "Absentismus", "Hausaufgaben", "Verspätungen", "Konflikte" —
/// siehe VorgangTagDisplay.Suggestions im Desktop-Projekt für die Vorschlagsliste.
public List<string> Tags { get; set; } = [];
public VorgangStatus Status { get; set; } = VorgangStatus.Open;
public List<Guid> DocumentationIds { get; set; } = [];
public List<VorgangClassRegisterEntry> ClassRegisterEntries { get; set; } = [];
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public DateTime? ClosedAt { get; set; }
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
}
public enum VorgangStatus { Open, Closed }
/// <summary>
/// Eingefrorene Kopie einer WebUntis-Klassenbuchzeile zum Zeitpunkt des Anheftens an einen
/// <see cref="Vorgang"/> — keine Referenz per ID, da weder <c>UntisForeignClassRegisterEventDto</c>
/// noch <c>UntisClassRegisterCacheEntry</c> eine über einen Cache-Refresh hinweg stabile ID haben.
/// </summary>
public class VorgangClassRegisterEntry
{
public DateOnly Date { get; set; }
public string StudentName { get; set; } = "";
public string? Subject { get; set; }
public string? TeacherUsername { get; set; }
public string? CategoryName { get; set; }
public string? CategoryGroup { get; set; }
public string? Text { get; set; }
public DateTime PinnedAt { get; set; } = DateTime.UtcNow;
}
+63
View File
@@ -0,0 +1,63 @@
namespace LehrerApp.Core.Models;
/// <summary>Vom Nutzer eingegebene Schuladresse. Die Geocodierung erfolgt ausschließlich
/// serverseitig beim expliziten Speichern, nicht während der Eingabe.</summary>
public class SchoolLocationRequest
{
public string SchoolName { get; set; } = "";
public string Street { get; set; } = "";
public string PostalCode { get; set; } = "";
public string City { get; set; } = "";
public GermanState State { get; set; } = GermanState.NW;
}
/// <summary>Serverseitig gespeicherter Schulstandort einschließlich des bestätigbaren
/// Geocoding-Ergebnisses. Koordinaten werden nie an den DWD als Straßenadresse übertragen.</summary>
public sealed class SchoolLocationProfile : SchoolLocationRequest
{
public double Latitude { get; set; }
public double Longitude { get; set; }
public string ResolvedAddress { get; set; } = "";
public DateTime UpdatedAt { get; set; }
}
public sealed class WeatherSnapshot
{
public string StationId { get; set; } = "";
public string StationName { get; set; } = "";
public double StationDistanceKm { get; set; }
public DateTime? ForecastIssuedAt { get; set; }
public DateTime RetrievedAt { get; set; }
/// <summary>True, wenn der DWD vorübergehend nicht erreichbar war und der Server den zuletzt
/// erfolgreich gespeicherten Stand zurückgibt.</summary>
public bool IsStale { get; set; }
public List<WeatherForecastHour> Forecast { get; set; } = [];
public List<WeatherWarning> Warnings { get; set; } = [];
}
public sealed class WeatherForecastHour
{
public DateTime ValidAt { get; set; }
public double? TemperatureC { get; set; }
public double? WindSpeedKmh { get; set; }
public double? WindGustKmh { get; set; }
public double? PrecipitationMm { get; set; }
/// <summary>Stündliche MOSMIX-Wahrscheinlichkeit für mehr als 0,1 mm Niederschlag.</summary>
public double? PrecipitationProbabilityPercent { get; set; }
/// <summary>MOSMIX-Sonnenscheindauer der letzten Stunde, in Minuten.</summary>
public double? SunshineDurationMinutes { get; set; }
public double? CloudCoverPercent { get; set; }
public int? WeatherCode { get; set; }
}
public sealed class WeatherWarning
{
public string Identifier { get; set; } = "";
public string Event { get; set; } = "";
public string Headline { get; set; } = "";
public string Description { get; set; } = "";
public string Instruction { get; set; } = "";
public string Severity { get; set; } = "";
public DateTime? Onset { get; set; }
public DateTime? Expires { get; set; }
}
+98 -3
View File
@@ -1,10 +1,14 @@
namespace LehrerApp.Core.Models; namespace LehrerApp.Core.Models;
public class Documentation public class Documentation : IHasAttachments
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
public Guid StudentId { get; set; } public Guid StudentId { get; set; }
public Guid? GroupId { get; set; } public Guid? GroupId { get; set; }
/// <summary>Unterrichtssitzung, in der die Beobachtung entstanden ist.</summary>
public Guid? ParticipationSessionId { get; set; }
/// <summary>Optionaler Bezug auf die konkrete geplante Stunde.</summary>
public Guid? LessonId { get; set; }
public DocumentationType Type { get; set; } public DocumentationType Type { get; set; }
public DateOnly Date { get; set; } public DateOnly Date { get; set; }
public string Title { get; set; } = ""; public string Title { get; set; } = "";
@@ -12,9 +16,23 @@ public class Documentation
public List<string> Participants { get; set; } = []; public List<string> Participants { get; set; } = [];
public AbsenceData? AbsenceData { get; set; } public AbsenceData? AbsenceData { get; set; }
public SupportData? SupportData { get; set; } public SupportData? SupportData { get; set; }
public ParentCallData? ParentCallData { get; set; }
public ParentLetterData? ParentLetterData { get; set; }
public List<DocumentAttachment> Attachments { get; set; } = [];
/// Freie Labels zur Nachverfolgung, z.B. "Kritisch", "Nacharbeiten" — siehe `DocumentationTagDisplay`.
public List<string> Tags { get; set; } = [];
/// <summary>Im Unterricht schnell erfasst und noch inhaltlich nachzuarbeiten.</summary>
public bool IsDraft { get; set; }
public bool IsConfidential { get; set; } public bool IsConfidential { get; set; }
/// <summary>Für die pädagogische Arbeit/Leistungsbewertung gedacht, aber nicht für den
/// WebUntis-Klassenbuch-Abgleich vorgesehen - blendet den Eintrag im Abgleichsdialog
/// (WebUntisDocumentationComparisonViewModel) aus dem Zwischenablage-Vorschlag aus.</summary>
public bool ExcludeFromWebUntisSync { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
// Löschen entfernt den Eintrag nicht hart, sondern markiert ihn nur (Nachvollziehbarkeit, 5.1.4).
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
} }
public class AbsenceData public class AbsenceData
{ {
@@ -28,7 +46,54 @@ public class SupportData
public DateOnly? ReviewDate { get; set; } public DateOnly? ReviewDate { get; set; }
public SupportStatus Status { get; set; } = SupportStatus.Active; public SupportStatus Status { get; set; } = SupportStatus.Active;
} }
public enum DocumentationType { Conversation, Incident, SupportPlan, Absence } /// <summary>Geplante Gesprächspunkte eines Elternanrufs, abgehakt in <see cref="ParentCallData"/>.</summary>
public class ParentCallPoint
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Text { get; set; } = "";
public bool IsDone { get; set; }
}
/// <summary>
/// Elternanruf: Gesprächspunkte werden vorab geplant, dann im begleitenden Dialog während des
/// Anrufs abgehakt und um Eindrücke/Ergänzungen zum Protokoll ergänzt.
/// </summary>
public class ParentCallData
{
public List<ParentCallPoint> Points { get; set; } = [];
public string Impressions { get; set; } = "";
public bool IsConducted { get; set; }
public DateOnly? ConductedDate { get; set; }
}
/// <summary>Elternbrief: Entwurf/Planung sowie Absende- und Rückmeldedaten.</summary>
public class ParentLetterData
{
public string DraftContent { get; set; } = "";
public DateOnly? SentDate { get; set; }
public bool ResponseReceived { get; set; }
public DateOnly? ResponseDate { get; set; }
public string ResponseNote { get; set; } = "";
}
/// <summary>Datei-Anhang eines Dokumentationseintrags, im LiteDB-Dateispeicher abgelegt.</summary>
public class DocumentAttachment
{
public string StorageId { get; set; } = "";
public string FileName { get; set; } = "";
public long SizeBytes { get; set; }
public DateTime UploadedAt { get; set; } = DateTime.UtcNow;
}
/// <summary>
/// Implementiert von jedem Modell mit Datei-Anhängen (aktuell <see cref="Documentation"/> und
/// <see cref="Lesson"/>). Anhang-Bytes reisen nicht im JSON-Sync-Ereignis mit, sondern als eigener
/// Binärtransfer (siehe <c>SyncEventPublisher</c>/<c>EventApplier</c>/<c>AttachmentSyncer</c> in
/// LehrerApp.Sync) — dieses Interface macht die Erkennung "hat dieses Modell Anhänge?" dort
/// generisch statt hart auf einen einzelnen Modelltyp verdrahtet.
/// </summary>
public interface IHasAttachments
{
List<DocumentAttachment> Attachments { get; }
}
// Hinten angefügt, damit die numerischen Werte bereits gespeicherter LiteDB-Einträge stabil bleiben.
public enum DocumentationType { Conversation, Incident, SupportPlan, Absence, ParentCall, ParentLetter, Planning }
public enum SupportStatus { Active, Completed, Paused } public enum SupportStatus { Active, Completed, Paused }
public class WorkTask public class WorkTask
@@ -41,9 +106,36 @@ public class WorkTask
public int? EstimatedMinutes { get; set; } public int? EstimatedMinutes { get; set; }
public WorkTaskStatus Status { get; set; } = WorkTaskStatus.Open; public WorkTaskStatus Status { get; set; } = WorkTaskStatus.Open;
public string? Notes { get; set; } public string? Notes { get; set; }
// Beim Abschließen (6.1.3) wird bei Weekly/Monthly automatisch die nächste Aufgabe erzeugt (6.1.4).
public TaskRecurrence Recurrence { get; set; } = TaskRecurrence.None;
/// Nutzer-Feedback: "Aufgaben" wirkt wie reine Zeiterfassung, dabei braucht man auch schlichte
/// Erinnerungen ohne Zeitbezug (z.B. "morgen Ansage an die Klasse machen"). Reminder-Aufgaben
/// laufen über dieselbe Liste/dasselbe Dashboard-Widget, werden aber nie mit einem
/// TimeEntry verknüpft und fließen deshalb schon rein datenmodell-bedingt (WorkloadEvaluationViewModel
/// wertet nur TimeEntry aus, nicht WorkTask) nicht in die Zeitauswertung (6.3) ein.
public TaskKind Kind { get; set; } = TaskKind.WorkItem;
/// Nutzer-Feedback: pädagogische Erinnerungen ("Ansage an die Klasse", "Material einsammeln")
/// sollen sich je nach Dringlichkeit farblich absetzen können, unabhängig vom Fälligkeitsdatum.
public TaskPriority Priority { get; set; } = TaskPriority.Normal;
/// Optionale Abhak-Liste (Nutzer-Feedback: "Namensliste aller Kurs-Schüler" ODER freie
/// Teil-Punkte, wahlweise). Beides läuft über denselben Listentyp — bei aus der Kursliste
/// befüllten Einträgen ist StudentId gesetzt (Sprungziel zum Schüler), bei frei eingegebenen
/// Einträgen bleibt es leer. Als verschachtelte LiteDB-Liste statt eigener Collection/JSON-
/// String, siehe TODO.md-Nachtrag zu 6.1/9 — synct dadurch automatisch über den bestehenden
/// Sync-Unterbau mit (EventPublisher serialisiert das ganze WorkTask-Dokument je Save()).
public List<ChecklistItem> ChecklistItems { get; set; } = [];
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
} }
public class ChecklistItem
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Label { get; set; } = "";
public bool IsDone { get; set; }
/// Gesetzt, wenn der Eintrag aus der Kursliste befüllt wurde (Sprungziel zum Schüler);
/// bei frei eingegebenen Einträgen null.
public Guid? StudentId { get; set; }
}
public class TimeEntry public class TimeEntry
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
@@ -57,5 +149,8 @@ public class TimeEntry
public string? Description { get; set; } public string? Description { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
} }
public enum TaskCategory { Correction, Preparation, Admin, Meeting, Other } public enum TaskCategory { Correction, Preparation, Admin, Meeting, Other, Teaching }
public enum WorkTaskStatus { Open, InProgress, Done } public enum WorkTaskStatus { Open, InProgress, Done }
public enum TaskRecurrence { None, Weekly, Monthly }
public enum TaskKind { WorkItem, Reminder }
public enum TaskPriority { Low, Normal, High }
@@ -0,0 +1,205 @@
using System.Globalization;
using System.Text;
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
/// <summary>
/// Parser für den beobachteten ClassyPlan-Jahresplanexport. Im Gegensatz zum bewusst schmalen
/// WebUntis-Parser unterstützt er insbesondere DATE-Ganztagstermine und mehrtägige Ereignisse.
/// Er arbeitet absichtlich strikt: Ist ein VEVENT unvollständig oder ungültig, scheitert der
/// gesamte Parse, damit ein nachgelagerter Snapshot-Abgleich keine bestehenden Termine löscht.
/// </summary>
public static class AnnualPlanIcsParser
{
private static readonly TimeZoneInfo BerlinTimeZone =
TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
private readonly record struct Property(string Head, string Value)
{
public bool IsDate => Head.Contains("VALUE=DATE", StringComparison.OrdinalIgnoreCase);
}
public static List<AnnualPlanEvent> Parse(string icsText)
{
var lines = Unfold(icsText);
if (!lines.Any(l => l.Equals("BEGIN:VCALENDAR", StringComparison.OrdinalIgnoreCase)) ||
!lines.Any(l => l.Equals("END:VCALENDAR", StringComparison.OrdinalIgnoreCase)))
throw new FormatException("Kein vollständiger iCal-Kalender gefunden.");
var result = new List<AnnualPlanEvent>();
Dictionary<string, Property>? current = null;
foreach (var line in lines)
{
if (line.Equals("BEGIN:VEVENT", StringComparison.OrdinalIgnoreCase))
{
if (current is not null) throw new FormatException("Verschachteltes VEVENT gefunden.");
current = new Dictionary<string, Property>(StringComparer.OrdinalIgnoreCase);
continue;
}
if (line.Equals("END:VEVENT", StringComparison.OrdinalIgnoreCase))
{
if (current is null) throw new FormatException("END:VEVENT ohne BEGIN:VEVENT gefunden.");
result.Add(BuildEvent(current, result.Count + 1));
current = null;
continue;
}
if (current is null) continue;
var colon = line.IndexOf(':');
if (colon < 0) continue;
var head = line[..colon];
var separator = head.IndexOf(';');
var key = separator < 0 ? head : head[..separator];
if (key.Length > 0) current[key] = new Property(head, line[(colon + 1)..]);
}
if (current is not null) throw new FormatException("Nicht abgeschlossenes VEVENT gefunden.");
if (result.Select(e => e.ExternalId).Distinct(StringComparer.Ordinal).Count() != result.Count)
throw new FormatException("Der Jahresplan enthält doppelte iCal-UIDs.");
return result;
}
private static AnnualPlanEvent BuildEvent(Dictionary<string, Property> properties, int number)
{
var uid = Required(properties, "UID", number).Value.Trim();
if (uid.Length == 0) throw Invalid(number, "UID fehlt");
var startProperty = Required(properties, "DTSTART", number);
var endProperty = properties.GetValueOrDefault("DTEND");
DateOnly startDate;
DateOnly endDate;
TimeOnly? startTime;
TimeOnly? endTime;
var isAllDay = startProperty.IsDate;
if (isAllDay)
{
startDate = ParseDate(startProperty.Value, number, "DTSTART");
var endExclusive = endProperty == default
? startDate.AddDays(1)
: endProperty.IsDate
? ParseDate(endProperty.Value, number, "DTEND")
: throw Invalid(number, "DTSTART und DTEND verwenden unterschiedliche Werttypen");
if (endExclusive <= startDate) throw Invalid(number, "DTEND liegt nicht nach DTSTART");
endDate = endExclusive.AddDays(-1); // RFC 5545: DTEND eines Ganztagstermins ist exklusiv.
startTime = null;
endTime = null;
}
else
{
var start = ParseLocalDateTime(startProperty, number, "DTSTART");
var end = endProperty == default
? start
: !endProperty.IsDate
? ParseLocalDateTime(endProperty, number, "DTEND")
: throw Invalid(number, "DTSTART und DTEND verwenden unterschiedliche Werttypen");
if (end < start) throw Invalid(number, "DTEND liegt vor DTSTART");
startDate = DateOnly.FromDateTime(start);
endDate = DateOnly.FromDateTime(end);
startTime = TimeOnly.FromDateTime(start);
endTime = TimeOnly.FromDateTime(end);
}
return new AnnualPlanEvent
{
ExternalId = uid,
Title = Text(properties, "SUMMARY"),
Description = Text(properties, "DESCRIPTION"),
Location = Text(properties, "LOCATION"),
StartDate = startDate,
EndDate = endDate,
StartTime = startTime,
EndTime = endTime,
IsAllDay = isAllDay,
CalendarGroup = Text(properties, "X-GROUPNAME"),
Color = Text(properties, "X-COLOR"),
Status = Text(properties, "STATUS") is { Length: > 0 } status ? status : "CONFIRMED",
Sequence = int.TryParse(Raw(properties, "SEQUENCE"), NumberStyles.Integer,
CultureInfo.InvariantCulture, out var sequence) ? sequence : 0,
SourceLastModifiedUtc = ParseOptionalUtc(properties.GetValueOrDefault("LAST-MODIFIED"), number),
};
}
private static Property Required(Dictionary<string, Property> properties, string key, int number) =>
properties.TryGetValue(key, out var value) ? value : throw Invalid(number, $"{key} fehlt");
private static string Raw(Dictionary<string, Property> properties, string key) =>
properties.TryGetValue(key, out var value) ? value.Value : "";
private static string Text(Dictionary<string, Property> properties, string key) =>
Unescape(Raw(properties, key));
private static DateOnly ParseDate(string value, int number, string field)
{
if (DateOnly.TryParseExact(value, "yyyyMMdd", CultureInfo.InvariantCulture,
DateTimeStyles.None, out var date)) return date;
throw Invalid(number, $"ungültiges {field}: {value}");
}
private static DateTime ParseLocalDateTime(Property property, int number, string field)
{
var utc = property.Value.EndsWith('Z');
var value = utc ? property.Value[..^1] : property.Value;
var formats = new[] { "yyyyMMdd'T'HHmmss", "yyyyMMdd'T'HHmm" };
if (!DateTime.TryParseExact(value, formats, CultureInfo.InvariantCulture,
DateTimeStyles.None, out var parsed))
throw Invalid(number, $"ungültiges {field}: {property.Value}");
return utc
? TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(parsed, DateTimeKind.Utc), BerlinTimeZone)
: DateTime.SpecifyKind(parsed, DateTimeKind.Unspecified);
}
private static DateTime? ParseOptionalUtc(Property property, int number)
{
if (property == default || string.IsNullOrWhiteSpace(property.Value)) return null;
var local = ParseLocalDateTime(property, number, "LAST-MODIFIED");
return property.Value.EndsWith('Z')
? TimeZoneInfo.ConvertTimeToUtc(local, BerlinTimeZone)
: TimeZoneInfo.ConvertTimeToUtc(DateTime.SpecifyKind(local, DateTimeKind.Unspecified), BerlinTimeZone);
}
private static List<string> Unfold(string text)
{
var result = new List<string>();
foreach (var line in text.Replace("\r\n", "\n").Replace("\r", "\n").Split('\n'))
{
if ((line.StartsWith(' ') || line.StartsWith('\t')) && result.Count > 0)
result[^1] += line[1..];
else
result.Add(line);
}
return result;
}
private static string Unescape(string value)
{
var result = new StringBuilder(value.Length);
for (var i = 0; i < value.Length; i++)
{
if (value[i] != '\\' || i + 1 >= value.Length)
{
result.Append(value[i]);
continue;
}
var next = value[++i];
result.Append(next switch
{
'n' or 'N' => '\n',
'\\' => '\\',
',' => ',',
';' => ';',
_ => next,
});
}
return result.ToString();
}
private static FormatException Invalid(int number, string message) =>
new($"Ungültiger Jahresplan-Termin #{number}: {message}.");
}
+73
View File
@@ -0,0 +1,73 @@
using System.Security.Cryptography;
using System.Text.Json;
namespace LehrerApp.Core.Services;
internal class AppLockConfig
{
public bool Enabled { get; set; }
public string? Salt { get; set; }
public string? PasswordHash { get; set; }
public int TimeoutMinutes { get; set; } = 10;
}
/// <summary>
/// App-Sperre nach Inaktivität: eigenes Passwort (unabhängig von einer eventuellen
/// Datenbank-Verschlüsselung), als gesalzener Hash in <c>&lt;AppData&gt;/LehrerApp/applock.json</c>
/// abgelegt. Das Klartext-Passwort wird nie gespeichert.
/// </summary>
public class AppLockService
{
private const int Pbkdf2Iterations = 100_000;
private readonly string _configPath;
private AppLockConfig _config;
public bool IsEnabled => _config.Enabled;
public int TimeoutMinutes => _config.TimeoutMinutes;
public bool HasPassword => !string.IsNullOrEmpty(_config.PasswordHash);
public AppLockService(string appDataPath)
{
_configPath = Path.Combine(appDataPath, "applock.json");
_config = Load();
}
public void Configure(bool enabled, int timeoutMinutes)
{
_config.Enabled = enabled;
_config.TimeoutMinutes = Math.Max(1, timeoutMinutes);
Save();
}
public void SetPassword(string password)
{
var salt = RandomNumberGenerator.GetBytes(16);
_config.Salt = Convert.ToBase64String(salt);
_config.PasswordHash = Hash(password, salt);
Save();
}
public bool VerifyPassword(string password)
{
if (string.IsNullOrEmpty(_config.PasswordHash) || string.IsNullOrEmpty(_config.Salt)) return false;
var salt = Convert.FromBase64String(_config.Salt);
return _config.PasswordHash == Hash(password, salt);
}
private static string Hash(string password, byte[] salt) =>
Convert.ToBase64String(Rfc2898DeriveBytes.Pbkdf2(password, salt, Pbkdf2Iterations, HashAlgorithmName.SHA256, 32));
private AppLockConfig Load()
{
try
{
if (File.Exists(_configPath))
return JsonSerializer.Deserialize<AppLockConfig>(File.ReadAllText(_configPath)) ?? new AppLockConfig();
}
catch { /* beschädigte Konfiguration -> Standardwerte, Sperre bleibt deaktiviert */ }
return new AppLockConfig();
}
private void Save() =>
File.WriteAllText(_configPath, JsonSerializer.Serialize(_config));
}
+77
View File
@@ -0,0 +1,77 @@
namespace LehrerApp.Core.Services;
public enum LogLevel { Info, Warning, Error }
/// <summary>
/// Schreibt Log-Zeilen in eine tägliche Datei im App-Datenverzeichnis
/// (<c>&lt;AppData&gt;/LehrerApp/logs/lehrerapp-JJJJ-MM-TT.log</c>).
/// Beim Start werden Dateien entfernt, die älter als <see cref="RetentionDays"/> sind.
/// </summary>
public class AppLogger
{
private const int RetentionDays = 14;
private readonly object _lock = new();
private readonly string _logDirectory;
public string LogDirectory => _logDirectory;
public AppLogger(string appDataPath)
{
_logDirectory = Path.Combine(appDataPath, "logs");
Directory.CreateDirectory(_logDirectory);
RotateOldFiles();
}
public void Info(string message) => Write(LogLevel.Info, message, null);
public void Warn(string message) => Write(LogLevel.Warning, message, null);
public void Error(string message, Exception? exception = null) => Write(LogLevel.Error, message, exception);
private void Write(LogLevel level, string message, Exception? exception)
{
var levelLabel = level switch
{
LogLevel.Info => "INFO ",
LogLevel.Warning => "WARN ",
LogLevel.Error => "ERROR",
_ => "? ",
};
var line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{levelLabel}] {message}";
if (exception is not null)
line += Environment.NewLine + exception;
lock (_lock)
{
try
{
File.AppendAllText(CurrentLogFile(), line + Environment.NewLine);
}
catch
{
// Logging darf die App nie zum Absturz bringen (z.B. Datenträger voll/gesperrt).
}
}
}
private string CurrentLogFile() =>
Path.Combine(_logDirectory, $"lehrerapp-{DateTime.Now:yyyy-MM-dd}.log");
private void RotateOldFiles()
{
var cutoff = DateTime.Now.AddDays(-RetentionDays);
try
{
foreach (var file in Directory.GetFiles(_logDirectory, "lehrerapp-*.log"))
{
if (File.GetLastWriteTime(file) < cutoff)
{
try { File.Delete(file); }
catch { /* nächster Start versucht es erneut */ }
}
}
}
catch
{
// Verzeichnis nicht lesbar o.ä. — Logging soll trotzdem weiterlaufen können.
}
}
}
@@ -0,0 +1,44 @@
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
public record AttendanceBalance(
int TotalChecked, int Present, int Excused, int Unexcused,
int SchoolEvent, int ExcusePending, double AbsenceRatePercent)
{
public bool ExceedsThreshold => AbsenceRatePercent > AttendanceBalanceService.WarningThresholdPercent;
}
/// <summary>
/// Fehlzeitenbilanz (5.2.2/5.2.3) — reine Auswertung der bereits im Mitarbeit-Feature
/// erfassten <see cref="AttendanceStatus"/>-Werte, keine zweite Erfassung.
/// </summary>
public class AttendanceBalanceService
{
public const double WarningThresholdPercent = 20.0;
public AttendanceBalance Calculate(IEnumerable<(DateOnly Date, AttendanceStatus? Status)> entries,
DateOnly from, DateOnly to)
{
var relevant = entries
.Where(e => e.Status.HasValue && e.Date >= from && e.Date <= to)
.Select(e => e.Status!.Value)
.ToList();
var present = relevant.Count(s => s is AttendanceStatus.Present
or AttendanceStatus.Late or AttendanceStatus.SignificantlyLate);
var excused = relevant.Count(s => s == AttendanceStatus.Excused);
var unexcused = relevant.Count(s => s is AttendanceStatus.Unexcused
or AttendanceStatus.Truant or AttendanceStatus.LeftDuringClass);
var schoolEvent = relevant.Count(s => s is AttendanceStatus.OtherSchoolEvent
or AttendanceStatus.LearningIsland or AttendanceStatus.Suspended);
var pending = relevant.Count(s => s == AttendanceStatus.ExcusePending);
var total = relevant.Count;
// Schulisch veranlasste Abwesenheit (Exkursion o.ä.) zählt nicht als Fehlzeit des Schülers.
var absenceCount = excused + unexcused + pending;
var rate = total == 0 ? 0.0 : Math.Round(100.0 * absenceCount / total, 1);
return new AttendanceBalance(total, present, excused, unexcused, schoolEvent, pending, rate);
}
}
+97
View File
@@ -0,0 +1,97 @@
namespace LehrerApp.Core.Services;
public record BackupInfo(string Path, DateTime CreatedAt, long SizeBytes);
/// <summary>
/// Rollierendes lokales Backup der LiteDB-Datei
/// (<c>&lt;AppData&gt;/LehrerApp/backups/lehrerapp-JJJJ-MM-TT_HH-mm-ss.db</c>).
/// Reine Dateikopie — unabhängig davon, ob die Datenbank verschlüsselt ist.
/// </summary>
public class BackupService
{
private const int DefaultKeepCount = 10;
private readonly string _backupDirectory;
public string BackupDirectory => _backupDirectory;
/// Optionales zweites Sicherungsziel (z.B. USB-Stick, Netzlaufwerk) — Backups liegen sonst
/// ausschließlich neben der Live-Datenbank und sind bei einem Plattenausfall oder einem
/// versehentlich gelöschten Profilverzeichnis wertlos. Wird von außen gesetzt (Desktop-Settings,
/// siehe BackupSettingsService); bewusst best-effort — ist das Ziel beim Sichern nicht
/// erreichbar (Stick nicht eingesteckt, Freigabe offline), bleibt nur das Hauptbackup bestehen.
public string? SecondaryBackupDirectory { get; set; }
public BackupService(string appDataPath)
{
_backupDirectory = Path.Combine(appDataPath, "backups");
Directory.CreateDirectory(_backupDirectory);
}
/// Erstellt ein Backup der übergebenen Datenbankdatei, falls sie existiert.
/// Gibt den Pfad des neuen Backups zurück, oder null, wenn keine Datei vorhanden war.
public string? CreateBackup(string databasePath, int keepCount = DefaultKeepCount)
{
if (!File.Exists(databasePath)) return null;
// Millisekunden + Zufallssuffix, damit mehrere Backups innerhalb derselben Sekunde
// (z.B. mehrfaches Klicken auf "Jetzt sichern") sich nicht gegenseitig überschreiben.
var suffix = Guid.NewGuid().ToString("N")[..8];
var fileName = $"lehrerapp-{DateTime.Now:yyyy-MM-dd_HH-mm-ss-fff}-{suffix}.db";
var target = Path.Combine(_backupDirectory, fileName);
File.Copy(databasePath, target, overwrite: true);
PruneOldBackups(keepCount);
if (!string.IsNullOrWhiteSpace(SecondaryBackupDirectory))
TryMirrorToSecondary(target, fileName, keepCount);
return target;
}
private void TryMirrorToSecondary(string sourcePath, string fileName, int keepCount)
{
try
{
Directory.CreateDirectory(SecondaryBackupDirectory!);
File.Copy(sourcePath, Path.Combine(SecondaryBackupDirectory!, fileName), overwrite: true);
foreach (var stale in Directory.GetFiles(SecondaryBackupDirectory!, "lehrerapp-*.db")
.Select(p => new FileInfo(p))
.OrderByDescending(f => f.LastWriteTime)
.Skip(keepCount))
{
try { stale.Delete(); }
catch { /* nächster Lauf versucht es erneut */ }
}
}
catch
{
// Sicherungsziel nicht erreichbar — das Hauptbackup (_backupDirectory) ist davon
// unberührt, ein manuelles/automatisches Backup soll daran nicht scheitern.
}
}
public List<BackupInfo> ListBackups() =>
Directory.GetFiles(_backupDirectory, "lehrerapp-*.db")
.Select(p => new BackupInfo(p, File.GetLastWriteTime(p), new FileInfo(p).Length))
.OrderByDescending(b => b.CreatedAt)
.ToList();
public void PruneOldBackups(int keepCount = DefaultKeepCount)
{
var backups = ListBackups();
foreach (var stale in backups.Skip(keepCount))
{
try { File.Delete(stale.Path); }
catch { /* nächster Lauf versucht es erneut */ }
}
}
/// Kopiert ein Backup an die Zielposition (z.B. über die aktive Datenbankdatei).
/// Der Aufrufer ist dafür verantwortlich, alle offenen Verbindungen vorher zu schließen.
public void RestoreBackup(string backupPath, string databasePath)
{
if (!File.Exists(backupPath)) throw new FileNotFoundException("Backup nicht gefunden.", backupPath);
File.Copy(backupPath, databasePath, overwrite: true);
}
}
@@ -0,0 +1,147 @@
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
public sealed record CompetencyScore(
double AchievedPoints, double MaxPoints, int EvidenceCount, int ParticipantCount)
{
public double Percent => MaxPoints <= 0 ? 0 : AchievedPoints / MaxPoints * 100.0;
}
public sealed class CompetencyAnalysisItem
{
public required string DomainName { get; init; }
public required string DomainCode { get; init; }
public required string Code { get; init; }
public required string Description { get; init; }
public int PlannedUnitCount { get; init; }
public int TaughtUnitCount { get; init; }
public int PlannedExamCount { get; init; }
public int AssessedExamCount { get; init; }
public CompetencyScore? GroupScore { get; init; }
public IReadOnlyDictionary<Guid, CompetencyScore> StudentScores { get; init; } =
new Dictionary<Guid, CompetencyScore>();
}
public sealed class CompetencyAnalysisService
{
public List<CompetencyAnalysisItem> Analyze(
IReadOnlyList<CompetencyDomain> catalog,
IReadOnlyList<Unit> units,
IReadOnlyList<Exam> exams,
IReadOnlyList<ExamResult> results)
{
var builders = new List<ItemBuilder>();
var byCode = new Dictionary<string, ItemBuilder>(StringComparer.OrdinalIgnoreCase);
foreach (var domain in catalog.OrderBy(x => x.SortOrder))
foreach (var item in domain.Items.OrderBy(x => x.SortOrder))
{
var builder = new ItemBuilder(domain.Name, domain.Code, item.Code, item.Description);
builders.Add(builder);
if (!string.IsNullOrWhiteSpace(item.Code)) byCode.TryAdd(item.Code.Trim(), builder);
}
ItemBuilder GetOrAdd(string rawCode)
{
var code = rawCode.Trim();
if (byCode.TryGetValue(code, out var existing)) return existing;
var added = new ItemBuilder("Nicht im Katalog", "", code,
"Dieser Kompetenzcode wird noch verwendet, ist aber nicht im aktuellen Katalog enthalten.");
byCode[code] = added;
builders.Add(added);
return added;
}
foreach (var unit in units)
foreach (var code in NormalizeCodes(unit.Competencies))
{
var builder = GetOrAdd(code);
if (unit.Status == UnitStatus.Planned) builder.PlannedUnits++;
else builder.TaughtUnits++;
}
var resultsByExam = results.GroupBy(x => x.ExamId).ToDictionary(x => x.Key, x => x.ToList());
foreach (var exam in exams)
{
var examCodes = NormalizeCodes(exam.Tasks.SelectMany(x => x.CompetencyCodes));
foreach (var code in examCodes)
{
var builder = GetOrAdd(code);
if (exam.Status == ExamStatus.Planned) builder.PlannedExams++;
else builder.AssessedExams++;
}
if (exam.Status is ExamStatus.Planned or ExamStatus.Conducted
|| !resultsByExam.TryGetValue(exam.Id, out var examResults)) continue;
foreach (var result in examResults.Where(x => !x.Absent))
for (var taskIndex = 0; taskIndex < exam.Tasks.Count && taskIndex < result.Points.Count; taskIndex++)
{
var task = exam.Tasks[taskIndex];
if (task.MaxPoints <= 0) continue;
var achieved = Math.Clamp(result.Points[taskIndex], 0, task.MaxPoints);
foreach (var code in NormalizeCodes(task.CompetencyCodes))
GetOrAdd(code).AddScore(result.StudentId, achieved, task.MaxPoints);
}
}
return builders.Select(x => x.Build()).ToList();
}
private static bool HasCode(string? value) => !string.IsNullOrWhiteSpace(value);
private static IEnumerable<string> NormalizeCodes(IEnumerable<string> codes) =>
codes.Where(HasCode).Select(x => x.Trim()).Distinct(StringComparer.OrdinalIgnoreCase);
private sealed class ItemBuilder(
string domainName, string domainCode, string code, string description)
{
private readonly Dictionary<Guid, MutableScore> _scores = [];
public int PlannedUnits { get; set; }
public int TaughtUnits { get; set; }
public int PlannedExams { get; set; }
public int AssessedExams { get; set; }
public void AddScore(Guid studentId, double achieved, double maximum)
{
if (!_scores.TryGetValue(studentId, out var score))
_scores[studentId] = score = new MutableScore();
score.Achieved += achieved;
score.Maximum += maximum;
score.EvidenceCount++;
}
public CompetencyAnalysisItem Build()
{
var studentScores = _scores.ToDictionary(x => x.Key, x => x.Value.ToScore(1));
var achieved = _scores.Values.Sum(x => x.Achieved);
var maximum = _scores.Values.Sum(x => x.Maximum);
var evidence = _scores.Values.Sum(x => x.EvidenceCount);
return new CompetencyAnalysisItem
{
DomainName = domainName,
DomainCode = domainCode,
Code = code,
Description = description,
PlannedUnitCount = PlannedUnits,
TaughtUnitCount = TaughtUnits,
PlannedExamCount = PlannedExams,
AssessedExamCount = AssessedExams,
GroupScore = maximum > 0
? new CompetencyScore(achieved, maximum, evidence, _scores.Count)
: null,
StudentScores = studentScores,
};
}
}
private sealed class MutableScore
{
public double Achieved { get; set; }
public double Maximum { get; set; }
public int EvidenceCount { get; set; }
public CompetencyScore ToScore(int participantCount) =>
new(Achieved, Maximum, EvidenceCount, participantCount);
}
}
@@ -0,0 +1,279 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
public enum CompetencyCatalogImportMode { Merge, Replace }
public sealed record CompetencyCatalogImportConflict(
string Id, string Location, string ExistingValue, string ImportedValue);
public sealed class CompetencyCatalogImportPreview
{
public required Guid SubjectId { get; init; }
public required string SubjectName { get; init; }
public required int GradeLevel { get; init; }
public required List<CompetencyDomain> ImportedDomains { get; init; }
public required string ExistingFingerprint { get; init; }
public List<string> Warnings { get; init; } = [];
public List<CompetencyCatalogImportConflict> Conflicts { get; init; } = [];
public int NewDomains { get; init; }
public int NewCompetencies { get; init; }
public int UnchangedCompetencies { get; init; }
}
/// <summary>Analysiert Kompetenzkataloge vollständig, bevor bestehende Daten verändert werden.</summary>
public sealed class CompetencyCatalogImportService(ICompetencyDomainRepository repository)
{
public CompetencyCatalogImportPreview Analyze(
string json, Guid subjectId, string subjectName, int gradeLevel)
{
CatalogDto dto;
try
{
dto = JsonSerializer.Deserialize<CatalogDto>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? throw new InvalidDataException("Die JSON-Datei ist leer.");
}
catch (JsonException ex)
{
throw new InvalidDataException($"Ungültiges JSON: {ex.Message}", ex);
}
if (dto.Domains is null || dto.Domains.Count == 0)
throw new InvalidDataException("Der Import enthält keine Kompetenzbereiche.");
var imported = new List<CompetencyDomain>();
var domainKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var competencyCodes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var domainIndex = 0; domainIndex < dto.Domains.Count; domainIndex++)
{
var sourceDomain = dto.Domains[domainIndex];
var name = sourceDomain.Name?.Trim() ?? "";
var code = sourceDomain.Code?.Trim() ?? "";
if (name.Length == 0)
throw new InvalidDataException($"Kompetenzbereich {domainIndex + 1} besitzt keinen Namen.");
var domainKey = DomainKey(code, name);
if (!domainKeys.Add(domainKey))
throw new InvalidDataException($"Der Kompetenzbereich „{name}“ kommt mehrfach vor.");
var items = new List<CompetencyItem>();
var localKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var (sourceItem, itemIndex) in (sourceDomain.Competencies ?? []).Select((item, index) => (item, index)))
{
var itemCode = sourceItem.Code?.Trim() ?? "";
var description = sourceItem.Description?.Trim() ?? "";
if (description.Length == 0)
throw new InvalidDataException(
$"Kompetenz {itemIndex + 1} im Bereich „{name}“ besitzt keine Beschreibung.");
var itemKey = ItemKey(itemCode, description);
if (!localKeys.Add(itemKey))
throw new InvalidDataException(
$"Die Kompetenz „{(itemCode.Length > 0 ? itemCode : description)}“ kommt im Bereich „{name}“ mehrfach vor.");
if (itemCode.Length > 0 && !competencyCodes.Add(itemCode))
throw new InvalidDataException($"Der Kompetenzcode „{itemCode}“ kommt in mehreren Bereichen vor.");
items.Add(new CompetencyItem
{
Code = itemCode,
Description = description,
SortOrder = itemIndex,
});
}
imported.Add(new CompetencyDomain
{
SubjectId = subjectId,
GradeLevel = gradeLevel,
Name = name,
Code = code,
SortOrder = domainIndex,
Items = items,
});
}
var warnings = new List<string>();
if (!string.IsNullOrWhiteSpace(dto.Subject)
&& !dto.Subject.Trim().Equals(subjectName, StringComparison.OrdinalIgnoreCase))
warnings.Add($"Die Datei nennt das Fach „{dto.Subject.Trim()}“, importiert wird in „{subjectName}“.");
if (dto.GradeLevel is > 0 and <= 13 && dto.GradeLevel != gradeLevel)
warnings.Add($"Die Datei nennt Klassenstufe {dto.GradeLevel}, ausgewählt ist Klassenstufe {gradeLevel}.");
var existing = repository.GetBySubjectAndGrade(subjectId, gradeLevel);
var conflicts = new List<CompetencyCatalogImportConflict>();
var newDomains = 0;
var newItems = 0;
var unchanged = 0;
var existingItemsByCode = existing
.SelectMany(domain => domain.Items.Select(item => (Domain: domain, Item: item)))
.Where(x => !string.IsNullOrWhiteSpace(x.Item.Code))
.GroupBy(x => x.Item.Code, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
foreach (var importedDomain in imported)
{
var domainKey = DomainKey(importedDomain.Code, importedDomain.Name);
var existingDomain = existing.FirstOrDefault(d => DomainKey(d.Code, d.Name) == domainKey);
if (existingDomain is null) newDomains++;
else if (existingDomain.Name != importedDomain.Name)
conflicts.Add(new($"domain:{domainKey}", $"Bereich {importedDomain.Code}",
existingDomain.Name, importedDomain.Name));
foreach (var importedItem in importedDomain.Items)
{
if (importedItem.Code.Length > 0
&& existingItemsByCode.TryGetValue(importedItem.Code, out var found))
{
if (found.Item.Description == importedItem.Description
&& existingDomain?.Id == found.Domain.Id) unchanged++;
else
conflicts.Add(new($"item:{importedItem.Code.ToUpperInvariant()}",
$"Kompetenz {importedItem.Code}",
$"{found.Item.Description} [{found.Domain.Name}]",
$"{importedItem.Description} [{importedDomain.Name}]"));
continue;
}
var existingWithoutCode = existingDomain?.Items.FirstOrDefault(i =>
i.Code.Length == 0 && i.Description.Equals(importedItem.Description, StringComparison.OrdinalIgnoreCase));
if (existingWithoutCode is not null) unchanged++;
else newItems++;
}
}
return new CompetencyCatalogImportPreview
{
SubjectId = subjectId,
SubjectName = subjectName,
GradeLevel = gradeLevel,
ImportedDomains = imported,
ExistingFingerprint = Fingerprint(existing),
Warnings = warnings,
Conflicts = conflicts.DistinctBy(c => c.Id).ToList(),
NewDomains = newDomains,
NewCompetencies = newItems,
UnchangedCompetencies = unchanged,
};
}
public void Apply(CompetencyCatalogImportPreview preview, CompetencyCatalogImportMode mode,
IReadOnlySet<string>? useImportedConflicts = null)
{
var current = repository.GetBySubjectAndGrade(preview.SubjectId, preview.GradeLevel);
if (Fingerprint(current) != preview.ExistingFingerprint)
throw new InvalidOperationException(
"Der Kompetenzkatalog wurde seit der Vorschau verändert. Bitte öffne den Import erneut.");
List<CompetencyDomain> result = mode == CompetencyCatalogImportMode.Replace
? preview.ImportedDomains.Select((d, index) => CloneAsNew(d, preview.SubjectId, preview.GradeLevel, index)).ToList()
: Merge(current, preview, useImportedConflicts ?? new HashSet<string>());
repository.ReplaceForSubjectAndGrade(preview.SubjectId, preview.GradeLevel, result);
}
private static List<CompetencyDomain> Merge(List<CompetencyDomain> existing,
CompetencyCatalogImportPreview preview, IReadOnlySet<string> useImported)
{
var result = existing.Select(ClonePreservingIds).OrderBy(d => d.SortOrder).ToList();
foreach (var importedDomain in preview.ImportedDomains)
{
var domainKey = DomainKey(importedDomain.Code, importedDomain.Name);
var target = result.FirstOrDefault(d => DomainKey(d.Code, d.Name) == domainKey);
if (target is null)
{
target = CloneAsNew(importedDomain, preview.SubjectId, preview.GradeLevel, result.Count);
target.Items.Clear();
result.Add(target);
}
else if (useImported.Contains($"domain:{domainKey}")) target.Name = importedDomain.Name;
foreach (var importedItem in importedDomain.Items)
{
if (importedItem.Code.Length == 0)
{
if (!target.Items.Any(i => i.Code.Length == 0
&& i.Description.Equals(importedItem.Description, StringComparison.OrdinalIgnoreCase)))
target.Items.Add(CloneItemAsNew(importedItem, target.Items.Count));
continue;
}
var located = result.SelectMany(d => d.Items.Select(i => (Domain: d, Item: i)))
.FirstOrDefault(x => x.Item.Code.Equals(importedItem.Code, StringComparison.OrdinalIgnoreCase));
if (located.Item is null)
{
target.Items.Add(CloneItemAsNew(importedItem, target.Items.Count));
continue;
}
var conflictId = $"item:{importedItem.Code.ToUpperInvariant()}";
if (!useImported.Contains(conflictId)) continue;
located.Domain.Items.Remove(located.Item);
located.Item.Description = importedItem.Description;
located.Item.Code = importedItem.Code;
located.Item.SortOrder = target.Items.Count;
target.Items.Add(located.Item);
NormalizeItemOrder(located.Domain);
}
}
for (var i = 0; i < result.Count; i++) result[i].SortOrder = i;
return result;
}
private static CompetencyDomain ClonePreservingIds(CompetencyDomain source) => new()
{
Id = source.Id, SubjectId = source.SubjectId, GradeLevel = source.GradeLevel,
Name = source.Name, Code = source.Code, SortOrder = source.SortOrder,
Items = source.Items.OrderBy(i => i.SortOrder).Select(i => new CompetencyItem
{
Id = i.Id, Code = i.Code, Description = i.Description, SortOrder = i.SortOrder,
}).ToList(),
};
private static CompetencyDomain CloneAsNew(CompetencyDomain source, Guid subjectId, int gradeLevel, int sortOrder) => new()
{
SubjectId = subjectId, GradeLevel = gradeLevel, Name = source.Name, Code = source.Code,
SortOrder = sortOrder,
Items = source.Items.OrderBy(i => i.SortOrder).Select((i, index) => CloneItemAsNew(i, index)).ToList(),
};
private static CompetencyItem CloneItemAsNew(CompetencyItem source, int sortOrder) => new()
{
Code = source.Code, Description = source.Description, SortOrder = sortOrder,
};
private static void NormalizeItemOrder(CompetencyDomain domain)
{
for (var i = 0; i < domain.Items.Count; i++) domain.Items[i].SortOrder = i;
}
private static string DomainKey(string? code, string? name) =>
!string.IsNullOrWhiteSpace(code) ? $"code:{code.Trim().ToLowerInvariant()}" : $"name:{name?.Trim().ToLowerInvariant()}";
private static string ItemKey(string? code, string description) =>
!string.IsNullOrWhiteSpace(code) ? $"code:{code.Trim().ToLowerInvariant()}" : $"description:{description.ToLowerInvariant()}";
private static string Fingerprint(IEnumerable<CompetencyDomain> domains) => string.Join("|",
domains.OrderBy(d => d.SortOrder).ThenBy(d => d.Id).Select(d =>
$"{d.Id:N}:{d.UpdatedAt.Ticks}:{d.Name}:{d.Code}:" +
string.Join(";", d.Items.OrderBy(i => i.SortOrder).Select(i => $"{i.Id:N}:{i.Code}:{i.Description}:{i.SortOrder}"))));
private sealed class CatalogDto
{
[JsonPropertyName("subject")] public string? Subject { get; set; }
[JsonPropertyName("gradeLevel")] public int GradeLevel { get; set; }
[JsonPropertyName("domains")] public List<DomainDto>? Domains { get; set; }
}
private sealed class DomainDto
{
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("code")] public string? Code { get; set; }
[JsonPropertyName("competencies")] public List<ItemDto>? Competencies { get; set; }
}
private sealed class ItemDto
{
[JsonPropertyName("code")] public string? Code { get; set; }
[JsonPropertyName("description")] public string? Description { get; set; }
}
}
@@ -0,0 +1,65 @@
using System.Text.Json;
namespace LehrerApp.Core.Services;
public sealed class DashboardCardSetting
{
public string Key { get; set; } = "";
public bool IsVisible { get; set; } = true;
public int Order { get; set; }
}
/// <summary>Speichert Sichtbarkeit und Reihenfolge der Dashboard-Kacheln lokal.</summary>
public sealed class DashboardSettingsService
{
// "attention" fasst die frueheren sieben Kacheln missingteachingtime/excuses/corrections/
// unplanned/alerts/attendance/support zusammen (siehe AttentionItem.cs im Desktop-Projekt).
// Alte gespeicherte dashboardsettings.json-Dateien mit den frueheren Keys sind unproblematisch:
// Load() unten verwirft unbekannte Keys ohnehin stillschweigend und ergaenzt neue als sichtbar.
public static readonly string[] DefaultCardOrder =
[
"today", "tasks", "attention", "calendar", "upcoming", "groups", "examload",
];
private readonly string _configPath;
public DashboardSettingsService(string appDataPath) =>
_configPath = Path.Combine(appDataPath, "dashboardsettings.json");
public List<DashboardCardSetting> Load()
{
try
{
if (File.Exists(_configPath))
{
var saved = JsonSerializer.Deserialize<List<DashboardCardSetting>>(
File.ReadAllText(_configPath)) ?? [];
var byKey = saved
.Where(s => DefaultCardOrder.Contains(s.Key))
.GroupBy(s => s.Key).ToDictionary(g => g.Key, g => g.First());
return DefaultCardOrder.Select((key, defaultOrder) => byKey.TryGetValue(key, out var item)
? new DashboardCardSetting { Key = key, IsVisible = item.IsVisible, Order = item.Order }
: new DashboardCardSetting { Key = key, IsVisible = true, Order = defaultOrder })
.OrderBy(s => s.Order).ThenBy(s => Array.IndexOf(DefaultCardOrder, s.Key))
.Select((s, index) => new DashboardCardSetting
{ Key = s.Key, IsVisible = s.IsVisible, Order = index })
.ToList();
}
}
catch { /* beschädigte Konfiguration -> Standardreihenfolge */ }
return DefaultCardOrder.Select((key, index) => new DashboardCardSetting
{ Key = key, IsVisible = true, Order = index }).ToList();
}
public void Save(IEnumerable<DashboardCardSetting> settings)
{
var normalized = settings.Select((s, index) => new DashboardCardSetting
{
Key = s.Key,
IsVisible = s.IsVisible,
Order = index,
}).ToList();
File.WriteAllText(_configPath, JsonSerializer.Serialize(normalized));
}
}
@@ -0,0 +1,67 @@
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
/// Zählt den Korrekturfortschritt einer Klausur aus den vorhandenen Ergebnissen, statt einen
/// eigenen "Korrektur läuft"-Status pflegen zu müssen — ein `ExamResult` gilt als erledigt, sobald
/// entweder Punkte/Note eingetragen oder der Schüler als abwesend markiert wurde. Von
/// DashboardViewModel (Karte "Offene Korrekturen") und ExamsOverviewViewModel (Klausuren-
/// Hauptseite) gemeinsam genutzt, damit beide Stellen exakt dieselbe Zahl zeigen.
public static class ExamCorrectionCounter
{
public static (int Expected, int Evaluated) Count(Exam exam, List<GroupMembership> groupMemberships,
List<ExamResult> examResults)
{
var expected = groupMemberships.Count(m => GroupMembershipService.IsActiveOn(m, exam.Date));
var evaluated = examResults.Count(r => r.Absent || !string.IsNullOrWhiteSpace(r.Grade) || r.Points.Count > 0);
return (expected, Math.Min(evaluated, expected));
}
}
public enum ExamListStatus { Planned, AwaitingCorrection, CorrectionInProgress, CorrectionStuck, AwaitingReturn, Returned }
public readonly record struct ExamPriorityInfo(ExamListStatus Status, double Score);
/// Ordnet Klausuren für die Klausuren-Hauptseite nach einem unsichtbaren Prioritäts-Score statt
/// nach Datum: unbearbeitete/überfällige Korrekturen oben, erledigte unten. "Korrektur läuft"
/// wird bewusst nicht als eigener, manuell zu pflegender Status abgelegt (siehe
/// `ExamCorrectionCounter`) — bleibt eine Klausur trotzdem lange in Bearbeitung hängen (typischer
/// Grund: ein einzelner nie nachschreibender Schüler), fällt sie nach `StuckAfterDays` aus der
/// dringenden Zone in eine ruhige "hängt fest"-Einstufung, statt dauerhaft oben zu kleben.
public static class ExamPriorityService
{
public const int StuckAfterDays = 21;
private const int CorrectionGraceDays = 3;
public static ExamPriorityInfo Evaluate(Exam exam, int expected, int evaluated, DateOnly today)
{
if (exam.Status == ExamStatus.Returned)
return new ExamPriorityInfo(ExamListStatus.Returned, -100);
if (exam.Status == ExamStatus.Graded)
return new ExamPriorityInfo(ExamListStatus.AwaitingReturn, 300);
if (exam.Status == ExamStatus.Planned)
{
var daysUntil = exam.Date.DayNumber - today.DayNumber;
return new ExamPriorityInfo(ExamListStatus.Planned, Math.Clamp(150 - daysUntil, 20, 150));
}
// Status == Conducted: Korrekturfortschritt entscheidet über die genaue Einstufung.
var daysSince = today.DayNumber - exam.Date.DayNumber;
if (evaluated <= 0)
{
var score = daysSince <= CorrectionGraceDays ? 400 + daysSince * 10 : 600 + daysSince;
return new ExamPriorityInfo(ExamListStatus.AwaitingCorrection, score);
}
if (expected > 0 && evaluated >= expected)
return new ExamPriorityInfo(ExamListStatus.AwaitingReturn, 300);
if (daysSince > StuckAfterDays)
return new ExamPriorityInfo(ExamListStatus.CorrectionStuck, 90);
return new ExamPriorityInfo(ExamListStatus.CorrectionInProgress, 400 + daysSince * 3);
}
}
@@ -0,0 +1,54 @@
using System.Globalization;
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
/// <summary>
/// Zählt eigene Klausuren pro ISO-Kalenderwoche über alle Lerngruppen hinweg. Nutzer-Feedback:
/// die klassenbezogene Kollisionsprüfung (mehrere Klausuren einer einzelnen Klasse in einer
/// Woche) übernimmt bereits der schulische Klausurplaner (externes Klassenbuch) — was dort
/// fehlt, ist die persönliche Belastung der Lehrkraft: mehr eigene Klausuren in derselben Woche
/// bedeuten unabhängig von der Klasse mehr Erstellungsaufwand vorher und mehr Korrekturaufwand
/// danach. Da eine LiteDB-Datei genau einem Nutzer gehört (siehe CLAUDE.md), sind "alle
/// Klausuren in der Datenbank" bereits gleichbedeutend mit "meine Klausuren".
/// </summary>
public static class ExamWeekLoadService
{
/// Ab dieser Anzahl eigener Klausuren in derselben Woche gilt die Woche als spürbar belastet.
/// Nutzer-Feedback: zwei Klausuren aus zwei unterrichteten Fächern in derselben Woche sind
/// normal, eine dritte ist die eigentliche Häufung.
public const int WarningThreshold = 3;
/// Anzahl Klausuren (ohne <paramref name="excludeExamId"/>, z.B. die gerade im Dialog
/// bearbeitete), die in dieselbe ISO-Kalenderwoche wie <paramref name="date"/> fallen.
public static int CountInSameWeek(IEnumerable<Exam> allExams, DateOnly date, Guid? excludeExamId = null)
{
var (year, week) = IsoWeek(date);
return allExams.Count(e => e.Id != excludeExamId && IsoWeek(e.Date) == (year, week));
}
/// Gruppiert die übergebenen Klausuren nach ISO-Kalenderwoche und liefert nur die Wochen mit
/// mindestens <see cref="WarningThreshold"/> Klausuren, chronologisch sortiert — für einen
/// vorausschauenden Überblick (z.B. Dashboard), nicht für die Einzelprüfung beim Anlegen.
public static List<ExamWeekLoad> FindOverloadedWeeks(IEnumerable<Exam> exams) =>
exams
.GroupBy(e => IsoWeek(e.Date))
.Where(g => g.Count() >= WarningThreshold)
.Select(g => new ExamWeekLoad(g.Key.Year, g.Key.Week, WeekStart(g.Key.Year, g.Key.Week), g.Count()))
.OrderBy(w => w.WeekStart)
.ToList();
private static (int Year, int Week) IsoWeek(DateOnly date)
{
var dt = date.ToDateTime(TimeOnly.MinValue);
return (ISOWeek.GetYear(dt), ISOWeek.GetWeekOfYear(dt));
}
private static DateOnly WeekStart(int isoYear, int isoWeek) =>
DateOnly.FromDateTime(ISOWeek.ToDateTime(isoYear, isoWeek, DayOfWeek.Monday));
}
public readonly record struct ExamWeekLoad(int IsoYear, int IsoWeek, DateOnly WeekStart, int ExamCount)
{
public DateOnly WeekEnd => WeekStart.AddDays(6);
}
+17 -1
View File
@@ -77,13 +77,29 @@ public class GradingService
public double WeightedAverage(List<(string Grade, double Weight)> grades) public double WeightedAverage(List<(string Grade, double Weight)> grades)
{ {
var numeric = grades var numeric = grades
.Select(g => (Value: int.TryParse(g.Grade, out var n) ? (int?)n : null, g.Weight)) .Select(g => (Value: ParseGradeValue(g.Grade), g.Weight))
.Where(g => g.Value.HasValue).ToList(); .Where(g => g.Value.HasValue).ToList();
if (numeric.Count == 0) return 0; if (numeric.Count == 0) return 0;
var total = numeric.Sum(g => g.Weight); var total = numeric.Sum(g => g.Weight);
return total == 0 ? 0 : numeric.Sum(g => g.Value!.Value * g.Weight) / total; return total == 0 ? 0 : numeric.Sum(g => g.Value!.Value * g.Weight) / total;
} }
/// Wandelt eine Notenangabe mit optionaler Tendenz ("3+", "2-") in einen rechnerischen
/// Notenwert um. Tendenzen verschieben um ein Drittel in Richtung der besseren Note
/// (z.B. "3+" → 2,67, "2-" → 2,33) — der in deutschen Notensystemen übliche Drittelabstand.
private static double? ParseGradeValue(string grade)
{
var trimmed = grade.Trim();
if (trimmed.Length == 0) return null;
var tendency = 0.0;
var core = trimmed;
if (core.EndsWith('+')) { tendency = -1.0 / 3.0; core = core[..^1].TrimEnd(); }
else if (core.EndsWith('-')) { tendency = 1.0 / 3.0; core = core[..^1].TrimEnd(); }
return int.TryParse(core, out var n) ? n + tendency : null;
}
/// Rundet einen rechnerischen Notenwert auf eine ganze Note/Punktzahl. /// Rundet einen rechnerischen Notenwert auf eine ganze Note/Punktzahl.
/// "Kaufmännisch" rundet bei genau 0,5 immer vom Nullpunkt weg (Standard). /// "Kaufmännisch" rundet bei genau 0,5 immer vom Nullpunkt weg (Standard).
/// "Pädagogisch" rundet bei genau 0,5 in Richtung der besseren Note /// "Pädagogisch" rundet bei genau 0,5 in Richtung der besseren Note
@@ -0,0 +1,64 @@
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
public enum SchoolYearPeriodKind { FullYear, H1, H2 }
/// <summary>
/// Gemeinsame Regeln für zeitlich begrenzte Gruppenzugehörigkeiten.
/// Eintritt und Austritt gelten unabhängig vom gewählten Halbjahresmodus.
/// </summary>
public static class GroupMembershipService
{
public static bool IsActiveOn(GroupMembership membership, DateOnly date)
{
if (membership.JoinedAt is { } joinedAt && date < joinedAt) return false;
if (membership.LeftAt is { } leftAt && date > leftAt) return false;
return membership.Period switch
{
MembershipPeriod.H1Only => IsFirstSemester(date),
MembershipPeriod.H2Only => !IsFirstSemester(date),
_ => true,
};
}
public static bool Overlaps(GroupMembership membership, DateOnly from, DateOnly to)
{
if (to < from) return false;
var effectiveFrom = membership.JoinedAt is { } joinedAt && joinedAt > from ? joinedAt : from;
var effectiveTo = membership.LeftAt is { } leftAt && leftAt < to ? leftAt : to;
if (effectiveTo < effectiveFrom) return false;
if (membership.Period is MembershipPeriod.FullYear or MembershipPeriod.Custom) return true;
for (var month = new DateOnly(effectiveFrom.Year, effectiveFrom.Month, 1);
month <= effectiveTo;
month = month.AddMonths(1))
{
if (membership.Period == MembershipPeriod.H1Only && IsFirstSemester(month)) return true;
if (membership.Period == MembershipPeriod.H2Only && !IsFirstSemester(month)) return true;
}
return false;
}
public static (DateOnly From, DateOnly To) SchoolYearPeriod(
string schoolYear, SchoolYearPeriodKind period)
{
var slash = schoolYear.IndexOf('/');
var startText = slash >= 0 ? schoolYear[..slash] : schoolYear;
if (!int.TryParse(startText, out var startYear))
throw new ArgumentException("Ungültiges Schuljahr.", nameof(schoolYear));
return period switch
{
SchoolYearPeriodKind.H1 => (new DateOnly(startYear, 8, 1), new DateOnly(startYear + 1, 1, 31)),
SchoolYearPeriodKind.H2 => (new DateOnly(startYear + 1, 2, 1), new DateOnly(startYear + 1, 7, 31)),
_ => (new DateOnly(startYear, 8, 1), new DateOnly(startYear + 1, 7, 31)),
};
}
private static bool IsFirstSemester(DateOnly date) => date.Month >= 8 || date.Month <= 1;
}
@@ -0,0 +1,111 @@
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
public sealed record GroupRolloverRequest(
string Name,
string TargetSchoolYear,
int GradeLevel,
IReadOnlyCollection<Guid> MembershipIds,
bool CopyGradingScheme,
bool ArchiveSource);
/// <summary>Erzeugt eine fachlich saubere Folgegruppe ohne Leistungs- oder Planungsdaten.</summary>
public sealed class GroupRolloverService(
IGroupRepository groups,
IGroupMembershipRepository memberships,
IGradingSchemeRepository gradingSchemes,
SchoolYearService schoolYears)
{
public LearningGroup RollOver(LearningGroup source, GroupRolloverRequest request)
{
var targetSchoolYear = request.TargetSchoolYear.Trim();
var targetStart = schoolYears.SchoolYearStart(targetSchoolYear);
if (targetSchoolYear != schoolYears.FormatSchoolYear(targetStart.Year))
throw new InvalidOperationException("Das Zielschuljahr muss das Format JJJJ/JJ haben, z.B. 2027/28.");
var sourceStart = schoolYears.SchoolYearStart(source.SchoolYear);
if (targetStart <= sourceStart)
throw new InvalidOperationException("Das Zielschuljahr muss nach dem bisherigen Schuljahr liegen.");
if (request.GradeLevel is < 1 or > 13)
throw new InvalidOperationException("Die Klassenstufe muss zwischen 1 und 13 liegen.");
if (string.IsNullOrWhiteSpace(request.Name))
throw new InvalidOperationException("Bitte einen Gruppennamen angeben.");
var duplicate = groups.GetBySchoolYear(targetSchoolYear, includeInactive: true)
.Any(g => g.Name.Equals(request.Name.Trim(), StringComparison.OrdinalIgnoreCase)
&& g.SubjectId == source.SubjectId && g.Type == source.Type);
if (duplicate)
throw new InvalidOperationException(
"Im Zielschuljahr existiert bereits eine gleichnamige Gruppe mit demselben Fach und Typ.");
var sourceMemberships = memberships.GetByGroup(source.Id)
.ToDictionary(m => m.Id);
var selected = request.MembershipIds.Distinct()
.Select(id => sourceMemberships.GetValueOrDefault(id)
?? throw new InvalidOperationException("Eine ausgewählte Mitgliedschaft gehört nicht mehr zur Ausgangsgruppe."))
.ToList();
if (selected.Count == 0)
throw new InvalidOperationException("Bitte mindestens einen Schüler auswählen.");
var target = new LearningGroup
{
Name = request.Name.Trim(),
Type = source.Type,
SubjectId = source.SubjectId,
SchoolYear = targetSchoolYear,
GradeLevel = request.GradeLevel,
GradingSystem = source.GradingSystem,
HoursPerWeek = source.HoursPerWeek,
IsActive = true,
IsOwnClass = source.IsOwnClass,
IsDifferentiated = source.IsDifferentiated,
PreviousGroupId = source.Id,
// WebUntisLessonId bewusst nicht übernommen: WebUntis vergibt sie pro Schuljahr neu,
// eine übernommene alte lsid würde in der Folgegruppe stumm falsche Fehlzeiten liefern.
};
var sourceWasActive = source.IsActive;
try
{
groups.Save(target);
foreach (var membership in selected)
{
memberships.Save(new GroupMembership
{
StudentId = membership.StudentId,
GroupId = target.Id,
AddedOn = DateOnly.FromDateTime(DateTime.Today),
Period = MembershipPeriod.FullYear,
JoinedAt = targetStart,
LeftAt = null,
Niveau = membership.Niveau,
});
}
if (request.CopyGradingScheme && gradingSchemes.GetByGroup(source.Id) is { } scheme)
{
gradingSchemes.Save(new GradingScheme
{
GroupId = target.Id,
ExamsPercent = scheme.ExamsPercent,
ParticipationPercent = scheme.ParticipationPercent,
OtherPercent = scheme.OtherPercent,
});
}
if (request.ArchiveSource && source.IsActive)
{
source.IsActive = false;
groups.Save(source);
}
return target;
}
catch
{
source.IsActive = sourceWasActive;
groups.Delete(target.Id);
throw;
}
}
}
+143
View File
@@ -0,0 +1,143 @@
using System.Globalization;
using System.Text;
namespace LehrerApp.Core.Services;
/// <summary>Ein einzelner Termin (VEVENT) aus einer geparsten iCal-Datei — z.B. aus dem
/// WebUntis-Stundenplan-Export (TODO.md, "WebUntis-iCal-Abgleich").</summary>
public class UntisIcsEvent
{
public string Uid { get; set; } = "";
public DateOnly Date { get; set; }
public DayOfWeek Weekday => Date.DayOfWeek;
public TimeOnly StartTime { get; set; }
public TimeOnly EndTime { get; set; }
public string? Summary { get; set; }
public string Location { get; set; } = "";
public string Description { get; set; } = "";
public string Status { get; set; } = "CONFIRMED";
}
/// <summary>
/// Bewusst kein vollständiger RFC-5545-Parser, sondern genau der Ausschnitt, den der reale
/// WebUntis-Export tatsächlich nutzt (verifiziert an einem echten Export, siehe Planungsdokument):
/// flache VEVENT-Liste ohne RRULE-Wiederholung, ohne Line-Folding, ohne VALARM. `DTSTART;TZID=...`
/// wird als lokale Wanduhrzeit gelesen (die App kennt ohnehin nur "lokale Zeit" der Schule, siehe
/// PeriodScheduleService) — ein `...Z`-Suffix (UTC) wird defensiv unterstützt, auch wenn im realen
/// Export nicht beobachtet. Termine mit unbekanntem DTSTART-Format werden übersprungen statt die
/// gesamte Verarbeitung abzubrechen (gleiche Fail-soft-Haltung wie EventApplier in LehrerApp.Sync).
/// </summary>
public static class IcsParser
{
private static readonly TimeZoneInfo BerlinTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
public static List<UntisIcsEvent> Parse(string icsText)
{
var lines = Unfold(icsText);
var events = new List<UntisIcsEvent>();
Dictionary<string, (string Params, string Value)>? current = null;
foreach (var line in lines)
{
if (line == "BEGIN:VEVENT") { current = []; continue; }
if (line == "END:VEVENT")
{
if (current is not null && TryBuildEvent(current, out var evt)) events.Add(evt);
current = null;
continue;
}
if (current is null) continue;
var (key, paramsPart, value) = SplitPropertyLine(line);
if (key.Length == 0) continue;
current[key] = (paramsPart, value);
}
return events;
}
// RFC 5545 Line-Folding: eine Fortsetzungszeile beginnt mit Leerzeichen/Tab und gehört zur
// Vorzeile — im realen WebUntis-Export nicht beobachtet (Zeilen deutlich unter dem 75-Oktett-
// Limit), aber defensiv unterstützt, falls sich der Export einmal ändert.
private static List<string> Unfold(string icsText)
{
var rawLines = icsText.Replace("\r\n", "\n").Replace("\r", "\n").Split('\n');
var result = new List<string>();
foreach (var raw in rawLines)
{
if ((raw.StartsWith(' ') || raw.StartsWith('\t')) && result.Count > 0)
result[^1] += raw[1..];
else
result.Add(raw);
}
return result;
}
// "KEY;PARAM=VAL;PARAM2=VAL2:VALUE" — der erste unescapte Doppelpunkt trennt Wert vom Rest.
private static (string Key, string Params, string Value) SplitPropertyLine(string line)
{
var colonIndex = line.IndexOf(':');
if (colonIndex < 0) return ("", "", "");
var head = line[..colonIndex];
var value = line[(colonIndex + 1)..];
var semiIndex = head.IndexOf(';');
return semiIndex < 0 ? (head, "", value) : (head[..semiIndex], head[(semiIndex + 1)..], value);
}
private static bool TryBuildEvent(Dictionary<string, (string Params, string Value)> props, out UntisIcsEvent evt)
{
evt = new UntisIcsEvent();
if (!props.TryGetValue("UID", out var uid) || string.IsNullOrWhiteSpace(uid.Value)) return false;
evt.Uid = uid.Value;
if (!props.TryGetValue("DTSTART", out var dtStart) || !TryParseLocalDateTime(dtStart, out var start))
return false;
evt.Date = DateOnly.FromDateTime(start);
evt.StartTime = TimeOnly.FromDateTime(start);
if (props.TryGetValue("DTEND", out var dtEnd) && TryParseLocalDateTime(dtEnd, out var end))
evt.EndTime = TimeOnly.FromDateTime(end);
evt.Summary = props.TryGetValue("SUMMARY", out var summary) && !string.IsNullOrWhiteSpace(summary.Value)
? Unescape(summary.Value) : null;
evt.Location = props.TryGetValue("LOCATION", out var location) ? Unescape(location.Value) : "";
evt.Description = props.TryGetValue("DESCRIPTION", out var description) ? Unescape(description.Value) : "";
evt.Status = props.TryGetValue("STATUS", out var status) && !string.IsNullOrWhiteSpace(status.Value)
? status.Value : "CONFIRMED";
return true;
}
// "TZID=Europe/Berlin:20260817T075000" (lokale Wanduhrzeit) oder "20260817T075000Z" (UTC).
private static bool TryParseLocalDateTime((string Params, string Value) prop, out DateTime local)
{
local = default;
var value = prop.Value;
var isUtc = value.EndsWith('Z');
var digits = isUtc ? value[..^1] : value;
if (!DateTime.TryParseExact(digits, "yyyyMMdd'T'HHmmss", CultureInfo.InvariantCulture,
DateTimeStyles.None, out var parsed))
return false;
local = isUtc
? TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(parsed, DateTimeKind.Utc), BerlinTimeZone)
: parsed;
return true;
}
private static string Unescape(string value)
{
var sb = new StringBuilder(value.Length);
for (var i = 0; i < value.Length; i++)
{
if (value[i] == '\\' && i + 1 < value.Length)
{
var next = value[i + 1];
sb.Append(next switch { ';' => ';', ',' => ',', 'n' or 'N' => '\n', '\\' => '\\', _ => next });
i++;
}
else sb.Append(value[i]);
}
return sb.ToString();
}
}
@@ -0,0 +1,99 @@
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
/// <summary>Gemeinsame Terminlogik für Verschieben und Trennen von Stunden.</summary>
public sealed class LessonSchedulingService(ILessonRepository lessons)
{
public void Move(Lesson lesson, DateOnly newDate, int? newPeriod, bool shiftFollowing)
{
EnsureTargetIsFree(lesson, newDate, newPeriod);
var oldDate = lesson.Date;
var delta = newDate.DayNumber - oldDate.DayNumber;
if (shiftFollowing && delta != 0)
{
foreach (var other in lessons.GetByUnit(lesson.UnitId))
{
if (other.Id == lesson.Id || other.Status is LessonStatus.Conducted or LessonStatus.Cancelled || other.Date <= oldDate)
continue;
other.Date = other.Date.AddDays(delta);
lessons.Save(other);
}
}
lesson.Date = newDate;
if (newPeriod.HasValue) lesson.LessonNumber = newPeriod;
lessons.Save(lesson);
}
public Lesson SplitAndMoveSecondPart(Lesson source, int splitAfterMinutes, DateOnly newDate,
int newPeriod, TimeOnly? newStartTime)
{
if (splitAfterMinutes <= 0 || source.Phases.Sum(p => p.DurationMinutes) <= splitAfterMinutes)
throw new InvalidOperationException("Der Verlauf reicht nicht über die erste Stunde hinaus.");
EnsureTargetIsFree(source, newDate, newPeriod);
var first = new List<LessonPhaseStep>();
var second = new List<LessonPhaseStep>();
var elapsed = 0;
foreach (var phase in source.Phases)
{
var remainingInFirst = splitAfterMinutes - elapsed;
if (remainingInFirst <= 0)
second.Add(Clone(phase, phase.DurationMinutes));
else if (phase.DurationMinutes <= remainingInFirst)
first.Add(Clone(phase, phase.DurationMinutes));
else
{
first.Add(Clone(phase, remainingInFirst));
second.Add(Clone(phase, phase.DurationMinutes - remainingInFirst));
}
elapsed += phase.DurationMinutes;
}
source.Phases = first;
var continuation = new Lesson
{
UnitId = source.UnitId,
GroupId = source.GroupId,
Date = newDate,
LessonNumber = newPeriod,
StartTime = newStartTime,
Topic = string.IsNullOrWhiteSpace(source.Topic) ? "Fortsetzung" : $"{source.Topic} Fortsetzung",
Phases = second,
Homework = source.Homework,
HomeworkChecked = source.HomeworkChecked,
HomeworkCheckDismissed = source.HomeworkCheckDismissed,
Reflection = source.Reflection,
Status = source.Status is LessonStatus.Conducted or LessonStatus.Cancelled ? LessonStatus.Draft : source.Status,
};
source.Homework = null;
source.HomeworkChecked = false;
source.HomeworkCheckDismissed = false;
source.Reflection = null;
lessons.Save(source);
lessons.Save(continuation);
return continuation;
}
private void EnsureTargetIsFree(Lesson source, DateOnly date, int? period)
{
if (period is null) return;
var occupied = lessons.GetByGroupAndDate(source.GroupId, date)
.Any(l => l.Id != source.Id && l.LessonNumber == period);
if (occupied)
throw new InvalidOperationException($"Für die Lerngruppe existiert am {date:dd.MM.yyyy} in der {period}. Stunde bereits eine Planung.");
}
private static LessonPhaseStep Clone(LessonPhaseStep source, int duration) => new()
{
Name = source.Name,
DurationMinutes = duration,
Activity = source.Activity,
Material = source.Material,
Shorthand = source.Shorthand,
AlternativePathId = source.AlternativePathId,
};
}
@@ -0,0 +1,40 @@
using System.Text.Json;
namespace LehrerApp.Core.Services;
/// <summary>Gewichte für ClassTeacherRosterRow.PatternScore (Klassenlehrer-Bereich, "Gesamtbild")
/// — Default-Werte entsprechen den ursprünglich fest verdrahteten Konstanten.</summary>
public sealed class PatternScoreWeights
{
public double UnexcusedDayWeight { get; set; } = 3.0;
public double ExcusedDayWeight { get; set; } = 1.0;
public double LateDayWeight { get; set; } = 0.5;
public double NegativeClassRegisterEntryWeight { get; set; } = 1.0;
public double ConcerningKeywordBonus { get; set; } = 1.5;
public double SuspensionKeywordWeight { get; set; } = 15.0;
}
/// <summary>Speichert die Gewichte lokal, gleiches Muster wie WorkloadSettingsService/
/// DashboardSettingsService.</summary>
public sealed class PatternScoreSettingsService
{
private readonly string _configPath;
public PatternScoreSettingsService(string appDataPath) =>
_configPath = Path.Combine(appDataPath, "patternscoreweights.json");
public PatternScoreWeights Load()
{
try
{
if (File.Exists(_configPath))
return JsonSerializer.Deserialize<PatternScoreWeights>(File.ReadAllText(_configPath))
?? new PatternScoreWeights();
}
catch { /* beschädigte Konfiguration -> Standardgewichte */ }
return new PatternScoreWeights();
}
public void Save(PatternScoreWeights weights) =>
File.WriteAllText(_configPath, JsonSerializer.Serialize(weights));
}
@@ -0,0 +1,62 @@
using System.Text.Json;
namespace LehrerApp.Core.Services;
public class PeriodTimeEntry
{
public int PeriodNumber { get; set; }
public TimeOnly Start { get; set; }
public TimeOnly End { get; set; }
}
internal class PeriodScheduleConfig
{
public List<PeriodTimeEntry> Periods { get; set; } = [];
}
/// <summary>
/// Uhrzeiten der Einzelstunden (Stundenraster) — Grundlage für die Zeitbedarf-Rückmeldung im
/// Verlaufsplan-Editor (4.2.2 Nachtrag). Nicht jede Schule/Stunde muss konfiguriert sein: fehlt
/// eine Stunde, liefert <see cref="GetDurationMinutes"/> 0 statt eines Standardwerts — die
/// Zeitbedarf-Anzeige blendet sich dann einfach aus, statt eine erfundene Dauer vorzutäuschen.
/// </summary>
public class PeriodScheduleService
{
private readonly string _configPath;
private PeriodScheduleConfig _config;
public IReadOnlyList<PeriodTimeEntry> Periods => _config.Periods;
public PeriodScheduleService(string appDataPath)
{
_configPath = Path.Combine(appDataPath, "periodschedule.json");
_config = Load();
}
public void SetPeriods(List<PeriodTimeEntry> periods)
{
_config.Periods = periods;
File.WriteAllText(_configPath, JsonSerializer.Serialize(_config));
}
public (TimeOnly Start, TimeOnly End)? GetTimes(int periodNumber)
{
var entry = _config.Periods.FirstOrDefault(p => p.PeriodNumber == periodNumber);
return entry is null ? null : (entry.Start, entry.End);
}
public int GetDurationMinutes(int periodNumber) =>
GetTimes(periodNumber) is { } t ? (int)(t.End - t.Start).TotalMinutes : 0;
private PeriodScheduleConfig Load()
{
try
{
if (File.Exists(_configPath))
return JsonSerializer.Deserialize<PeriodScheduleConfig>(File.ReadAllText(_configPath))
?? new PeriodScheduleConfig();
}
catch { /* beschädigte Konfiguration -> Standardwert */ }
return new PeriodScheduleConfig();
}
}
@@ -0,0 +1,69 @@
using System.Text.Encodings.Web;
using System.Text.Json;
using LehrerApp.Core.Interfaces;
namespace LehrerApp.Core.Services;
/// <summary>
/// Datenauskunft für einen Schüler (5.4.3, Art. 15 DSGVO) — sammelt alle über die Person
/// gespeicherten Daten aus den einzelnen Repositories zu einem Export.
///
/// Enthält bewusst auch als vertraulich markierte Dokumentationseinträge: das
/// Vertraulich-Kennzeichen in dieser App blendet Inhalte in der laufenden Ansicht aus,
/// ist aber keine rechtliche Ausnahme vom Auskunftsanspruch der betroffenen Person selbst.
/// Ob im Einzelfall doch eine Ausnahme greift (z.B. schutzwürdige Belange Dritter nach
/// Landes-Schulrecht), muss die verantwortliche Lehrkraft/Schule selbst prüfen.
/// </summary>
public class PersonalDataExportService(
IStudentRepository students,
IGroupRepository groups,
IGroupMembershipRepository memberships,
IGradeRepository grades,
IExamResultRepository examResults,
IParticipationRepository participation,
IDocumentationRepository documentation,
ISeatingPlanRepository seatingPlans)
{
public string ExportAsJson(Guid studentId)
{
var student = students.GetById(studentId)
?? throw new InvalidOperationException("Schüler nicht gefunden.");
var studentMemberships = memberships.GetByStudent(studentId);
var studentGroups = studentMemberships
.Select(m => groups.GetById(m.GroupId))
.Where(g => g is not null)
.ToList();
var dto = new
{
ExportedAt = DateTime.UtcNow,
Student = student,
Gruppenzuordnungen = studentMemberships,
Noten = studentGroups.SelectMany(g => grades.GetByStudentAndGroup(studentId, g!.Id)).ToList(),
Klausurergebnisse = examResults.GetByStudent(studentId),
Mitarbeit = participation.GetByStudent(studentId),
Dokumentation = documentation.GetByStudent(studentId),
Sitzplaetze = studentGroups
.SelectMany(g => seatingPlans.GetByGroup(g!.Id))
.SelectMany(plan => plan.Assignments
.Where(a => a.StudentId == studentId)
.Select(a => new
{
Sitzplan = plan.Name,
plan.Room,
Reihe = a.Row + 1,
Platz = a.Column + 1,
}))
.ToList(),
};
return JsonSerializer.Serialize(dto, new JsonSerializerOptions
{
WriteIndented = true,
// Ohne diese Option würden Umlaute als \uXXXX escaped — schlecht lesbar für den
// eigentlichen Zweck dieses Exports (Auskunft an die betroffene Person).
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
});
}
}
@@ -0,0 +1,47 @@
using System.Text.Json;
namespace LehrerApp.Core.Services;
internal class PrivacySettingsConfig
{
public int RetentionYears { get; set; } = 3;
}
/// <summary>
/// Löschfristen für Dokumentationseinträge (5.4.2). Löscht nichts automatisch — schlägt
/// abgelaufene Einträge nur zur manuellen Prüfung/Löschung vor.
/// </summary>
public class PrivacySettingsService
{
private readonly string _configPath;
private PrivacySettingsConfig _config;
public int RetentionYears => _config.RetentionYears;
public PrivacySettingsService(string appDataPath)
{
_configPath = Path.Combine(appDataPath, "privacy.json");
_config = Load();
}
public void SetRetentionYears(int years)
{
_config.RetentionYears = Math.Max(1, years);
File.WriteAllText(_configPath, JsonSerializer.Serialize(_config));
}
public DateTime RetentionCutoff(DateTime? now = null) =>
(now ?? DateTime.UtcNow).AddYears(-RetentionYears);
private PrivacySettingsConfig Load()
{
try
{
if (File.Exists(_configPath))
return JsonSerializer.Deserialize<PrivacySettingsConfig>(File.ReadAllText(_configPath))
?? new PrivacySettingsConfig();
}
catch { /* beschädigte Konfiguration -> Standardwert */ }
return new PrivacySettingsConfig();
}
}
@@ -0,0 +1,79 @@
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
public record PublicHoliday(DateOnly Date, string Name);
/// <summary>
/// Berechnet die gesetzlichen Feiertage eines Bundeslands für ein Kalenderjahr (4.3.5).
/// Im Gegensatz zu Schulferien sind Feiertage über eine feste Regel je Bundesland herleitbar und
/// werden deshalb nicht in der Datenbank gespeichert.
/// </summary>
public class PublicHolidayService
{
public List<PublicHoliday> GetHolidays(int year, GermanState state)
{
var easterSunday = EasterSunday(year);
var holidays = new List<PublicHoliday>
{
new(new DateOnly(year, 1, 1), "Neujahr"),
new(easterSunday.AddDays(-2), "Karfreitag"),
new(easterSunday.AddDays(1), "Ostermontag"),
new(new DateOnly(year, 5, 1), "Tag der Arbeit"),
new(easterSunday.AddDays(39), "Christi Himmelfahrt"),
new(easterSunday.AddDays(50), "Pfingstmontag"),
new(new DateOnly(year, 10, 3), "Tag der Deutschen Einheit"),
new(new DateOnly(year, 12, 25), "1. Weihnachtstag"),
new(new DateOnly(year, 12, 26), "2. Weihnachtstag"),
};
if (state is GermanState.BW or GermanState.BY or GermanState.ST)
holidays.Add(new(new DateOnly(year, 1, 6), "Heilige Drei Könige"));
if (state is GermanState.BE)
holidays.Add(new(new DateOnly(year, 3, 8), "Internationaler Frauentag"));
if (state is GermanState.BW or GermanState.BY or GermanState.HE or GermanState.NW
or GermanState.RP or GermanState.SL)
holidays.Add(new(easterSunday.AddDays(60), "Fronleichnam"));
if (state is GermanState.SL)
holidays.Add(new(new DateOnly(year, 8, 15), "Mariä Himmelfahrt"));
if (state is GermanState.BB or GermanState.MV or GermanState.SN or GermanState.ST
or GermanState.TH or GermanState.HB or GermanState.HH or GermanState.NI
or GermanState.SH)
holidays.Add(new(new DateOnly(year, 10, 31), "Reformationstag"));
if (state is GermanState.BW or GermanState.BY or GermanState.NW or GermanState.RP
or GermanState.SL)
holidays.Add(new(new DateOnly(year, 11, 1), "Allerheiligen"));
if (state is GermanState.SN)
holidays.Add(new(BussUndBettag(year), "Buß- und Bettag"));
return holidays.OrderBy(h => h.Date).ToList();
}
/// Gauß'sche Osterformel.
private static DateOnly EasterSunday(int year)
{
int a = year % 19, b = year / 100, c = year % 100;
int d = b / 4, e = b % 4, f = (b + 8) / 25, g = (b - f + 1) / 3;
int h = (19 * a + b - d - g + 15) % 30;
int i = c / 4, k = c % 4, l = (32 + 2 * e + 2 * i - h - k) % 7;
int m = (a + 11 * h + 22 * l) / 451;
int month = (h + l - 7 * m + 114) / 31;
int day = (h + l - 7 * m + 114) % 31 + 1;
return new DateOnly(year, month, day);
}
/// Buß- und Bettag: Mittwoch vor dem 23. November (entspricht dem letzten Mittwoch vor dem
/// ersten Advent).
private static DateOnly BussUndBettag(int year)
{
var date = new DateOnly(year, 11, 23);
while (date.DayOfWeek != DayOfWeek.Wednesday) date = date.AddDays(-1);
return date;
}
}
@@ -0,0 +1,48 @@
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
/// Welcher der drei Leistungsbereiche (siehe GradingScheme) bei der Zielnoten-Frage der
/// gesuchte/unbekannte ist.
public enum GradeBucketKind { Exams, Participation, Other }
public readonly record struct TargetGradeResult(double RequiredAverage, bool IsAchievable);
/// <summary>
/// Beantwortet die Zielnoten-Frage "Was brauche ich noch, um Zeugnisnote X zu erreichen?"
/// (Nutzer-Feedback) — kehrt <see cref="GradingService.CalculateReportGrade"/> um: löst nach
/// dem Durchschnitt eines gewählten Bereichs auf, der zusammen mit den bekannten Durchschnitten
/// der übrigen Bereiche (gewichtet nach <see cref="GradingScheme"/>) genau die Zielnote ergibt.
/// Bereiche ohne bekannten Durchschnitt (z.B. noch keine Mitarbeitsnote im Halbjahr) fließen
/// nicht in die bekannte Seite der Gleichung ein — exakt dieselbe Normierung wie
/// <see cref="GradingService.CalculateReportGrade"/> bei fehlenden Bereichen.
/// </summary>
public static class ReportGradeTargetCalculator
{
public static TargetGradeResult? SolveRequiredAverage(double target, GradeBucketKind solveFor,
double? examsAverage, double? participationAverage, double? otherAverage,
GradingScheme scheme, GradingSystem system)
{
var solvePercent = solveFor switch
{
GradeBucketKind.Exams => scheme.ExamsPercent,
GradeBucketKind.Participation => scheme.ParticipationPercent,
_ => scheme.OtherPercent,
};
// Dieser Bereich fließt laut Gewichtungsschema gar nicht in die Zeugnisnote ein —
// keine Zielnoten-Frage für ihn beantwortbar.
if (solvePercent <= 0) return null;
var known = new List<(double Avg, double Percent)>();
if (solveFor != GradeBucketKind.Exams && examsAverage is { } e) known.Add((e, scheme.ExamsPercent));
if (solveFor != GradeBucketKind.Participation && participationAverage is { } p) known.Add((p, scheme.ParticipationPercent));
if (solveFor != GradeBucketKind.Other && otherAverage is { } o) known.Add((o, scheme.OtherPercent));
var totalPercent = known.Sum(k => k.Percent) + solvePercent;
var knownContribution = known.Sum(k => k.Avg * k.Percent);
var required = (target * totalPercent - knownContribution) / solvePercent;
var (min, max) = system == GradingSystem.Grades1To6 ? (1.0, 6.0) : (0.0, 15.0);
return new TargetGradeResult(required, required >= min && required <= max);
}
}
@@ -0,0 +1,42 @@
using System.Text.Json;
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
internal class SchoolCalendarConfig
{
public GermanState State { get; set; } = GermanState.NW;
}
/// <summary>Welches Bundesland für die Feiertagsberechnung (4.3.5) gilt.</summary>
public class SchoolCalendarSettingsService
{
private readonly string _configPath;
private SchoolCalendarConfig _config;
public GermanState State => _config.State;
public SchoolCalendarSettingsService(string appDataPath)
{
_configPath = Path.Combine(appDataPath, "schoolcalendar.json");
_config = Load();
}
public void SetState(GermanState state)
{
_config.State = state;
File.WriteAllText(_configPath, JsonSerializer.Serialize(_config));
}
private SchoolCalendarConfig Load()
{
try
{
if (File.Exists(_configPath))
return JsonSerializer.Deserialize<SchoolCalendarConfig>(File.ReadAllText(_configPath))
?? new SchoolCalendarConfig();
}
catch { /* beschädigte Konfiguration -> Standardwert */ }
return new SchoolCalendarConfig();
}
}
+13 -8
View File
@@ -2,21 +2,26 @@ namespace LehrerApp.Core.Services;
public class SchoolYearService public class SchoolYearService
{ {
public string CurrentSchoolYear() /// <param name="today">Nur für Tests — Standard ist das heutige Datum.</param>
{ public string CurrentSchoolYear(DateOnly? today = null) =>
var now = DateTime.Today; FormatSchoolYear(StartYearFor(today ?? DateOnly.FromDateTime(DateTime.Today)));
return FormatSchoolYear(now.Month >= 8 ? now.Year : now.Year - 1);
}
public string FormatSchoolYear(int startYear) => public string FormatSchoolYear(int startYear) =>
$"{startYear}/{(startYear + 1) % 100:D2}"; $"{startYear}/{(startYear + 1) % 100:D2}";
public DateOnly SchoolYearStart(string sy) => public DateOnly SchoolYearStart(string sy) =>
new(int.Parse(sy.Split('/')[0]), 8, 1); new(int.Parse(sy.Split('/')[0]), 8, 1);
public DateOnly SchoolYearEnd(string sy) => public DateOnly SchoolYearEnd(string sy) =>
new(int.Parse(sy.Split('/')[0]) + 1, 7, 31); new(int.Parse(sy.Split('/')[0]) + 1, 7, 31);
public List<string> RecentSchoolYears(int count = 5)
/// <param name="today">Nur für Tests — Standard ist das heutige Datum.</param>
public List<string> RecentSchoolYears(int count = 5, DateOnly? today = null)
{ {
var now = DateTime.Today; var cur = StartYearFor(today ?? DateOnly.FromDateTime(DateTime.Today));
var cur = now.Month >= 8 ? now.Year : now.Year - 1;
return Enumerable.Range(0, count).Select(i => FormatSchoolYear(cur - i)).ToList(); return Enumerable.Range(0, count).Select(i => FormatSchoolYear(cur - i)).ToList();
} }
// Schuljahreswechsel liegt am 1. August: ab diesem Tag zählt das neue Schuljahr.
private static int StartYearFor(DateOnly date) => date.Month >= 8 ? date.Year : date.Year - 1;
} }
@@ -0,0 +1,840 @@
using System.Globalization;
using System.Text;
using LehrerApp.Core.Importing;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
/// <summary>
/// Orchestriert den Schülerimport ohne UI-Abhängigkeit: Format erkennen, vollständig
/// analysieren, Konflikte beschreiben und erst nach Bestätigung speichern.
/// </summary>
public sealed class StudentImportService
{
private const string CreateOption = "create";
private const string SkipOption = "skip";
private const string ExistingOptionPrefix = "existing:";
private const string GroupOptionPrefix = "group:";
private const string ImportedContactRelation = "Stammdaten";
private readonly ImportHandlerCatalog<ImportedStudent> _handlers;
private readonly IStudentRepository _students;
private readonly IGroupRepository _groups;
private readonly IGroupMembershipRepository _memberships;
private readonly SchoolYearService _schoolYears;
public StudentImportService(
IEnumerable<IImportHandler<ImportedStudent>> handlers,
IStudentRepository students,
IGroupRepository groups,
IGroupMembershipRepository memberships,
SchoolYearService schoolYears)
{
_handlers = new ImportHandlerCatalog<ImportedStudent>(handlers);
_students = students;
_groups = groups;
_memberships = memberships;
_schoolYears = schoolYears;
}
public IReadOnlyList<IImportHandler<ImportedStudent>> Handlers => _handlers.Handlers;
public IReadOnlyList<string> SupportedExtensions => _handlers.SupportedExtensions;
public async Task<StudentImportPreview> AnalyzeAsync(
ImportFile file,
CancellationToken cancellationToken = default) =>
await AnalyzeInternalAsync(file, null, cancellationToken).ConfigureAwait(false);
public async Task<StudentImportPreview> AnalyzeAsync(
ImportFile file,
Guid targetGroupId,
CancellationToken cancellationToken = default) =>
await AnalyzeInternalAsync(file, targetGroupId, cancellationToken).ConfigureAwait(false);
private async Task<StudentImportPreview> AnalyzeInternalAsync(
ImportFile file,
Guid? targetGroupId,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(file);
var detected = await _handlers.DetectAsync(file, cancellationToken).ConfigureAwait(false);
var parsed = await detected.Handler.ParseAsync(file, cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
var messages = parsed.Messages.ToList();
var imported = parsed.Items.Select(Normalize).ToList();
Validate(imported, messages);
if (imported.Count == 0 && messages.All(message => message.Severity != ImportMessageSeverity.Error))
messages.Add(new ImportMessage(
ImportMessageSeverity.Error,
"students.empty",
"Die Importdatei enthält keine Schülerdaten."));
var existing = _students.GetAll(includeInactive: true);
var groups = _groups.GetAll(includeInactive: true);
var groupMemberships = groups.SelectMany(group => _memberships.GetByGroup(group.Id)).ToList();
var targetGroup = targetGroupId is null
? null
: groups.SingleOrDefault(group => group.Id == targetGroupId.Value);
if (targetGroupId is not null && targetGroup is not { IsActive: true })
messages.Add(new ImportMessage(
ImportMessageSeverity.Error,
"student.target-group.unavailable",
"Die Ziel-Lerngruppe existiert nicht oder ist archiviert."));
var currentSchoolYear = _schoolYears.CurrentSchoolYear();
var currentGroups = groups
.Where(group => group.IsActive && group.SchoolYear == currentSchoolYear)
.OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
var entries = new List<StudentImportEntry>();
var conflicts = new List<ImportConflict>();
for (var index = 0; index < imported.Count; index++)
{
var candidate = imported[index];
var entryId = $"student:{index + 1}";
var externalMatches = FindByExternalId(existing, detected.Handler.FormatId, candidate.ExternalId);
if (externalMatches.Count == 1)
{
var matched = externalMatches[0];
AddDifferenceWarning(candidate, matched, messages);
entries.Add(new StudentImportEntry(
entryId,
candidate,
new StudentImportResolution(StudentImportResolutionKind.UseExisting, matched.Id),
null,
FieldsToSupplement(matched, candidate, detected.Handler.FormatId)));
continue;
}
if (externalMatches.Count > 1)
{
messages.Add(new ImportMessage(
ImportMessageSeverity.Error,
"student.external-id.ambiguous",
"Die externe Schüler-ID ist mehreren vorhandenen Schülern zugeordnet.",
candidate.SourceReference));
entries.Add(new StudentImportEntry(
entryId,
candidate,
new StudentImportResolution(StudentImportResolutionKind.Skip),
null,
[]));
continue;
}
var sameName = existing.Where(student => NamesEqual(student, candidate)).ToList();
var exact = candidate.DateOfBirth is not null
? sameName.Where(student => student.DateOfBirth == candidate.DateOfBirth).ToList()
: [];
if (candidate.DateOfBirth is null && exact.Count == 0 && targetGroup is not null)
{
var memberIds = groupMemberships
.Where(membership => membership.GroupId == targetGroup.Id)
.Select(membership => membership.StudentId)
.ToHashSet();
var memberMatches = sameName.Where(student => memberIds.Contains(student.Id)).ToList();
if (memberMatches.Count == 1)
exact = memberMatches;
}
if (exact.Count == 1)
{
AddDifferenceWarning(candidate, exact[0], messages);
entries.Add(new StudentImportEntry(
entryId,
candidate,
new StudentImportResolution(StudentImportResolutionKind.UseExisting, exact[0].Id),
null,
FieldsToSupplement(exact[0], candidate, detected.Handler.FormatId)));
continue;
}
if (sameName.Count == 0)
{
entries.Add(new StudentImportEntry(
entryId,
candidate,
new StudentImportResolution(StudentImportResolutionKind.CreateNew),
null,
[]));
continue;
}
var conflictId = $"student-match:{index + 1}";
conflicts.Add(CreateMatchConflict(
conflictId,
candidate,
sameName,
detected.Handler.FormatId));
entries.Add(new StudentImportEntry(entryId, candidate, null, conflictId, []));
}
var groupAssignments = targetGroup is { IsActive: true }
? [new StudentImportGroupAssignment(targetGroup.Name, targetGroup.Id, null)]
: targetGroupId is null
? BuildGroupAssignments(imported, currentGroups, conflicts, messages)
: [];
return new StudentImportPreview(
detected.Handler.FormatId,
detected.Handler.DisplayName,
entries,
messages,
conflicts,
groupAssignments,
targetGroupId,
Fingerprint(existing),
GroupStateFingerprint(groups, groupMemberships, currentSchoolYear));
}
public Task<StudentImportApplyResult> ApplyAsync(
StudentImportPreview preview,
IEnumerable<ImportDecision>? decisions = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(preview);
cancellationToken.ThrowIfCancellationRequested();
if (!preview.CanApply)
throw new InvalidOperationException(
"Der Import enthält Fehler oder keine Schüler und kann nicht angewendet werden.");
var existing = _students.GetAll(includeInactive: true);
if (!string.Equals(
preview.ExistingStudentsFingerprint,
Fingerprint(existing),
StringComparison.Ordinal))
throw new InvalidOperationException(
"Die Schülerdaten wurden seit der Vorschau verändert. Bitte analysiere die Datei erneut.");
var groups = _groups.GetAll(includeInactive: true);
var groupMemberships = groups.SelectMany(group => _memberships.GetByGroup(group.Id)).ToList();
if (!string.Equals(
preview.GroupStateFingerprint,
GroupStateFingerprint(groups, groupMemberships, _schoolYears.CurrentSchoolYear()),
StringComparison.Ordinal))
throw new InvalidOperationException(
"Die Lerngruppen oder Zuordnungen wurden seit der Vorschau verändert. "
+ "Bitte analysiere die Datei erneut.");
var decisionMap = BuildDecisionMap(preview, decisions ?? []);
var resolutions = preview.Entries
.Select(entry => Resolve(entry, decisionMap))
.ToList();
// Alle Entscheidungen werden vor dem ersten Schreibzugriff geprüft.
ValidateResolutions(resolutions, existing);
var groupResolutions = preview.GroupAssignments.ToDictionary(
assignment => assignment.SourceGroupName,
assignment => ResolveGroup(assignment, decisionMap),
StringComparer.OrdinalIgnoreCase);
ValidateGroupResolutions(groupResolutions.Values, groups);
var createdIds = new List<Guid>();
var studentIdsByEntry = new Dictionary<string, Guid?>(StringComparer.Ordinal);
var existingById = existing.ToDictionary(student => student.Id);
var updated = 0;
var matched = 0;
var skipped = 0;
foreach (var (entry, resolution) in preview.Entries.Zip(resolutions))
{
cancellationToken.ThrowIfCancellationRequested();
switch (resolution.Kind)
{
case StudentImportResolutionKind.CreateNew:
var created = CreateStudent(entry.Student, preview.FormatId);
_students.Save(created);
createdIds.Add(created.Id);
studentIdsByEntry[entry.Id] = created.Id;
break;
case StudentImportResolutionKind.UseExisting:
var existingStudent = existingById[resolution.ExistingStudentId!.Value];
if (Supplement(existingStudent, entry.Student, preview.FormatId))
{
_students.Save(existingStudent);
updated++;
}
matched++;
studentIdsByEntry[entry.Id] = existingStudent.Id;
break;
case StudentImportResolutionKind.Skip:
skipped++;
studentIdsByEntry[entry.Id] = null;
break;
default:
throw new InvalidOperationException("Unbekannte Importentscheidung.");
}
}
var createdMemberships = 0;
foreach (var entry in preview.Entries)
{
cancellationToken.ThrowIfCancellationRequested();
if (!studentIdsByEntry.TryGetValue(entry.Id, out var studentId)
|| studentId is null
|| !TryResolveTargetGroup(preview, entry, groupResolutions, out var groupId)
|| groupId is null
|| _memberships.GetByStudentAndGroup(studentId.Value, groupId.Value) is not null)
continue;
_memberships.Save(new GroupMembership
{
StudentId = studentId.Value,
GroupId = groupId.Value,
AddedOn = DateOnly.FromDateTime(DateTime.Today),
Period = MembershipPeriod.FullYear,
});
createdMemberships++;
}
return Task.FromResult(new StudentImportApplyResult(
createdIds.Count,
updated,
matched,
skipped,
createdMemberships,
createdIds.AsReadOnly()));
}
private static bool TryResolveTargetGroup(
StudentImportPreview preview,
StudentImportEntry entry,
IReadOnlyDictionary<string, Guid?> groupResolutions,
out Guid? groupId)
{
if (preview.TargetGroupId is not null)
{
groupId = preview.TargetGroupId;
return true;
}
if (entry.Student.GroupName is { } sourceGroup
&& groupResolutions.TryGetValue(sourceGroup, out groupId))
return true;
groupId = null;
return false;
}
private static ImportedStudent Normalize(ImportedStudent student) => student with
{
FirstName = student.FirstName?.Trim() ?? "",
LastName = student.LastName?.Trim() ?? "",
ExternalId = NullIfWhiteSpace(student.ExternalId),
GroupName = NullIfWhiteSpace(student.GroupName),
Contact = Normalize(student.Contact),
SourceReference = NullIfWhiteSpace(student.SourceReference),
};
private static ImportedStudentContact? Normalize(ImportedStudentContact? contact)
{
if (contact is null) return null;
var normalized = contact with
{
Email = NullIfWhiteSpace(contact.Email),
MobilePhone = NullIfWhiteSpace(contact.MobilePhone),
Phone = NullIfWhiteSpace(contact.Phone),
Street = NullIfWhiteSpace(contact.Street),
PostalCode = NullIfWhiteSpace(contact.PostalCode),
City = NullIfWhiteSpace(contact.City),
};
return normalized.HasData ? normalized : null;
}
private static string? NullIfWhiteSpace(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static void Validate(
IReadOnlyList<ImportedStudent> imported,
ICollection<ImportMessage> messages)
{
for (var index = 0; index < imported.Count; index++)
{
var student = imported[index];
var source = student.SourceReference ?? $"Eintrag {index + 1}";
if (student.FirstName.Length == 0)
messages.Add(new ImportMessage(
ImportMessageSeverity.Error,
"student.first-name.required",
"Der Vorname fehlt.",
source));
if (student.LastName.Length == 0)
messages.Add(new ImportMessage(
ImportMessageSeverity.Error,
"student.last-name.required",
"Der Nachname fehlt.",
source));
}
var duplicateExternalIds = imported
.Where(student => student.ExternalId is not null)
.GroupBy(student => student.ExternalId!, StringComparer.OrdinalIgnoreCase)
.Where(group => group.Count() > 1);
foreach (var duplicate in duplicateExternalIds)
messages.Add(new ImportMessage(
ImportMessageSeverity.Error,
"student.external-id.duplicate",
$"Die externe Schüler-ID „{duplicate.Key}“ kommt mehrfach vor."));
var duplicateIdentities = imported
.Where(student => student.FirstName.Length > 0
&& student.LastName.Length > 0
&& student.DateOfBirth is not null)
.GroupBy(student =>
$"{student.FirstName.ToUpperInvariant()}\u001f{student.LastName.ToUpperInvariant()}\u001f{student.DateOfBirth:yyyyMMdd}")
.Where(group => group.Count() > 1);
foreach (var duplicate in duplicateIdentities)
{
var student = duplicate.First();
messages.Add(new ImportMessage(
ImportMessageSeverity.Error,
"student.duplicate",
$"„{student.LastName}, {student.FirstName}“ mit Geburtsdatum "
+ $"{DateDisplay(student.DateOfBirth)} kommt mehrfach im Import vor."));
}
}
private static ImportConflict CreateMatchConflict(
string conflictId,
ImportedStudent imported,
IReadOnlyList<Student> candidates,
ImportFormatId formatId)
{
var options = candidates.Select(candidate => new ImportConflictOption(
ExistingOption(candidate.Id),
$"Vorhandenen Schüler „{candidate.FullName}“ verwenden",
SupplementDescription(candidate, imported, formatId)))
.Append(new ImportConflictOption(
CreateOption,
"Als neuen Schüler anlegen",
"Der vorhandene Datensatz bleibt unverändert."))
.Append(new ImportConflictOption(
SkipOption,
"Eintrag überspringen"))
.ToList()
.AsReadOnly();
return new ImportConflict(
conflictId,
"Möglicher vorhandener Schüler",
"Vor- und Nachname stimmen überein, der Datensatz konnte aber nicht eindeutig zugeordnet werden.",
ImportedDetails(imported),
string.Join(Environment.NewLine, candidates.Select(StudentDetails)),
options,
SkipOption,
imported.SourceReference);
}
private static IReadOnlyList<Student> FindByExternalId(
IEnumerable<Student> existing,
ImportFormatId formatId,
string? externalId)
{
if (externalId is null) return [];
return existing.Where(student =>
student.ExternalIds is not null
&& student.ExternalIds.TryGetValue(formatId.Value, out var stored)
&& string.Equals(stored, externalId, StringComparison.OrdinalIgnoreCase))
.ToList();
}
private static void AddDifferenceWarning(
ImportedStudent imported,
Student existing,
ICollection<ImportMessage> messages)
{
var differences = new List<string>();
if (existing.DateOfBirth is not null && imported.DateOfBirth is not null
&& existing.DateOfBirth != imported.DateOfBirth)
differences.Add("Geburtsdatum");
if (existing.Gender is not null && imported.Gender is not null
&& existing.Gender != imported.Gender)
differences.Add("Geschlecht");
if (differences.Count == 0) return;
messages.Add(new ImportMessage(
ImportMessageSeverity.Warning,
"student.existing-values-kept",
$"Vorhandene abweichende Werte ({string.Join(", ", differences)}) werden nicht überschrieben.",
imported.SourceReference));
}
private static IReadOnlyList<string> FieldsToSupplement(
Student existing,
ImportedStudent imported,
ImportFormatId formatId)
{
var fields = new List<string>();
if (existing.DateOfBirth is null && imported.DateOfBirth is not null)
fields.Add("Geburtsdatum");
if (existing.Gender is null && imported.Gender is not null)
fields.Add("Geschlecht");
if (imported.ExternalId is not null
&& (existing.ExternalIds is null || !existing.ExternalIds.ContainsKey(formatId.Value)))
fields.Add("externe Kennung");
if (imported.Contact is { HasData: true } contact)
{
var target = existing.Contacts?.FirstOrDefault(IsImportedContact);
AddMissingField(fields, "E-Mail", target?.Email, contact.Email);
AddMissingField(fields, "Mobiltelefon", target?.MobilePhone, contact.MobilePhone);
AddMissingField(fields, "Telefon", target?.Phone, contact.Phone);
AddMissingField(fields, "Straße", target?.Street, contact.Street);
AddMissingField(fields, "Postleitzahl", target?.PostalCode, contact.PostalCode);
AddMissingField(fields, "Ort", target?.City, contact.City);
}
return fields.AsReadOnly();
}
private static void AddMissingField(
ICollection<string> fields,
string label,
string? existing,
string? imported)
{
if (string.IsNullOrWhiteSpace(existing) && !string.IsNullOrWhiteSpace(imported))
fields.Add(label);
}
private static string SupplementDescription(
Student existing,
ImportedStudent imported,
ImportFormatId formatId)
{
var supplements = FieldsToSupplement(existing, imported, formatId);
return supplements.Count == 0
? $"{StudentDetails(existing)} · keine Ergänzungen erforderlich"
: $"{StudentDetails(existing)} · ergänzt: {string.Join(", ", supplements)}";
}
private static List<StudentImportGroupAssignment> BuildGroupAssignments(
IEnumerable<ImportedStudent> imported,
IReadOnlyList<LearningGroup> currentGroups,
ICollection<ImportConflict> conflicts,
ICollection<ImportMessage> messages)
{
var result = new List<StudentImportGroupAssignment>();
var sourceGroups = imported
.Select(student => student.GroupName)
.Where(group => group is not null)
.Select(group => group!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(group => group, StringComparer.OrdinalIgnoreCase);
foreach (var sourceGroup in sourceGroups)
{
var sourceGradeLevel = LeadingGradeLevel(sourceGroup);
var exact = currentGroups
.Where(group => string.Equals(
group.Name.Trim(), sourceGroup, StringComparison.OrdinalIgnoreCase)
&& group.Type == GroupType.Class
&& (sourceGradeLevel is null || group.GradeLevel == sourceGradeLevel))
.ToList();
var ownClassMatches = exact.Where(group => group.IsOwnClass).ToList();
if (ownClassMatches.Count == 1)
exact = ownClassMatches;
if (exact.Count == 1)
{
result.Add(new StudentImportGroupAssignment(sourceGroup, exact[0].Id, null));
continue;
}
var candidates = exact.Count > 1 ? exact : currentGroups;
if (candidates.Count == 0)
{
messages.Add(new ImportMessage(
ImportMessageSeverity.Warning,
"student.group.not-found",
$"Für die Klasse „{sourceGroup}“ gibt es im aktuellen Schuljahr keine aktive Lerngruppe; "
+ "die Schüler werden ohne Gruppenzuordnung importiert."));
result.Add(new StudentImportGroupAssignment(sourceGroup, null, null));
continue;
}
var conflictId = $"student-group:{sourceGroup.ToLowerInvariant()}";
var options = candidates.Select(group => new ImportConflictOption(
GroupOption(group.Id),
$"Lerngruppe „{group.Name}“ verwenden",
$"{group.SchoolYear} · Klassenstufe {group.GradeLevel}"))
.Append(new ImportConflictOption(
SkipOption,
"Keine Lerngruppe zuordnen"))
.ToList()
.AsReadOnly();
conflicts.Add(new ImportConflict(
conflictId,
"Lerngruppe zuordnen",
exact.Count > 1
? $"Für „{sourceGroup}“ wurden mehrere namensgleiche Lerngruppen gefunden."
: $"Für „{sourceGroup}“ wurde keine eindeutig namensgleiche Lerngruppe gefunden.",
sourceGroup,
null,
options,
SkipOption));
result.Add(new StudentImportGroupAssignment(sourceGroup, null, conflictId));
}
return result;
}
private static int? LeadingGradeLevel(string groupName)
{
var digits = new string(groupName.Trim().TakeWhile(char.IsDigit).ToArray());
return int.TryParse(digits, CultureInfo.InvariantCulture, out var gradeLevel)
? gradeLevel
: null;
}
private static Dictionary<string, ImportDecision> BuildDecisionMap(
StudentImportPreview preview,
IEnumerable<ImportDecision> decisions)
{
var conflicts = preview.Conflicts.ToDictionary(conflict => conflict.Id, StringComparer.Ordinal);
var map = new Dictionary<string, ImportDecision>(StringComparer.Ordinal);
foreach (var decision in decisions)
{
if (!conflicts.TryGetValue(decision.ConflictId, out var conflict))
throw new InvalidOperationException(
$"Die Entscheidung verweist auf den unbekannten Konflikt „{decision.ConflictId}“.");
if (!conflict.Options.Any(option => option.Id == decision.SelectedOptionId))
throw new InvalidOperationException(
$"Die Option „{decision.SelectedOptionId}“ gehört nicht zum Konflikt „{decision.ConflictId}“.");
if (!map.TryAdd(decision.ConflictId, decision))
throw new InvalidOperationException(
$"Für den Konflikt „{decision.ConflictId}“ wurden mehrere Entscheidungen übergeben.");
}
var missing = conflicts.Keys.Where(id => !map.ContainsKey(id)).ToList();
if (missing.Count > 0)
throw new InvalidOperationException(
"Vor dem Import müssen alle Konflikte entschieden werden.");
return map;
}
private static StudentImportResolution Resolve(
StudentImportEntry entry,
IReadOnlyDictionary<string, ImportDecision> decisions)
{
if (entry.AutomaticResolution is not null)
return entry.AutomaticResolution;
if (entry.ConflictId is null || !decisions.TryGetValue(entry.ConflictId, out var decision))
throw new InvalidOperationException("Für einen Importkonflikt fehlt die Entscheidung.");
if (decision.SelectedOptionId == CreateOption)
return new StudentImportResolution(StudentImportResolutionKind.CreateNew);
if (decision.SelectedOptionId == SkipOption)
return new StudentImportResolution(StudentImportResolutionKind.Skip);
if (TryReadExistingOption(decision.SelectedOptionId, out var existingId))
return new StudentImportResolution(StudentImportResolutionKind.UseExisting, existingId);
throw new InvalidOperationException(
$"Die Konfliktoption „{decision.SelectedOptionId}“ ist unbekannt.");
}
private static Guid? ResolveGroup(
StudentImportGroupAssignment assignment,
IReadOnlyDictionary<string, ImportDecision> decisions)
{
if (assignment.ConflictId is null)
return assignment.AutomaticGroupId;
if (!decisions.TryGetValue(assignment.ConflictId, out var decision))
throw new InvalidOperationException("Für die Lerngruppenzuordnung fehlt die Entscheidung.");
if (decision.SelectedOptionId == SkipOption)
return null;
if (TryReadGroupOption(decision.SelectedOptionId, out var groupId))
return groupId;
throw new InvalidOperationException(
$"Die Gruppenoption „{decision.SelectedOptionId}“ ist unbekannt.");
}
private static void ValidateResolutions(
IReadOnlyList<StudentImportResolution> resolutions,
IReadOnlyCollection<Student> existing)
{
var existingIds = existing.Select(student => student.Id).ToHashSet();
foreach (var resolution in resolutions.Where(resolution =>
resolution.Kind == StudentImportResolutionKind.UseExisting))
{
if (resolution.ExistingStudentId is not Guid id || !existingIds.Contains(id))
throw new InvalidOperationException(
"Ein ausgewählter vorhandener Schüler existiert nicht mehr.");
}
}
private static void ValidateGroupResolutions(
IEnumerable<Guid?> resolutions,
IReadOnlyCollection<LearningGroup> groups)
{
var writableGroupIds = groups
.Where(group => group.IsActive)
.Select(group => group.Id)
.ToHashSet();
if (resolutions.Any(id => id is not null && !writableGroupIds.Contains(id.Value)))
throw new InvalidOperationException(
"Eine ausgewählte Lerngruppe existiert nicht mehr oder ist archiviert.");
}
private static Student CreateStudent(ImportedStudent imported, ImportFormatId formatId)
{
var student = new Student
{
FirstName = imported.FirstName,
LastName = imported.LastName,
DateOfBirth = imported.DateOfBirth,
Gender = imported.Gender,
};
if (imported.ExternalId is not null)
student.ExternalIds[formatId.Value] = imported.ExternalId;
var contact = CreateImportedContact(student, imported.Contact);
if (contact is not null) student.Contacts.Add(contact);
return student;
}
private static bool Supplement(
Student target,
ImportedStudent imported,
ImportFormatId formatId)
{
var changed = false;
if (target.DateOfBirth is null && imported.DateOfBirth is not null)
{
target.DateOfBirth = imported.DateOfBirth;
changed = true;
}
if (target.Gender is null && imported.Gender is not null)
{
target.Gender = imported.Gender;
changed = true;
}
target.ExternalIds ??= [];
if (imported.ExternalId is not null && !target.ExternalIds.ContainsKey(formatId.Value))
{
target.ExternalIds[formatId.Value] = imported.ExternalId;
changed = true;
}
if (imported.Contact is not { HasData: true } importedContact)
return changed;
target.Contacts ??= [];
var contact = target.Contacts.FirstOrDefault(IsImportedContact);
if (contact is null)
{
contact = CreateImportedContact(target, importedContact)!;
target.Contacts.Add(contact);
return true;
}
changed |= FillIfEmpty(contact.Email, importedContact.Email, value => contact.Email = value);
changed |= FillIfEmpty(contact.MobilePhone, importedContact.MobilePhone, value => contact.MobilePhone = value);
changed |= FillIfEmpty(contact.Phone, importedContact.Phone, value => contact.Phone = value);
changed |= FillIfEmpty(contact.Street, importedContact.Street, value => contact.Street = value);
changed |= FillIfEmpty(contact.PostalCode, importedContact.PostalCode, value => contact.PostalCode = value);
changed |= FillIfEmpty(contact.City, importedContact.City, value => contact.City = value);
return changed;
}
private static Contact? CreateImportedContact(
Student student,
ImportedStudentContact? imported)
{
if (imported is not { HasData: true }) return null;
return new Contact
{
Name = $"{student.FirstName} {student.LastName}".Trim(),
Relation = ImportedContactRelation,
Email = imported.Email,
MobilePhone = imported.MobilePhone,
Phone = imported.Phone,
Street = imported.Street,
PostalCode = imported.PostalCode,
City = imported.City,
};
}
private static bool IsImportedContact(Contact contact) =>
string.Equals(contact.Relation, ImportedContactRelation, StringComparison.OrdinalIgnoreCase);
private static bool FillIfEmpty(string? existing, string? imported, Action<string> assign)
{
if (!string.IsNullOrWhiteSpace(existing) || string.IsNullOrWhiteSpace(imported))
return false;
assign(imported);
return true;
}
private static bool NamesEqual(Student existing, ImportedStudent imported) =>
string.Equals(existing.FirstName.Trim(), imported.FirstName, StringComparison.OrdinalIgnoreCase)
&& string.Equals(existing.LastName.Trim(), imported.LastName, StringComparison.OrdinalIgnoreCase);
private static string ExistingOption(Guid id) => $"{ExistingOptionPrefix}{id:N}";
private static string GroupOption(Guid id) => $"{GroupOptionPrefix}{id:N}";
private static bool TryReadExistingOption(string option, out Guid id)
{
id = Guid.Empty;
return option.StartsWith(ExistingOptionPrefix, StringComparison.Ordinal)
&& Guid.TryParseExact(option[ExistingOptionPrefix.Length..], "N", out id);
}
private static bool TryReadGroupOption(string option, out Guid id)
{
id = Guid.Empty;
return option.StartsWith(GroupOptionPrefix, StringComparison.Ordinal)
&& Guid.TryParseExact(option[GroupOptionPrefix.Length..], "N", out id);
}
private static string ImportedDetails(ImportedStudent student) =>
$"{student.LastName}, {student.FirstName} · {DateDisplay(student.DateOfBirth)}";
private static string StudentDetails(Student student) =>
$"{student.FullName} · {DateDisplay(student.DateOfBirth)}";
private static string DateDisplay(DateOnly? date) =>
date?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture) ?? "Geburtsdatum unbekannt";
private static string Fingerprint(IEnumerable<Student> students)
{
var builder = new StringBuilder();
foreach (var student in students.OrderBy(student => student.Id))
{
builder.Append(student.Id.ToString("N"))
.Append(':').Append(student.UpdatedAt.Ticks)
.Append(':').Append(student.FirstName)
.Append(':').Append(student.LastName)
.Append(':').Append(student.DateOfBirth?.ToString("yyyyMMdd", CultureInfo.InvariantCulture))
.Append(':').Append(student.Gender)
.Append(':').Append(student.IsActive)
.Append(':').Append(string.Join(",", (student.ExternalIds ?? [])
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => $"{pair.Key}={pair.Value}")))
.Append(':').Append(string.Join(",", (student.Contacts ?? [])
.OrderBy(contact => contact.Id)
.Select(contact =>
$"{contact.Id:N}={contact.Name}/{contact.Relation}/{contact.Email}/"
+ $"{contact.MobilePhone}/{contact.Phone}/{contact.Street}/"
+ $"{contact.PostalCode}/{contact.City}")))
.Append('|');
}
return builder.ToString();
}
private static string GroupStateFingerprint(
IEnumerable<LearningGroup> groups,
IEnumerable<GroupMembership> memberships,
string currentSchoolYear)
{
var builder = new StringBuilder(currentSchoolYear).Append('|');
foreach (var group in groups.OrderBy(group => group.Id))
builder.Append(group.Id.ToString("N"))
.Append(':').Append(group.UpdatedAt.Ticks)
.Append(':').Append(group.Name)
.Append(':').Append(group.SchoolYear)
.Append(':').Append(group.IsActive)
.Append('|');
foreach (var membership in memberships.OrderBy(membership => membership.Id))
builder.Append(membership.Id.ToString("N"))
.Append(':').Append(membership.StudentId.ToString("N"))
.Append(':').Append(membership.GroupId.ToString("N"))
.Append('|');
return builder.ToString();
}
}
+228
View File
@@ -0,0 +1,228 @@
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
public sealed class UntisDiffResult
{
/// SubstitutionEntry-Kandidaten (Id ist immer neu vergeben — der Aufrufer entscheidet über
/// ISubstitutionEntryRepository.GetByExternalId, ob ein vorhandener Eintrag aktualisiert
/// statt dupliziert werden muss; siehe UntisSyncService).
public List<SubstitutionEntry> SubstitutionsToSave { get; init; } = [];
public List<UntisSnapshotEntry> SnapshotToSave { get; init; } = [];
/// Verschwundene, bereits als Ausfall verarbeitete Zeilen — werden aus dem Snapshot entfernt,
/// damit derselbe Ausfall nicht bei jedem weiteren Poll erneut erkannt wird.
public List<Guid> SnapshotIdsToDelete { get; init; } = [];
/// ExternalIds automatisch erzeugter SubstitutionEntry-Zeilen, die NICHT (mehr) gebraucht
/// werden — z.B. weil eine früher erkannte Abweichung sich (durch einen Bugfix oder eine
/// erneute Zuordnung) als nicht mehr abweichend herausstellt, oder eine zuvor als fehlend
/// gemeldete Stunde jetzt wieder im Feed auftaucht. Nutzer-Feedback: "Es ist immer noch so"
/// (nachdem der eigentliche Vergleichsfehler bereits behoben war) — Ursache: einmal erzeugte
/// automatische Einträge wurden nie wieder entfernt, selbst wenn der Vergleich sie beim
/// nächsten Poll nicht mehr als Abweichung einstufte; sie blieben als Karteileichen stehen.
/// Existiert kein Eintrag mit dieser ExternalId, ist das Löschen ein no-op (siehe UntisSyncService).
public List<string> SubstitutionExternalIdsToDelete { get; init; } = [];
}
/// <summary>
/// Stufe 2 des WebUntis-Abgleichs: ein neuer iCal-Abruf gegen den zuletzt gespeicherten
/// Schnappschuss (Nutzer-Feedback: "Abgleich mit einem lokalen Backup, um Änderungen zu finden").
/// Framework-frei wie <see cref="UntisMatchingService"/> — nimmt nur einfache Objekte/Listen
/// entgegen, kein Datenbankzugriff.
/// </summary>
public class UntisDiffService
{
public const int DefaultLookaheadDays = 14;
public UntisDiffResult Diff(List<UntisIcsEvent> newEvents, List<UntisSnapshotEntry> previousSnapshot,
List<UntisSlotMapping> confirmedMappings, DateOnly today, int lookaheadDays = DefaultLookaheadDays,
List<SupervisionDuty>? existingSupervisionDuties = null, HashSet<DateOnly>? freeDates = null)
{
var previousByUid = previousSnapshot.ToDictionary(s => s.Uid);
// GroupBy statt direktem ToDictionary: robust gegen mehrere Mappings für denselben Slot
// (z.B. Altdaten vor einem Bugfix) - die zuletzt angelegte gewinnt, statt mit einer
// ArgumentException abzubrechen und dadurch den gesamten Poll (Timer-Callback) zu killen.
var mappingByWeekdayStart = confirmedMappings
.Where(m => m.Confirmed && (m.Kind == SubstitutionKind.Lesson ? m.PeriodNumber is not null : m.AfterPeriod is not null))
.GroupBy(m => (m.Weekday, m.StartTime))
.ToDictionary(g => g.Key, g => g.OrderByDescending(m => m.CreatedAt).First());
// Regelmäßige, ohnehin schon jede Woche im Stundenplan sichtbare Aufsichten (siehe
// SupervisionDuty/Einstellungen "Aufsichten") - eine bestätigte Supervision-Zuordnung, die
// KEINER davon entspricht, ist per Definition eine zusätzliche/Vertretungsaufsicht und soll
// deshalb selbst als reguläres Vorkommnis (nicht erst bei Abweichung) im Plan auftauchen.
var regularDutyKeys = (existingSupervisionDuties ?? [])
.Select(d => (d.Weekday, d.AfterPeriod))
.ToHashSet();
var substitutions = new List<SubstitutionEntry>();
var snapshotToSave = new List<UntisSnapshotEntry>();
var externalIdsToDelete = new List<string>();
var newEventsByUid = newEvents.ToDictionary(e => e.Uid);
foreach (var evt in newEvents)
{
snapshotToSave.Add(new UntisSnapshotEntry
{
Id = previousByUid.TryGetValue(evt.Uid, out var existing) ? existing.Id : Guid.NewGuid(),
Uid = evt.Uid, Date = evt.Date, StartTime = evt.StartTime, EndTime = evt.EndTime,
Summary = evt.Summary, Location = evt.Location, Description = evt.Description,
Status = evt.Status, LastSeenAt = DateTime.UtcNow,
});
if (!mappingByWeekdayStart.TryGetValue((evt.Weekday, evt.StartTime), out var mapping)) continue;
if (string.Equals(evt.Status, "CANCELLED", StringComparison.OrdinalIgnoreCase))
{
substitutions.Add(mapping.Kind == SubstitutionKind.Lesson
? BuildCancelledLesson(evt.Date, mapping, evt.Uid)
: BuildSupervisionNote(evt.Date, mapping, evt.Uid, "Laut WebUntis abgesagt."));
continue;
}
if (mapping.Kind == SubstitutionKind.Lesson)
{
if (DeviationReason(evt, mapping) is { } reason)
substitutions.Add(BuildChangedLesson(evt, mapping, reason));
else
externalIdsToDelete.Add(evt.Uid); // war ggf. vorher fälschlich als Vertretung erkannt
}
else if (!regularDutyKeys.Contains((mapping.Weekday, mapping.AfterPeriod!.Value)))
{
// Nutzer-Feedback: "Auch die Extra-Aufsicht ist dann nicht im Plan [...] So macht
// doch der Sync nur so halb Sinn" - ohne passende reguläre Aufsicht IST dieses
// Vorkommnis selbst schon die meldenswerte Vertretung, nicht erst eine Abweichung
// davon.
substitutions.Add(BuildSupervisionNote(evt.Date, mapping, evt.Uid, "Zusätzliche Aufsicht laut WebUntis."));
}
else
{
externalIdsToDelete.Add(evt.Uid); // jetzt als reguläre Aufsicht erkannt, keine Meldung mehr nötig
}
}
// Aufsichten: weiterhin rein reaktiv gegen den letzten Snapshot (kein fester wöchentlicher
// Anspruch, siehe "Zusätzliche Aufsicht" oben - ein Verschwinden ist nur meldenswert, wenn
// die Aufsicht vorher tatsächlich einmal gesehen wurde).
var snapshotIdsToDelete = new List<Guid>();
foreach (var previous in previousSnapshot)
{
if (newEventsByUid.ContainsKey(previous.Uid)) continue;
if (previous.Date < today || previous.Date > today.AddDays(lookaheadDays)) continue;
if (!mappingByWeekdayStart.TryGetValue((previous.Date.DayOfWeek, previous.StartTime), out var mapping)) continue;
if (mapping.Kind != SubstitutionKind.Supervision) continue;
var vanishedId = BuildVanishedExternalId(previous.Date, mapping);
substitutions.Add(BuildSupervisionNote(previous.Date, mapping, vanishedId, "Laut WebUntis entfallen oder übernommen."));
snapshotIdsToDelete.Add(previous.Id);
}
// Unterrichtsstunden: zusätzlich AKTIV prüfen, ob für jedes vom Feed bereits abgedeckte
// künftige Datum dieses Wochentags ein passender Termin existiert - rein reaktives
// Schnappschuss-Diffing (oben) erkennt nur Termine, die zwischen zwei Abrufen
// VERSCHWINDEN, nicht solche, die schon beim allerersten Abruf nie im Feed auftauchten
// (Nutzer-Feedback: "Am 27.08. fällt eine Stunde NAT in der 8c aus. Dieser Ausfall steht
// nicht im Plan [...] die Klasse ist dort weg, der Unterricht wird definitiv nicht
// stattfinden" - WebUntis hatte diesen Ausfall von Anfang an nie als Termin gelistet, es
// gab also nie einen Schnappschuss-Eintrag, der hätte "verschwinden" können). Begrenzt auf
// das vom Feed tatsächlich abgedeckte Zeitfenster (jüngstes gesehenes Datum), damit nicht
// Tage jenseits des vom Feed veröffentlichten Horizonts fälschlich als Ausfall gelten, und
// um Ferien/Feiertage bereinigt (freeDates), an denen WebUntis ohnehin keinen Termin führt.
if (newEvents.Count > 0)
{
var maxKnownDate = newEvents.Max(e => e.Date);
var horizonEnd = maxKnownDate < today.AddDays(lookaheadDays) ? maxKnownDate : today.AddDays(lookaheadDays);
var eventDates = newEvents.Select(e => (e.Date, e.StartTime)).ToHashSet();
var freeDateSet = freeDates ?? [];
foreach (var mapping in mappingByWeekdayStart.Values.Where(m => m.Kind == SubstitutionKind.Lesson))
{
for (var date = today; date <= horizonEnd; date = date.AddDays(1))
{
if (date.DayOfWeek != mapping.Weekday) continue;
if (freeDateSet.Contains(date)) continue;
if (eventDates.Contains((date, mapping.StartTime)))
{
// War evtl. an einem früheren Poll (noch) als fehlend gemeldet - jetzt
// wieder im Feed vorhanden, die alte Meldung räumt sich damit selbst ab.
externalIdsToDelete.Add(BuildMissingExternalId(date, mapping));
continue;
}
substitutions.Add(BuildCancelledLesson(date, mapping, BuildMissingExternalId(date, mapping)));
}
}
}
return new UntisDiffResult
{
SubstitutionsToSave = substitutions, SnapshotToSave = snapshotToSave,
SnapshotIdsToDelete = snapshotIdsToDelete, SubstitutionExternalIdsToDelete = externalIdsToDelete,
};
}
// Nutzer-Feedback: "Ich habe das aufgelöst und den Mathematik E-Kurs ausgewählt [...] Diese
// manuelle Verknüpfung ist aber jetzt scheinbar vergessen" — Ursache: ClassToken wird beim
// Bestätigen kompakt ohne Leerzeichen gespeichert ("10a;10b;10c"), WebUntis trennt die
// Klassen in DESCRIPTION aber mit "; " (Semikolon + Leerzeichen), z.B. "10a; 10b; 10c;
// Gastro HED". Ein erster Fix entfernte Leerzeichen nur auf der evt.Description-Seite — blieb
// aber weiterhin falsch, wenn mapping.ClassToken selbst ein eingebettetes Leerzeichen enthält
// (z.B. wenn die reale DESCRIPTION mehrere Klassen NICHT mit ";", sondern mit "," trennt:
// ExtractClassTokens fand dann kein ";" und behandelte "10a, 10b, 10c" als EIN Token mit
// Leerzeichen/Kommas statt drei getrennte - ein Vergleich gegen die leerzeichenbereinigte
// Description konnte dieses Token dann NIE mehr finden, unabhängig von der Auswahl im Dialog).
// Fix: beide Seiten symmetrisch von Leerzeichen befreien (gleiches Prinzip wie
// UntisMatchingService.Normalize für den Gruppennamen-Abgleich), plus ExtractClassTokens
// akzeptiert jetzt sowohl ";" als auch "," als Trennzeichen zwischen Klassen.
//
// Trotzdem meldet der Nutzer weiterhin falsche Vertretungen für genau diese Art Muster
// ("Immer die Kurse mit Lerngruppen, die aus mehreren Klassen zusammengesetzt sind") - der
// genaue Grund lässt sich ohne Einblick in die echten DESCRIPTION-Werte aus seinem Feed nicht
// mehr blind erraten. Statt eines weiteren ungetesteten Rateversuchs schreibt eine abweichende
// Vertretung deshalb jetzt den GENAUEN Vergleich (roher evt.Summary/evt.Description gegen den
// gespeicherten mapping.Summary/ClassToken) in ihre eigene Beschreibung - der Nutzer kann die
// Ursache dann direkt im Stundenplan ablesen, ohne die iCal-URL erneut teilen zu müssen.
private static string? DeviationReason(UntisIcsEvent evt, UntisSlotMapping mapping)
{
if (evt.Summary != mapping.Summary)
return $"Fach weicht ab: WebUntis meldet „{evt.Summary ?? "(leer)"}“, erwartet war „{mapping.Summary ?? "(leer)"}“.";
if (!RemoveWhitespace(evt.Description).Contains(RemoveWhitespace(mapping.ClassToken)))
return $"Klasse weicht ab: WebUntis-Beschreibung „{evt.Description}“ enthält nicht den erwarteten Klassen-Token „{mapping.ClassToken}“.";
return null;
}
private static string RemoveWhitespace(string value) =>
new(value.Where(c => !char.IsWhiteSpace(c)).ToArray());
private static SubstitutionEntry BuildCancelledLesson(DateOnly date, UntisSlotMapping mapping, string externalId) => new()
{
Date = date, Kind = SubstitutionKind.Cancelled, PeriodNumber = mapping.PeriodNumber,
Description = "Automatisch über WebUntis-Abgleich erkannt.", ExternalId = externalId,
};
private static SubstitutionEntry BuildChangedLesson(UntisIcsEvent evt, UntisSlotMapping mapping, string reason) => new()
{
Date = evt.Date, Kind = SubstitutionKind.Lesson, PeriodNumber = mapping.PeriodNumber,
GroupId = mapping.GroupId, GroupLabel = mapping.ClassToken,
Description = $"WebUntis: {evt.Summary ?? "?"} · {evt.Location} (regulär: {mapping.Summary ?? "?"}) — {reason}",
ExternalId = evt.Uid,
};
// Aufsichten haben keinen TimetableSlot im Hintergrund (siehe SubstitutionEntry.Kind-Doc) -
// deshalb Kind=Supervision statt Cancelled, auch wenn der Termin verschwunden ist.
private static SubstitutionEntry BuildSupervisionNote(DateOnly date, UntisSlotMapping mapping,
string externalId, string reason) => new()
{
Date = date, Kind = SubstitutionKind.Supervision, AfterPeriod = mapping.AfterPeriod,
Description = $"Automatisch über WebUntis-Abgleich erkannt: {reason}", ExternalId = externalId,
};
// Ein verschwundener Termin hat keine eigene UID mehr im neuen Fetch - ein aus Slot+Datum
// abgeleiteter Ersatzschlüssel dient weiter als Idempotenz-Schlüssel für spätere Polls.
private static string BuildVanishedExternalId(DateOnly date, UntisSlotMapping mapping) =>
$"vanished-{mapping.Id}-{date:yyyyMMdd}";
// Für eine von Anfang an fehlende Stunde gibt es nie eine echte iCal-UID - ein aus
// Slot+Datum abgeleiteter Schlüssel macht wiederholte Polls trotzdem idempotent (Update
// statt Duplikat), unabhängig vom Snapshot-Stand.
private static string BuildMissingExternalId(DateOnly date, UntisSlotMapping mapping) =>
$"missing-{mapping.Id}-{date:yyyyMMdd}";
}
@@ -0,0 +1,241 @@
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
/// <summary>Ein aus vielen Wochen-Vorkommen abgeleitetes reguläres WebUntis-Wochenmuster
/// (Nutzer-Feedback: "eine fuzzy logic, die erst mal die regulären Fächer meiner ical meinem
/// Stundenplan in der App zuordnet").</summary>
public sealed class UntisWeeklyPattern
{
public DayOfWeek Weekday { get; init; }
public TimeOnly StartTime { get; init; }
public TimeOnly EndTime { get; init; }
public string? Summary { get; init; }
/// Klassen-Token(s) aus DESCRIPTION, Lehrkraft-Kürzel bereits abgetrennt — bei kombinierten
/// Gruppen mehrere Einträge (z.B. ["10a", "10b", "10c", "Gastro"]).
public List<string> ClassTokens { get; init; } = [];
public int OccurrenceCount { get; init; }
}
public sealed class UntisSlotMatch
{
public required UntisWeeklyPattern Pattern { get; init; }
/// Erste/primäre Stunde, gesetzt wenn das Muster einen Klassenbezug hat (Unterrichtsstunde) —
/// entspricht CoveredPeriods[0].
public int? PeriodNumber { get; init; }
/// ALLE Stunden, die dieser WebUntis-Termin überdeckt — bei einer Doppelstunde meldet WebUntis
/// EINEN Termin über beide Stundenzeiten hinweg (z.B. 07:5009:20 für Stunde 1+2), während der
/// Stundenplan der App dafür ZWEI TimetableSlot-Einträge (Stunde 1 UND Stunde 2) haben kann.
/// Ohne diese Liste würde die zweite Stunde nie als "zugeordnet" gelten, selbst nachdem der
/// Nutzer das Muster bestätigt hat (Nutzer-Feedback: "ich habe aber jetzt alles zugeordnet,
/// und trotzdem erhalte ich die Warnung, dass 16 Stunden ohne Untis-Zuordnung sind").
public List<int> CoveredPeriods { get; init; } = [];
/// Gesetzt, wenn das Muster KEINEN Klassenbezug hat (z.B. Aufsicht/Springstunde) — die Pause
/// direkt vor der Startzeit (0 = vor der 1. Stunde), siehe UntisSlotMapping.AfterPeriod.
public int? AfterPeriod { get; init; }
public bool IsSupervisionCandidate => Pattern.ClassTokens.Count == 0;
public Guid? SuggestedGroupId { get; init; }
public bool IsConfident { get; init; }
}
public sealed class UntisMatchResult
{
public List<UntisSlotMatch> Matches { get; init; } = [];
/// Vorhandene TimetableSlots, zu denen sich kein passendes WebUntis-Wochenmuster finden ließ
/// (Nutzer-Feedback: "oder der Stundenplan gar nicht mehr passt").
public List<TimetableSlot> UnmatchedTimetableSlots { get; init; } = [];
}
/// <summary>
/// Stufe 1 des WebUntis-Abgleichs (siehe TODO.md): aus einem vollen iCal-Abruf (typischerweise
/// ein Schuljahr an Einzelterminen, siehe IcsParser) das reguläre Wochenmuster ableiten und gegen
/// den in der App gepflegten Stundenplan (TimetableSlot/LearningGroup) abgleichen. Framework-frei
/// wie AttendanceBalanceService — reine Objekte rein, reine Objekte raus, ohne Datenbank- oder
/// HTTP-Zugriff, dadurch ohne Mocking testbar.
/// </summary>
public class UntisMatchingService
{
// Kleine Toleranz gegen Minuten-Rundungsdifferenzen zwischen dem konfigurierten Stundenraster
// und den tatsächlichen WebUntis-Zeiten.
private const int StartTimeToleranceMinutes = 3;
public UntisMatchResult BuildMatches(List<UntisIcsEvent> events, List<LearningGroup> groups,
List<TimetableSlot> timetableSlots, PeriodScheduleService periodSchedule)
{
var teacherToken = DetectTeacherToken(events);
var patterns = BuildWeeklyPatterns(events, teacherToken);
var matches = new List<UntisSlotMatch>();
foreach (var pattern in patterns)
{
// Kein Klassenbezug (z.B. Aufsicht/Springstunde, Nutzer-Feedback: "Zwei Termine sind
// meine Aufsichten, die nicht zugeordnet werden können") - liegt typischerweise in
// einer Pause, nicht auf einer Unterrichtsstunden-Startzeit, deshalb eigener,
// grundsätzlich immer auflösbarer Weg statt ResolvePeriodNumber.
if (pattern.ClassTokens.Count == 0)
{
matches.Add(new UntisSlotMatch { Pattern = pattern, AfterPeriod = ResolveAfterPeriod(pattern.StartTime, periodSchedule) });
continue;
}
var coveredPeriods = ResolveCoveredPeriods(pattern.StartTime, pattern.EndTime, periodSchedule);
var periodNumber = coveredPeriods.Count > 0 ? coveredPeriods[0] : ResolvePeriodNumber(pattern.StartTime, periodSchedule);
var (groupId, confident) = ResolveGroup(pattern.ClassTokens, periodNumber, groups, timetableSlots);
matches.Add(new UntisSlotMatch
{
Pattern = pattern, PeriodNumber = periodNumber, CoveredPeriods = coveredPeriods,
SuggestedGroupId = groupId, IsConfident = confident,
});
}
var matchedSlotKeys = matches
.Where(m => m.SuggestedGroupId is not null)
.SelectMany(m => (m.CoveredPeriods.Count > 0 ? m.CoveredPeriods : m.PeriodNumber is { } p ? [p] : [])
.Select(period => (m.Pattern.Weekday, period, m.SuggestedGroupId!.Value)))
.ToHashSet();
var unmatchedSlots = timetableSlots
.Where(s => !matchedSlotKeys.Contains((s.Weekday, s.PeriodNumber, s.GroupId)))
.ToList();
return new UntisMatchResult { Matches = matches, UnmatchedTimetableSlots = unmatchedSlots };
}
/// Häufigstes letztes Wort in DESCRIPTION über alle Termine — bei einem persönlichen Feed
/// konstant das eigene Kürzel, bewusst nicht hartkodiert (siehe Planungsdokument).
private static string? DetectTeacherToken(List<UntisIcsEvent> events) =>
events
.Select(e => LastToken(e.Description))
.Where(t => t is not null)
.GroupBy(t => t)
.OrderByDescending(g => g.Count())
.Select(g => g.Key)
.FirstOrDefault();
private static string? LastToken(string description)
{
var parts = description.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return parts.Length == 0 ? null : parts[^1];
}
private static List<UntisWeeklyPattern> BuildWeeklyPatterns(List<UntisIcsEvent> events, string? teacherToken)
{
var withTokens = events.Select(e => new
{
Event = e,
ClassTokens = ExtractClassTokens(e.Description, teacherToken),
});
var patterns = new List<UntisWeeklyPattern>();
foreach (var group in withTokens.GroupBy(x => (x.Event.Weekday, x.Event.StartTime, x.Event.EndTime)))
{
var modal = group
.GroupBy(x => (x.Event.Summary, ClassTokens: string.Join(";", x.ClassTokens)))
.OrderByDescending(g => g.Count())
.First();
patterns.Add(new UntisWeeklyPattern
{
Weekday = group.Key.Weekday,
StartTime = group.Key.StartTime,
EndTime = group.Key.EndTime,
Summary = modal.Key.Summary,
ClassTokens = modal.Key.ClassTokens.Length == 0
? [] : modal.Key.ClassTokens.Split(';').ToList(),
OccurrenceCount = modal.Count(),
});
}
return patterns;
}
// "10a; 10b; 10c; Gastro HED" -> ["10a", "10b", "10c", "Gastro"] (Lehrkraft-Token entfernt).
// Trennzeichen zwischen mehreren Klassen wurde nur an einem einzelnen echten Beispiel mit EINER
// Klasse verifiziert (siehe Planungsdokument) - für kombinierte/differenzierte Gruppen ist nicht
// sicher belegt, ob WebUntis ";" oder "," verwendet, deshalb werden beide akzeptiert statt sich
// auf eine Annahme festzulegen.
private static readonly char[] ClassTokenSeparators = [';', ','];
private static List<string> ExtractClassTokens(string description, string? teacherToken)
{
var withoutTeacher = teacherToken is not null && description.EndsWith(teacherToken)
? description[..^teacherToken.Length].TrimEnd()
: description;
if (string.IsNullOrWhiteSpace(withoutTeacher)) return [];
return withoutTeacher.Split(ClassTokenSeparators, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
}
private static int? ResolvePeriodNumber(TimeOnly startTime, PeriodScheduleService periodSchedule)
{
var exact = periodSchedule.Periods.FirstOrDefault(p => p.Start == startTime);
if (exact is not null) return exact.PeriodNumber;
var closest = periodSchedule.Periods
.Select(p => (Period: p, DiffMinutes: Math.Abs((p.Start.ToTimeSpan() - startTime.ToTimeSpan()).TotalMinutes)))
.Where(t => t.DiffMinutes <= StartTimeToleranceMinutes)
.OrderBy(t => t.DiffMinutes)
.FirstOrDefault();
return closest.Period?.PeriodNumber;
}
// Alle konfigurierten Stunden, die vollständig innerhalb [start, end) liegen — bei einer
// einzelnen Stunde genau eine, bei einer von WebUntis zu einem Termin zusammengefassten
// Doppelstunde entsprechend zwei (siehe UntisSlotMatch.CoveredPeriods).
private static List<int> ResolveCoveredPeriods(TimeOnly start, TimeOnly end, PeriodScheduleService periodSchedule)
{
var tolerance = TimeSpan.FromMinutes(StartTimeToleranceMinutes);
return periodSchedule.Periods
.Where(p => p.Start.ToTimeSpan() >= start.ToTimeSpan() - tolerance
&& p.End.ToTimeSpan() <= end.ToTimeSpan() + tolerance)
.OrderBy(p => p.PeriodNumber)
.Select(p => p.PeriodNumber)
.ToList();
}
// Die Pause direkt vor startTime: die zuletzt endende konfigurierte Stunde davor, 0 (vor der
// 1. Stunde) falls keine liegt - anders als ResolvePeriodNumber immer auflösbar, da eine
// Pause per Definition zwischen/vor Stunden liegt statt exakt auf einer Startzeit.
private static int ResolveAfterPeriod(TimeOnly startTime, PeriodScheduleService periodSchedule) =>
periodSchedule.Periods
.Where(p => p.End <= startTime)
.OrderByDescending(p => p.End)
.Select(p => (int?)p.PeriodNumber)
.FirstOrDefault() ?? 0;
private static (Guid? GroupId, bool Confident) ResolveGroup(List<string> classTokens, int? periodNumber,
List<LearningGroup> groups, List<TimetableSlot> timetableSlots)
{
if (classTokens.Count == 0) return (null, false);
var candidates = classTokens
.Select(token => ResolveSingleGroup(token, groups))
.Where(g => g is not null)
.Select(g => g!)
.Distinct()
.ToList();
if (candidates.Count == 1) return (candidates[0].Id, true);
if (candidates.Count == 0) return (null, false);
// Mehrere Klassen im selben Termin (kombinierte/differenzierte Gruppen) — die Gruppe
// bevorzugen, die an dieser Stelle bereits einen TimetableSlot hat.
if (periodNumber is { } period)
{
var withExistingSlot = candidates
.Where(g => timetableSlots.Any(s => s.GroupId == g.Id && s.PeriodNumber == period))
.ToList();
if (withExistingSlot.Count == 1) return (withExistingSlot[0].Id, true);
}
return (null, false); // mehrdeutig - der Nutzer entscheidet im Review-Dialog
}
private static LearningGroup? ResolveSingleGroup(string token, List<LearningGroup> groups)
{
var exact = groups.FirstOrDefault(g => string.Equals(g.Name, token, StringComparison.OrdinalIgnoreCase));
if (exact is not null) return exact;
var normalizedToken = Normalize(token);
return groups.FirstOrDefault(g => Normalize(g.Name) == normalizedToken);
}
private static string Normalize(string value) =>
new(value.Where(c => !char.IsWhiteSpace(c)).Select(char.ToLowerInvariant).ToArray());
}
@@ -0,0 +1,41 @@
using System.Text.Json;
namespace LehrerApp.Core.Services;
internal class WorkloadSettingsConfig
{
public double RequiredWeeklyHours { get; set; }
}
/// <summary>Pflichtstundenzahl pro Woche für den Ist-Soll-Abgleich in der Arbeitszeitauswertung (6.3.2).</summary>
public class WorkloadSettingsService
{
private readonly string _configPath;
private WorkloadSettingsConfig _config;
public double RequiredWeeklyHours => _config.RequiredWeeklyHours;
public WorkloadSettingsService(string appDataPath)
{
_configPath = Path.Combine(appDataPath, "workloadsettings.json");
_config = Load();
}
public void SetRequiredWeeklyHours(double hours)
{
_config.RequiredWeeklyHours = hours;
File.WriteAllText(_configPath, JsonSerializer.Serialize(_config));
}
private WorkloadSettingsConfig Load()
{
try
{
if (File.Exists(_configPath))
return JsonSerializer.Deserialize<WorkloadSettingsConfig>(File.ReadAllText(_configPath))
?? new WorkloadSettingsConfig();
}
catch { /* beschädigte Konfiguration -> Standardwert */ }
return new WorkloadSettingsConfig();
}
}
+9
View File
@@ -0,0 +1,9 @@
using Xunit;
// LiteDB nutzt einen statischen, geteilten BsonMapper.Global für die Reflection-basierte
// Index-Auflösung (EnsureIndex mit Lambda-Ausdrücken). Bei paralleler Testausführung über
// mehrere Testklassen hinweg (xUnit-Standard) konkurrieren mehrere Threads beim erstmaligen
// Aufbau der Typ-Metadaten für verschiedene Modelle — das führt zu sporadischen
// "Member X not found on BsonMapper"-Fehlern, die mit dem eigentlichen Testinhalt nichts zu
// tun haben. Tests in diesem Projekt laufen deshalb sequenziell.
[assembly: CollectionBehavior(DisableTestParallelization = true)]
@@ -0,0 +1,213 @@
using LehrerApp.Core.Models;
using LehrerApp.Data.Repositories;
using Xunit;
namespace LehrerApp.Data.Tests;
// Baustein 4: pro öffentlichem Repository-Aufruf genau EIN Sync-Ereignis, auch wenn die
// eigentliche Kaskade mehrere Collections betrifft. Batch-Methoden feuern ein Ereignis pro
// betroffener Entität.
public sealed class ChangeHookCascadeTests
{
private static LiteDbContext NewInMemoryContext() => new(new MemoryStream());
[Fact]
public void GroupRepository_Delete_LoestGenauEinEreignisAusTrotzKaskade()
{
using var db = NewInMemoryContext();
var groupRepo = new GroupRepository(db);
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
groupRepo.Save(group);
db.Grades.Insert(new Grade { GroupId = group.Id, StudentId = Guid.NewGuid() });
db.Exams.Insert(new Exam { GroupId = group.Id });
var calls = new List<(string EntityType, string Operation)>();
db.OnChange = (type, id, op, payload) => calls.Add((type, op));
groupRepo.Delete(group.Id);
var call = Assert.Single(calls);
Assert.Equal((nameof(LearningGroup), "Delete"), call);
}
[Fact]
public void ExamRepository_Delete_LoestGenauEinEreignisAusTrotzErgebnisKaskade()
{
using var db = NewInMemoryContext();
var repo = new ExamRepository(db);
var exam = new Exam { GroupId = Guid.NewGuid() };
repo.Save(exam);
db.ExamResults.Insert(new ExamResult { ExamId = exam.Id, StudentId = Guid.NewGuid() });
db.ExamResults.Insert(new ExamResult { ExamId = exam.Id, StudentId = Guid.NewGuid() });
var calls = new List<(string EntityType, string Operation)>();
db.OnChange = (type, id, op, payload) => calls.Add((type, op));
repo.Delete(exam.Id);
var call = Assert.Single(calls);
Assert.Equal((nameof(Exam), "Delete"), call);
}
[Fact]
public void ParticipationSessionRepository_Delete_LoestGenauEinEreignisAusTrotzEintragKaskade()
{
using var db = NewInMemoryContext();
var repo = new ParticipationSessionRepository(db);
var session = new ParticipationSession { GroupId = Guid.NewGuid() };
repo.Save(session);
db.ParticipationEntries.Insert(new ParticipationEntry { SessionId = session.Id, StudentId = Guid.NewGuid() });
var calls = new List<(string EntityType, string Operation)>();
db.OnChange = (type, id, op, payload) => calls.Add((type, op));
repo.Delete(session.Id);
var call = Assert.Single(calls);
Assert.Equal((nameof(ParticipationSession), "Delete"), call);
}
[Fact]
public void ExamResultRepository_SaveMany_LoestEinEreignisProEintragAus()
{
using var db = NewInMemoryContext();
var repo = new ExamResultRepository(db);
var results = new List<ExamResult>
{
new() { ExamId = Guid.NewGuid(), StudentId = Guid.NewGuid() },
new() { ExamId = Guid.NewGuid(), StudentId = Guid.NewGuid() },
new() { ExamId = Guid.NewGuid(), StudentId = Guid.NewGuid() },
};
var calls = new List<(string EntityType, string EntityId, string Operation)>();
db.OnChange = (type, id, op, payload) => calls.Add((type, id, op));
repo.SaveMany(results);
Assert.Equal(3, calls.Count);
Assert.All(calls, c => Assert.Equal((nameof(ExamResult), "Save"), (c.EntityType, c.Operation)));
Assert.Equal(results.Select(r => r.Id.ToString()).OrderBy(x => x), calls.Select(c => c.EntityId).OrderBy(x => x));
}
[Fact]
public void ParticipationRepository_SaveMany_LoestEinEreignisProEintragAus()
{
using var db = NewInMemoryContext();
var repo = new ParticipationRepository(db);
var sessionId = Guid.NewGuid();
var entries = new List<ParticipationEntry>
{
new() { SessionId = sessionId, StudentId = Guid.NewGuid() },
new() { SessionId = sessionId, StudentId = Guid.NewGuid() },
};
var calls = new List<(string EntityType, string Operation)>();
db.OnChange = (type, id, op, payload) => calls.Add((type, op));
repo.SaveMany(entries);
Assert.Equal(2, calls.Count);
Assert.All(calls, c => Assert.Equal((nameof(ParticipationEntry), "Save"), c));
}
[Fact]
public void ParticipationRepository_DeleteBySession_LoestEinEreignisProEintragAus()
{
using var db = NewInMemoryContext();
var repo = new ParticipationRepository(db);
var sessionId = Guid.NewGuid();
repo.Save(new ParticipationEntry { SessionId = sessionId, StudentId = Guid.NewGuid() });
repo.Save(new ParticipationEntry { SessionId = sessionId, StudentId = Guid.NewGuid() });
var calls = new List<(string EntityType, string Operation)>();
db.OnChange = (type, id, op, payload) => calls.Add((type, op));
repo.DeleteBySession(sessionId);
Assert.Equal(2, calls.Count);
Assert.All(calls, c => Assert.Equal((nameof(ParticipationEntry), "Delete"), c));
}
[Fact]
public void CompetencyDomainRepository_ReplaceForSubjectAndGrade_LoestDeleteFuerAlteUndSaveFuerNeueAus()
{
using var db = NewInMemoryContext();
var repo = new CompetencyDomainRepository(db);
var subjectId = Guid.NewGuid();
repo.Save(new CompetencyDomain { SubjectId = subjectId, GradeLevel = 8, Name = "Alt" });
var calls = new List<(string EntityType, string Operation)>();
db.OnChange = (type, id, op, payload) => calls.Add((type, op));
repo.ReplaceForSubjectAndGrade(subjectId, 8,
[
new CompetencyDomain { Name = "Neu 1" },
new CompetencyDomain { Name = "Neu 2" },
]);
Assert.Equal(3, calls.Count);
Assert.Equal(1, calls.Count(c => c.Operation == "Delete"));
Assert.Equal(2, calls.Count(c => c.Operation == "Save"));
Assert.All(calls, c => Assert.Equal(nameof(CompetencyDomain), c.EntityType));
}
[Fact]
public void CompetencyDomainRepository_DeleteBySubjectAndGrade_LoestEinEreignisProDomaenAus()
{
using var db = NewInMemoryContext();
var repo = new CompetencyDomainRepository(db);
var subjectId = Guid.NewGuid();
repo.Save(new CompetencyDomain { SubjectId = subjectId, GradeLevel = 8, Name = "A" });
repo.Save(new CompetencyDomain { SubjectId = subjectId, GradeLevel = 8, Name = "B" });
var calls = new List<(string EntityType, string Operation)>();
db.OnChange = (type, id, op, payload) => calls.Add((type, op));
repo.DeleteBySubjectAndGrade(subjectId, 8);
Assert.Equal(2, calls.Count);
Assert.All(calls, c => Assert.Equal((nameof(CompetencyDomain), "Delete"), c));
}
[Fact]
public void DocumentationRepository_Delete_LoestSaveMitIsDeletedAus()
{
using var db = NewInMemoryContext();
var repo = new DocumentationRepository(db);
var doc = new Documentation { StudentId = Guid.NewGuid(), Title = "Gespräch" };
repo.Save(doc);
var calls = new List<(string EntityType, string Operation, object? Payload)>();
db.OnChange = (type, id, op, payload) => calls.Add((type, op, payload));
repo.Delete(doc.Id);
var call = Assert.Single(calls);
Assert.Equal((nameof(Documentation), "Save"), (call.EntityType, call.Operation));
Assert.True(((Documentation)call.Payload!).IsDeleted);
}
[Fact]
public void DocumentationRepository_HardDelete_LoestDeleteAus()
{
using var db = NewInMemoryContext();
var repo = new DocumentationRepository(db);
var doc = new Documentation { StudentId = Guid.NewGuid(), Title = "Gespräch" };
repo.Save(doc);
var calls = new List<(string EntityType, string Operation)>();
db.OnChange = (type, id, op, payload) => calls.Add((type, op));
repo.HardDelete(doc.Id);
var call = Assert.Single(calls);
Assert.Equal((nameof(Documentation), "Delete"), call);
}
[Fact]
public void VorgangRepository_Delete_LoestSaveMitIsDeletedAus()
{
using var db = NewInMemoryContext();
var repo = new VorgangRepository(db);
var vorgang = new Vorgang { Title = "Konflikt" };
repo.Save(vorgang);
var calls = new List<(string EntityType, string Operation, object? Payload)>();
db.OnChange = (type, id, op, payload) => calls.Add((type, op, payload));
repo.Delete(vorgang.Id);
var call = Assert.Single(calls);
Assert.Equal((nameof(Vorgang), "Save"), (call.EntityType, call.Operation));
Assert.True(((Vorgang)call.Payload!).IsDeleted);
}
}
@@ -0,0 +1,204 @@
using LehrerApp.Core.Models;
using LehrerApp.Data.Repositories;
using Xunit;
namespace LehrerApp.Data.Tests;
// Baustein 3: prüft für jedes der ~19 "einfachen" Repositories (keine Kaskaden/Batches), dass
// Save/Delete den OnChange-Hook mit dem korrekten EntityType auslöst. Tabellengetrieben statt
// eine Datei pro Repository, um die immer gleiche Prüfung nicht 19x zu wiederholen.
public sealed class ChangeHookMatrixTests
{
public static IEnumerable<object[]> Cases()
{
yield return Case("SeatingPlan", nameof(SeatingPlan), db =>
{
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
new GroupRepository(db).Save(group);
var repo = new SeatingPlanRepository(db);
var plan = new SeatingPlan { GroupId = group.Id, Name = "Standard", Rows = 2, Columns = 2 };
repo.Save(plan);
return (plan.Id, () => repo.Delete(plan.Id));
});
yield return Case("GroupMembership", nameof(GroupMembership), db =>
{
var repo = new GroupMembershipRepository(db);
var membership = new GroupMembership { StudentId = Guid.NewGuid(), GroupId = Guid.NewGuid() };
repo.Save(membership);
return (membership.Id, () => repo.Delete(membership.Id));
});
yield return Case("GradingKeyTemplate", nameof(GradingKeyTemplate), db =>
{
var repo = new GradingKeyTemplateRepository(db);
var template = new GradingKeyTemplate { Name = "Standard", GradingSystem = GradingSystem.Points0To15 };
repo.Save(template);
return (template.Id, () => repo.Delete(template.Id));
});
yield return Case("Grade", nameof(Grade), db =>
{
var repo = new GradeRepository(db);
var grade = new Grade { GroupId = Guid.NewGuid(), StudentId = Guid.NewGuid(), Value = "2" };
repo.Save(grade);
return (grade.Id, () => repo.Delete(grade.Id));
});
yield return Case("GradingScheme", nameof(GradingScheme), db =>
{
var repo = new GradingSchemeRepository(db);
var scheme = new GradingScheme { GroupId = Guid.NewGuid(), ExamsPercent = 60, ParticipationPercent = 30, OtherPercent = 10 };
repo.Save(scheme);
return (scheme.Id, () => repo.Delete(scheme.Id));
});
yield return Case("ReportGrade", nameof(ReportGrade), db =>
{
var repo = new ReportGradeRepository(db);
var grade = new ReportGrade { StudentId = Guid.NewGuid(), GroupId = Guid.NewGuid(), Period = "1. Halbjahr" };
repo.Save(grade);
return (grade.Id, () => repo.Delete(grade.Id));
});
yield return Case("Unit", nameof(Unit), db =>
{
var repo = new UnitRepository(db);
var unit = new Unit { GroupId = Guid.NewGuid(), Title = "Einheit" };
repo.Save(unit);
return (unit.Id, () => repo.Delete(unit.Id));
});
yield return Case("Lesson", nameof(Lesson), db =>
{
var repo = new LessonRepository(db);
var lesson = new Lesson { UnitId = Guid.NewGuid(), GroupId = Guid.NewGuid(), Date = new DateOnly(2026, 3, 12) };
repo.Save(lesson);
return (lesson.Id, () => repo.Delete(lesson.Id));
});
yield return Case("WorkTask", nameof(WorkTask), db =>
{
var repo = new WorkTaskRepository(db);
var task = new WorkTask { Title = "Klausur korrigieren", Category = TaskCategory.Correction };
repo.Save(task);
return (task.Id, () => repo.Delete(task.Id));
});
yield return Case("TimeEntry", nameof(TimeEntry), db =>
{
var repo = new TimeEntryRepository(db);
var entry = new TimeEntry { Date = new DateOnly(2026, 3, 12), DurationMinutes = 30, Category = "Vorbereitung" };
repo.Save(entry);
return (entry.Id, () => repo.Delete(entry.Id));
});
yield return Case("ParticipationAspect", nameof(ParticipationAspect), db =>
{
var repo = new ParticipationAspectRepository(db);
var aspect = new ParticipationAspect { GroupId = Guid.NewGuid(), Key = "quality", Label = "Qualität" };
repo.Save(aspect);
return (aspect.Id, () => repo.Delete(aspect.Id));
});
yield return Case("ParticipationSection", nameof(ParticipationSection), db =>
{
var repo = new ParticipationSectionRepository(db);
var section = new ParticipationSection
{
GroupId = Guid.NewGuid(), Label = "1. Abschnitt",
StartDate = new DateOnly(2026, 3, 1), EndDate = new DateOnly(2026, 4, 1),
};
repo.Save(section);
return (section.Id, () => repo.Delete(section.Id));
});
yield return Case("Subject", nameof(Subject), db =>
{
var repo = new SubjectRepository(db);
var subject = new Subject { Name = "Chemie" };
repo.Save(subject);
return (subject.Id, () => repo.Delete(subject.Id));
});
yield return Case("ShorthandCode", nameof(ShorthandCode), db =>
{
var repo = new ShorthandCodeRepository(db);
var code = new ShorthandCode { Code = "Tb", Label = "Tafelbild" };
repo.Save(code);
return (code.Id, () => repo.Delete(code.Id));
});
yield return Case("AlternativeLessonPath", nameof(AlternativeLessonPath), db =>
{
var repo = new AlternativeLessonPathRepository(db);
var path = new AlternativeLessonPath { Name = "Kurzversion" };
repo.Save(path);
return (path.Id, () => repo.Delete(path.Id));
});
yield return Case("TimetableSlot", nameof(TimetableSlot), db =>
{
var repo = new TimetableSlotRepository(db);
var slot = new TimetableSlot { GroupId = Guid.NewGuid(), Weekday = DayOfWeek.Tuesday, PeriodNumber = 3 };
repo.Save(slot);
return (slot.Id, () => repo.Delete(slot.Id));
});
yield return Case("SchoolHoliday", nameof(SchoolHoliday), db =>
{
var repo = new SchoolHolidayRepository(db);
var holiday = new SchoolHoliday { Name = "Osterferien", StartDate = new DateOnly(2026, 3, 30), EndDate = new DateOnly(2026, 4, 10) };
repo.Save(holiday);
return (holiday.Id, () => repo.Delete(holiday.Id));
});
yield return Case("SupervisionDuty", nameof(SupervisionDuty), db =>
{
var repo = new SupervisionDutyRepository(db);
var duty = new SupervisionDuty { Weekday = DayOfWeek.Monday, AfterPeriod = 2, Location = "Pausenhof" };
repo.Save(duty);
return (duty.Id, () => repo.Delete(duty.Id));
});
yield return Case("SubstitutionEntry", nameof(SubstitutionEntry), db =>
{
var repo = new SubstitutionEntryRepository(db);
var entry = new SubstitutionEntry { Date = new DateOnly(2026, 3, 12), Kind = SubstitutionKind.Lesson, PeriodNumber = 3, Description = "Vertretung 8a" };
repo.Save(entry);
return (entry.Id, () => repo.Delete(entry.Id));
});
}
[Theory]
[MemberData(nameof(Cases))]
public void Save_LoestOnChangeMitKorrektemEntityTypeAus(
string _, string entityType, Func<LiteDbContext, (Guid Id, Action Delete)> setup)
{
using var db = new LiteDbContext(new MemoryStream());
var calls = new List<(string EntityType, string Operation)>();
db.OnChange = (type, id, op, payload) => calls.Add((type, op));
setup(db);
Assert.Contains((entityType, "Save"), calls);
}
[Theory]
[MemberData(nameof(Cases))]
public void Delete_LoestOnChangeMitKorrektemEntityTypeAus(
string _, string entityType, Func<LiteDbContext, (Guid Id, Action Delete)> setup)
{
using var db = new LiteDbContext(new MemoryStream());
var (id, delete) = setup(db);
var calls = new List<(string EntityType, string EntityId, string Operation)>();
db.OnChange = (type, entId, op, payload) => calls.Add((type, entId, op));
delete();
Assert.Contains((entityType, id.ToString(), "Delete"), calls);
}
private static object[] Case(string name, string entityType, Func<LiteDbContext, (Guid Id, Action Delete)> setup) =>
[name, entityType, setup];
}
+80
View File
@@ -0,0 +1,80 @@
using LehrerApp.Core.Models;
using LehrerApp.Data.Repositories;
using Xunit;
namespace LehrerApp.Data.Tests;
// Vorlage-Test für den OnChange-Hook (Baustein 2), den die übrigen Repositories in den
// Bausteinen 3/4 auf dieselbe Weise bekommen.
public sealed class ChangeHookTests
{
private static LiteDbContext NewInMemoryContext() => new(new MemoryStream());
[Fact]
public void StudentRepository_Save_LoestOnChangeMitKorrektenArgumentenAus()
{
using var db = NewInMemoryContext();
var calls = new List<(string EntityType, string EntityId, string Operation, object? Payload)>();
db.OnChange = (type, id, op, payload) => calls.Add((type, id, op, payload));
var repo = new StudentRepository(db);
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
repo.Save(student);
var call = Assert.Single(calls);
Assert.Equal("Student", call.EntityType);
Assert.Equal(student.Id.ToString(), call.EntityId);
Assert.Equal("Save", call.Operation);
Assert.Same(student, call.Payload);
}
[Fact]
public void StudentRepository_Delete_LoestOnChangeMitNullPayloadAus()
{
using var db = NewInMemoryContext();
var repo = new StudentRepository(db);
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
repo.Save(student);
var calls = new List<(string EntityType, string EntityId, string Operation, object? Payload)>();
db.OnChange = (type, id, op, payload) => calls.Add((type, id, op, payload));
repo.Delete(student.Id);
var call = Assert.Single(calls);
Assert.Equal("Student", call.EntityType);
Assert.Equal(student.Id.ToString(), call.EntityId);
Assert.Equal("Delete", call.Operation);
Assert.Null(call.Payload);
}
[Fact]
public void StudentRepository_Save_OhneGesetztenHook_WirftNicht()
{
using var db = NewInMemoryContext();
var repo = new StudentRepository(db);
var exception = Record.Exception(() => repo.Save(new Student { FirstName = "Anna", LastName = "Beispiel" }));
Assert.Null(exception);
}
// Kein eigener Fall in ChangeHookMatrixTests: UntisHubJobStateRepository kennt kein Delete
// (siehe IUntisHubJobStateRepository), passt also nicht in deren Save+Delete-Tabellenform.
[Fact]
public void UntisHubJobStateRepository_Save_LoestOnChangeAus()
{
using var db = NewInMemoryContext();
var calls = new List<(string EntityType, string EntityId, string Operation, object? Payload)>();
db.OnChange = (type, id, op, payload) => calls.Add((type, id, op, payload));
var repo = new UntisHubJobStateRepository(db);
var state = new UntisHubJobState { Kind = UntisHubJobKind.OffenePeriods, LastRunAt = DateTime.UtcNow };
repo.Save(state);
var call = Assert.Single(calls);
Assert.Equal(nameof(UntisHubJobState), call.EntityType);
Assert.Equal(state.Id.ToString(), call.EntityId);
Assert.Equal("Save", call.Operation);
Assert.Same(state, call.Payload);
}
}
@@ -0,0 +1,139 @@
using LiteDB;
using Xunit;
namespace LehrerApp.Data.Tests;
public sealed class DatabaseEncryptionServiceTests
{
[Fact]
public void IsEncrypted_OhneVorhandeneDatei_GibtFalseZurueck()
{
using var temp = new TempDatabase();
var service = new DatabaseEncryptionService();
Assert.False(service.IsEncrypted(temp.Path));
}
[Fact]
public void IsEncrypted_UnverschluesselteDatenbank_GibtFalseZurueck()
{
using var temp = new TempDatabase();
using (var db = new LiteDatabase(temp.Path))
db.GetCollection<BsonDocument>("t").Insert(new BsonDocument { ["x"] = 1 });
var service = new DatabaseEncryptionService();
Assert.False(service.IsEncrypted(temp.Path));
}
[Fact]
public void SetPassword_VerschluesseltDieDatenbankUndBehaeltDieDaten()
{
using var temp = new TempDatabase();
using (var db = new LiteDatabase(temp.Path))
db.GetCollection<BsonDocument>("t").Insert(new BsonDocument { ["Name"] = "Hallo" });
var service = new DatabaseEncryptionService();
service.SetPassword(temp.Path, currentPassword: null, newPassword: "geheim123");
Assert.True(service.IsEncrypted(temp.Path));
Assert.True(service.VerifyPassword(temp.Path, "geheim123"));
Assert.False(service.VerifyPassword(temp.Path, "falsch"));
using var reopened = new LiteDatabase(new ConnectionString(temp.Path) { Password = "geheim123" });
var doc = reopened.GetCollection<BsonDocument>("t").FindAll().First();
Assert.Equal("Hallo", doc["Name"].AsString);
}
[Fact]
public void SetPassword_EntferntDasPasswortWiederWennNeuesPasswortNullIst()
{
using var temp = new TempDatabase();
var service = new DatabaseEncryptionService();
using (var db = new LiteDatabase(new ConnectionString(temp.Path) { Password = "geheim123" }))
db.GetCollection<BsonDocument>("t").Insert(new BsonDocument { ["x"] = 1 });
service.SetPassword(temp.Path, currentPassword: "geheim123", newPassword: null);
Assert.False(service.IsEncrypted(temp.Path));
using var reopened = new LiteDatabase(temp.Path);
Assert.Single(reopened.GetCollection<BsonDocument>("t").FindAll());
}
[Fact]
public void CanOpenAndRead_OhneVorhandeneDatei_GibtFalseZurueck()
{
using var temp = new TempDatabase();
var service = new DatabaseEncryptionService();
Assert.False(service.CanOpenAndRead(temp.Path, null));
}
[Fact]
public void CanOpenAndRead_GueltigeUnverschluesselteDatenbank_GibtTrueZurueck()
{
using var temp = new TempDatabase();
using (var db = new LiteDatabase(temp.Path))
db.GetCollection<BsonDocument>("t").Insert(new BsonDocument { ["x"] = 1 });
var service = new DatabaseEncryptionService();
Assert.True(service.CanOpenAndRead(temp.Path, null));
}
[Fact]
public void CanOpenAndRead_GueltigeVerschluesselteDatenbankMitRichtigemPasswort_GibtTrueZurueck()
{
using var temp = new TempDatabase();
using (var db = new LiteDatabase(new ConnectionString(temp.Path) { Password = "geheim123" }))
db.GetCollection<BsonDocument>("t").Insert(new BsonDocument { ["x"] = 1 });
var service = new DatabaseEncryptionService();
Assert.True(service.CanOpenAndRead(temp.Path, "geheim123"));
}
[Fact]
public void CanOpenAndRead_FalschesPasswort_GibtFalseZurueck()
{
using var temp = new TempDatabase();
using (var db = new LiteDatabase(new ConnectionString(temp.Path) { Password = "geheim123" }))
db.GetCollection<BsonDocument>("t").Insert(new BsonDocument { ["x"] = 1 });
var service = new DatabaseEncryptionService();
Assert.False(service.CanOpenAndRead(temp.Path, "falsch"));
}
[Fact]
public void CanOpenAndRead_BeschaedigteDatei_GibtFalseZurueck()
{
using var temp = new TempDatabase();
// Eine kurze Textdatei initialisiert LiteDB stillschweigend als neue, leere Datenbank
// (kleiner als eine Seite) — erst genug Datenmüll jenseits der Seitengröße lässt das
// Datei-Layout tatsächlich fehlschlagen und simuliert eine echte Beschädigung
// (z.B. durch einen vollen Datenträger während der Backup-Kopie).
File.WriteAllBytes(temp.Path, Enumerable.Repeat((byte)0x42, 32_768).ToArray());
var service = new DatabaseEncryptionService();
Assert.False(service.CanOpenAndRead(temp.Path, null));
}
private sealed class TempDatabase : IDisposable
{
private readonly string _directory = System.IO.Path.Combine(
System.IO.Path.GetTempPath(), $"lehrerapp-dbenc-tests-{Guid.NewGuid():N}");
public string Path { get; }
public TempDatabase()
{
Directory.CreateDirectory(_directory);
Path = System.IO.Path.Combine(_directory, "test.db");
}
public void Dispose()
{
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
}
}
}
@@ -0,0 +1,59 @@
using Xunit;
namespace LehrerApp.Data.Tests;
public sealed class LiteAttachmentStorageTests
{
private static LiteDbContext NewInMemoryContext() => new(new MemoryStream());
[Fact]
public void Upload_OpenRead_RoundtripLiefertDenGespeichertenInhalt()
{
using var db = NewInMemoryContext();
var storage = new LiteAttachmentStorage(db);
var bytes = "Elternbrief-Inhalt äöü"u8.ToArray();
string id;
using (var ms = new MemoryStream(bytes))
id = storage.Upload("Brief.pdf", ms);
using var result = storage.OpenRead(id);
Assert.NotNull(result);
using var reader = new MemoryStream();
result!.CopyTo(reader);
Assert.Equal(bytes, reader.ToArray());
}
[Fact]
public void Upload_GroessereDateiAlsDasLimit_WirftException()
{
using var db = NewInMemoryContext();
var storage = new LiteAttachmentStorage(db);
using var ms = new MemoryStream(new byte[LehrerApp.Core.Interfaces.IAttachmentStorage.MaxSizeBytes + 1]);
Assert.Throws<InvalidOperationException>(() => storage.Upload("zu-gross.bin", ms));
}
[Fact]
public void OpenRead_UnbekannteId_GibtNullZurueck()
{
using var db = NewInMemoryContext();
var storage = new LiteAttachmentStorage(db);
Assert.Null(storage.OpenRead("fehlt"));
}
[Fact]
public void Delete_EntferntDenAnhangEndgueltig()
{
using var db = NewInMemoryContext();
var storage = new LiteAttachmentStorage(db);
string id;
using (var ms = new MemoryStream([1, 2, 3]))
id = storage.Upload("a.bin", ms);
storage.Delete(id);
Assert.Null(storage.OpenRead(id));
}
}
+163
View File
@@ -115,6 +115,169 @@ public sealed class LiteDbContextTests
new ExamResult { StudentId = studentId, ExamId = examId })); new ExamResult { StudentId = studentId, ExamId = examId }));
} }
[Fact]
public void NeueDatenbank_HatDieAktuelleSchemaVersionNachDemErstenOeffnen()
{
using var temp = new TempDatabase();
using var context = new LiteDbContext(temp.Path);
Assert.Equal(4, context.SchemaVersion);
}
[Fact]
public void SchemaVersion_WirdUeberEinenNeustartHinwegBeibehalten()
{
using var temp = new TempDatabase();
using (var first = new LiteDbContext(temp.Path)) { }
using var second = new LiteDbContext(temp.Path);
Assert.Equal(4, second.SchemaVersion);
}
[Fact]
public void MigrateLessonPhases_FasstAltePhaseMethodenUndMaterialienVerlustfreiZusammen()
{
using var temp = new TempDatabase();
var lessonWithDataId = Guid.NewGuid();
var lessonEmptyId = Guid.NewGuid();
using (var legacy = new LiteDatabase(temp.Path))
{
legacy.GetCollection<BsonDocument>("lessons").Insert(new BsonDocument
{
["_id"] = lessonWithDataId,
[nameof(Lesson.UnitId)] = Guid.NewGuid(),
[nameof(Lesson.GroupId)] = Guid.NewGuid(),
["Phase"] = "Einstieg",
["Methods"] = new BsonArray { "Gespräch", "Tafelbild" },
["Materials"] = new BsonArray { "Arbeitsblatt" },
});
// Alte Stunde ganz ohne Phase/Methoden/Materialien — soll leere Phases-Liste bekommen,
// nicht eine leere synthetisierte Zeile.
legacy.GetCollection<BsonDocument>("lessons").Insert(new BsonDocument
{
["_id"] = lessonEmptyId,
[nameof(Lesson.UnitId)] = Guid.NewGuid(),
[nameof(Lesson.GroupId)] = Guid.NewGuid(),
});
}
using (var context = new LiteDbContext(temp.Path))
{
var withData = context.Lessons.FindById(lessonWithDataId);
Assert.NotNull(withData);
Assert.Single(withData.Phases);
Assert.Equal("Einstieg", withData.Phases[0].Name);
Assert.Equal("Gespräch; Tafelbild", withData.Phases[0].Activity);
Assert.Equal("Arbeitsblatt", withData.Phases[0].Material);
Assert.Equal(0, withData.Phases[0].DurationMinutes);
var empty = context.Lessons.FindById(lessonEmptyId);
Assert.NotNull(empty);
Assert.Empty(empty.Phases);
}
using var migrated = new LiteDatabase(temp.Path);
var raw = migrated.GetCollection<BsonDocument>("lessons").FindById(lessonWithDataId);
Assert.False(raw.ContainsKey("Phase"));
Assert.False(raw.ContainsKey("Methods"));
Assert.False(raw.ContainsKey("Materials"));
}
[Fact]
public void MigrateLessonShorthand_FasstVonUndNachInEinFreitextfeldZusammen()
{
using var temp = new TempDatabase();
var lessonId = Guid.NewGuid();
using (var legacy = new LiteDatabase(temp.Path))
{
legacy.GetCollection<BsonDocument>("lessons").Insert(new BsonDocument
{
["_id"] = lessonId,
[nameof(Lesson.UnitId)] = Guid.NewGuid(),
[nameof(Lesson.GroupId)] = Guid.NewGuid(),
[nameof(Lesson.Phases)] = new BsonArray
{
new BsonDocument { ["ShorthandFrom"] = "Tb", ["ShorthandTo"] = "SH" },
new BsonDocument { ["ShorthandFrom"] = "Plenum" }, // nur "Von" gesetzt, kein Pfeil
new BsonDocument { }, // weder Von noch Nach
},
});
}
using (var context = new LiteDbContext(temp.Path))
{
var lesson = context.Lessons.FindById(lessonId);
Assert.NotNull(lesson);
Assert.Equal(3, lesson.Phases.Count);
Assert.Equal("Tb->SH", lesson.Phases[0].Shorthand);
Assert.Equal("Plenum", lesson.Phases[1].Shorthand);
Assert.Equal("", lesson.Phases[2].Shorthand);
}
using var migratedShorthand = new LiteDatabase(temp.Path);
var rawLesson = migratedShorthand.GetCollection<BsonDocument>("lessons").FindById(lessonId);
var rawPhases = rawLesson["Phases"].AsArray;
Assert.False(rawPhases[0].AsDocument.ContainsKey("ShorthandFrom"));
Assert.False(rawPhases[0].AsDocument.ContainsKey("ShorthandTo"));
}
[Fact]
public void MigrateLessonAlternativePaths_ErzeugtKatalogEintraegeUndVerweistDarauf()
{
using var temp = new TempDatabase();
var lesson1Id = Guid.NewGuid();
var lesson2Id = Guid.NewGuid();
using (var legacy = new LiteDatabase(temp.Path))
{
legacy.GetCollection<BsonDocument>("lessons").Insert(new BsonDocument
{
["_id"] = lesson1Id,
[nameof(Lesson.UnitId)] = Guid.NewGuid(),
[nameof(Lesson.GroupId)] = Guid.NewGuid(),
[nameof(Lesson.Phases)] = new BsonArray
{
new BsonDocument { ["AlternativePath"] = "Kurzversion" },
new BsonDocument { ["AlternativePath"] = "" }, // Hauptweg, kein Katalogeintrag
new BsonDocument { }, // gar kein Feld, unangetastet
},
});
legacy.GetCollection<BsonDocument>("lessons").Insert(new BsonDocument
{
["_id"] = lesson2Id,
[nameof(Lesson.UnitId)] = Guid.NewGuid(),
[nameof(Lesson.GroupId)] = Guid.NewGuid(),
[nameof(Lesson.Phases)] = new BsonArray
{
new BsonDocument { ["AlternativePath"] = "Kurzversion" }, // gleicher Name -> gleicher Katalogeintrag
},
});
}
using (var context = new LiteDbContext(temp.Path))
{
var catalog = context.AlternativeLessonPaths.FindAll().ToList();
var entry = Assert.Single(catalog);
Assert.Equal("Kurzversion", entry.Name);
var lesson1 = context.Lessons.FindById(lesson1Id)!;
Assert.Equal(entry.Id, lesson1.Phases[0].AlternativePathId);
Assert.Null(lesson1.Phases[1].AlternativePathId);
Assert.Null(lesson1.Phases[2].AlternativePathId);
var lesson2 = context.Lessons.FindById(lesson2Id)!;
Assert.Equal(entry.Id, lesson2.Phases[0].AlternativePathId); // wiederverwendeter Katalogeintrag
}
using var migrated = new LiteDatabase(temp.Path);
var rawLesson1 = migrated.GetCollection<BsonDocument>("lessons").FindById(lesson1Id);
var rawPhases = rawLesson1["Phases"].AsArray;
Assert.False(rawPhases[0].AsDocument.ContainsKey("AlternativePath"));
}
private sealed class TempDatabase : IDisposable private sealed class TempDatabase : IDisposable
{ {
private readonly string _directory = System.IO.Path.Combine( private readonly string _directory = System.IO.Path.Combine(
File diff suppressed because it is too large Load Diff
+181
View File
@@ -0,0 +1,181 @@
using LehrerApp.Core.Models;
using LehrerApp.Data.Repositories;
using Xunit;
namespace LehrerApp.Data.Tests;
public sealed class TrashTests
{
private static LiteDbContext NewInMemoryContext() => new(new MemoryStream());
[Fact]
public void GradeRepository_DeleteVerschiebtInDenPapierkorbUndRestoreStelltWiederHer()
{
using var db = NewInMemoryContext();
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
new GroupRepository(db).Save(group);
var student = new Student { FirstName = "Anna", LastName = "A" };
db.Students.Insert(student);
var repo = new GradeRepository(db);
var grade = new Grade
{
StudentId = student.Id, GroupId = group.Id, Category = GradeCategory.Oral,
Value = "2", Date = new DateOnly(2026, 3, 4),
};
repo.Save(grade);
repo.Delete(grade.Id);
Assert.Null(db.Grades.FindById(grade.Id));
var trashed = db.TrashedItems.FindAll().Single();
Assert.Equal(nameof(Grade), trashed.EntityType);
Assert.Equal("Note 2 (04.03.2026)", trashed.Summary);
repo.Restore(trashed.Id);
var restored = db.Grades.FindById(grade.Id);
Assert.NotNull(restored);
Assert.Equal("2", restored!.Value);
Assert.Empty(db.TrashedItems.FindAll());
}
[Fact]
public void GradeRepository_RestoreMitUnbekannterTrashIdTutNichts()
{
using var db = NewInMemoryContext();
var repo = new GradeRepository(db);
repo.Restore(Guid.NewGuid());
Assert.Empty(db.Grades.FindAll());
}
[Fact]
public void WorkTaskRepository_DeleteVerschiebtInDenPapierkorbUndRestoreStelltWiederHer()
{
using var db = NewInMemoryContext();
var repo = new WorkTaskRepository(db);
var task = new WorkTask { Title = "Elternbrief schreiben", Category = TaskCategory.Admin };
repo.Save(task);
repo.Delete(task.Id);
Assert.Null(db.Tasks.FindById(task.Id));
Assert.Equal("Elternbrief schreiben", db.TrashedItems.FindAll().Single().Summary);
repo.Restore(db.TrashedItems.FindAll().Single().Id);
Assert.NotNull(db.Tasks.FindById(task.Id));
}
[Fact]
public void TimeEntryRepository_DeleteVerschiebtInDenPapierkorbUndRestoreStelltWiederHer()
{
using var db = NewInMemoryContext();
var repo = new TimeEntryRepository(db);
var entry = new TimeEntry { Category = "Korrektur", Date = new DateOnly(2026, 3, 4), DurationMinutes = 45 };
repo.Save(entry);
repo.Delete(entry.Id);
Assert.Null(db.TimeEntries.FindById(entry.Id));
Assert.Equal("45 Min. Korrektur (04.03.2026)", db.TrashedItems.FindAll().Single().Summary);
repo.Restore(db.TrashedItems.FindAll().Single().Id);
Assert.NotNull(db.TimeEntries.FindById(entry.Id));
}
[Fact]
public void SeatingPlanRepository_DeleteVerschiebtInDenPapierkorbUndRestoreStelltWiederHer()
{
using var db = NewInMemoryContext();
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
new GroupRepository(db).Save(group);
var repo = new SeatingPlanRepository(db);
var plan = new SeatingPlan { GroupId = group.Id, Name = "Standard", Room = "B204", Rows = 2, Columns = 2 };
repo.Save(plan);
repo.Delete(plan.Id);
Assert.Null(db.SeatingPlans.FindById(plan.Id));
Assert.Equal("Standard (Raum B204)", db.TrashedItems.FindAll().Single().Summary);
repo.Restore(db.TrashedItems.FindAll().Single().Id);
Assert.NotNull(db.SeatingPlans.FindById(plan.Id));
}
[Fact]
public void SeatingPlanRepository_DeleteOhneRaumNutztNurDenNamenAlsSummary()
{
using var db = NewInMemoryContext();
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
new GroupRepository(db).Save(group);
var repo = new SeatingPlanRepository(db);
var plan = new SeatingPlan { GroupId = group.Id, Name = "Standard", Rows = 2, Columns = 2 };
repo.Save(plan);
repo.Delete(plan.Id);
Assert.Equal("Standard", db.TrashedItems.FindAll().Single().Summary);
}
[Fact]
public void GradingKeyTemplateRepository_DeleteVerschiebtInDenPapierkorbUndRestoreStelltWiederHer()
{
using var db = NewInMemoryContext();
var repo = new GradingKeyTemplateRepository(db);
var template = new GradingKeyTemplate { Name = "Standard 1-6", GradingSystem = GradingSystem.Grades1To6 };
repo.Save(template);
repo.Delete(template.Id);
Assert.Null(db.GradingKeyTemplates.FindById(template.Id));
Assert.Equal("Standard 1-6", db.TrashedItems.FindAll().Single().Summary);
repo.Restore(db.TrashedItems.FindAll().Single().Id);
Assert.NotNull(db.GradingKeyTemplates.FindById(template.Id));
}
[Fact]
public void TrashRepository_GetAllSortiertNeuesteZuerst()
{
using var db = NewInMemoryContext();
db.TrashedItems.Insert(new TrashedItem
{
EntityType = nameof(WorkTask), Summary = "Älter", DeletedAt = DateTime.UtcNow.AddDays(-2),
});
db.TrashedItems.Insert(new TrashedItem
{
EntityType = nameof(WorkTask), Summary = "Neuer", DeletedAt = DateTime.UtcNow,
});
var all = new TrashRepository(db).GetAll();
Assert.Equal(2, all.Count);
Assert.Equal("Neuer", all[0].Summary);
Assert.Equal("Älter", all[1].Summary);
}
[Fact]
public void TrashRepository_PurgeOlderThanEntferntNurAeltereEintraege()
{
using var db = NewInMemoryContext();
db.TrashedItems.Insert(new TrashedItem
{
EntityType = nameof(WorkTask), Summary = "Alt", DeletedAt = DateTime.UtcNow.AddDays(-40),
});
db.TrashedItems.Insert(new TrashedItem
{
EntityType = nameof(WorkTask), Summary = "Frisch", DeletedAt = DateTime.UtcNow,
});
new TrashRepository(db).PurgeOlderThan(DateTime.UtcNow.AddDays(-30));
var remaining = db.TrashedItems.FindAll().ToList();
Assert.Single(remaining);
Assert.Equal("Frisch", remaining[0].Summary);
}
}
+5
View File
@@ -0,0 +1,5 @@
using System.Runtime.CompilerServices;
// EventApplier (Baustein 5) muss dieselben internen Kaskaden-Hilfsmethoden auf LiteDbContext
// wiederverwenden können wie die Repositories selbst (siehe LiteDbContext.CascadeDelete*).
[assembly: InternalsVisibleTo("LehrerApp.Sync")]
@@ -0,0 +1,91 @@
using LiteDB;
namespace LehrerApp.Data;
/// <summary>
/// Prüft und ändert den Passwortschutz einer LiteDB-Datei.
/// Passwort-Änderungen laufen über eine Kopie (neue Datei mit Zielpasswort, alle
/// Collections umkopiert, dann Austausch) statt über <c>LiteDatabase.Rebuild</c> mit
/// Passwort, das in LiteDB 5.0.21 nachweislich fehlschlägt (per Skript verifiziert).
/// </summary>
public class DatabaseEncryptionService
{
public bool IsEncrypted(string dbPath)
{
if (!File.Exists(dbPath)) return false;
try
{
using var db = new LiteDatabase(dbPath);
return false;
}
catch (LiteException)
{
return true;
}
}
public bool VerifyPassword(string dbPath, string password)
{
try
{
using var db = new LiteDatabase(new ConnectionString(dbPath) { Password = password });
return true;
}
catch (LiteException)
{
return false;
}
}
/// Öffnet die Datei probeweise und liest die Sammlungsliste, um eine strukturell gültige
/// (nicht nur vorhandene) LiteDB-Datei zu bestätigen — z.B. direkt nach dem Anlegen eines
/// Backups, das durch einen vollen Datenträger oder einen vorzeitig entfernten USB-Stick
/// beschädigt worden sein könnte. Bewusst breites Catch: jede Art von Fehlschlag beim Öffnen
/// oder Lesen bedeutet hier "Backup nicht verifizierbar", nicht nur ein falsches Passwort.
public bool CanOpenAndRead(string dbPath, string? password)
{
if (!File.Exists(dbPath)) return false;
try
{
var connection = password is null
? new ConnectionString(dbPath)
: new ConnectionString(dbPath) { Password = password };
using var db = new LiteDatabase(connection);
_ = db.GetCollectionNames().ToList();
return true;
}
catch
{
return false;
}
}
/// Setzt (newPassword != null), ändert oder entfernt (newPassword == null) das
/// Passwort der Datenbank. Der Aufrufer muss sicherstellen, dass keine andere
/// Verbindung (z.B. der laufende <see cref="LiteDbContext"/>) die Datei offen hält.
public void SetPassword(string dbPath, string? currentPassword, string? newPassword)
{
var tempPath = dbPath + ".reencrypt.tmp";
if (File.Exists(tempPath)) File.Delete(tempPath);
var srcConnection = currentPassword is null
? new ConnectionString(dbPath)
: new ConnectionString(dbPath) { Password = currentPassword };
var dstConnection = newPassword is null
? new ConnectionString(tempPath)
: new ConnectionString(tempPath) { Password = newPassword };
using (var src = new LiteDatabase(srcConnection))
using (var dst = new LiteDatabase(dstConnection))
{
foreach (var name in src.GetCollectionNames())
{
var docs = src.GetCollection<BsonDocument>(name).FindAll().ToList();
if (docs.Count > 0) dst.GetCollection<BsonDocument>(name).InsertBulk(docs);
}
}
File.Copy(tempPath, dbPath, overwrite: true);
File.Delete(tempPath);
}
}
+22
View File
@@ -0,0 +1,22 @@
using LehrerApp.Core.Interfaces;
namespace LehrerApp.Data;
public class LiteAttachmentStorage(LiteDbContext db) : IAttachmentStorage
{
public string Upload(string fileName, Stream content)
{
if (content.Length > IAttachmentStorage.MaxSizeBytes)
throw new InvalidOperationException(
$"Datei ist größer als {IAttachmentStorage.MaxSizeBytes / 1024 / 1024} MB.");
var id = Guid.NewGuid().ToString("N");
db.Attachments.Upload(id, fileName, content);
return id;
}
public Stream? OpenRead(string storageId) =>
db.Attachments.Exists(storageId) ? db.Attachments.OpenRead(storageId) : null;
public void Delete(string storageId) => db.Attachments.Delete(storageId);
}
+369 -2
View File
@@ -3,25 +3,44 @@ using LehrerApp.Core.Models;
namespace LehrerApp.Data; namespace LehrerApp.Data;
/// Wird nach jedem Save/Delete einer Entität aufgerufen; payload ist die gespeicherte Entität
/// bzw. null bei Delete. Sync-agnostisch siehe <see cref="LiteDbContext.OnChange"/>.
public delegate void ChangeHandler(string entityType, string entityId, string operation, object? payload);
/// <summary> /// <summary>
/// Zentrale LiteDB-Verbindung. Singleton eine Datei = ein Nutzer. /// Zentrale LiteDB-Verbindung. Singleton eine Datei = ein Nutzer.
/// </summary> /// </summary>
public class LiteDbContext : IDisposable public class LiteDbContext : IDisposable
{ {
/// Aktuelle Schema-Version. Migrationsschritte werden versioniert unter
/// <see cref="RunVersionedMigrations"/> ergänzt, statt bei jedem Start erneut
/// (idempotent, aber unnötig) über alle Daten zu laufen.
private const int CurrentSchemaVersion = 4;
private readonly LiteDatabase _db; private readonly LiteDatabase _db;
public LiteDbContext(string databasePath) public LiteDbContext(string databasePath, string? password = null)
{ {
_db = new LiteDatabase(new ConnectionString(databasePath) _db = new LiteDatabase(new ConnectionString(databasePath)
{ {
Connection = ConnectionType.Shared, Connection = ConnectionType.Shared,
Password = password,
}); });
MigrateExistingData(); RunVersionedMigrations();
EnsureIndexes();
}
/// Für Tests: In-Memory-Datenbank ohne Datei auf der Festplatte.
public LiteDbContext(Stream stream)
{
_db = new LiteDatabase(stream);
RunVersionedMigrations();
EnsureIndexes(); EnsureIndexes();
} }
public ILiteCollection<Student> Students => _db.GetCollection<Student>("students"); public ILiteCollection<Student> Students => _db.GetCollection<Student>("students");
public ILiteCollection<LearningGroup> Groups => _db.GetCollection<LearningGroup>("groups"); public ILiteCollection<LearningGroup> Groups => _db.GetCollection<LearningGroup>("groups");
public ILiteCollection<SeatingPlan> SeatingPlans => _db.GetCollection<SeatingPlan>("seating_plans");
public ILiteCollection<GroupMembership> Memberships => _db.GetCollection<GroupMembership>("group_memberships"); public ILiteCollection<GroupMembership> Memberships => _db.GetCollection<GroupMembership>("group_memberships");
public ILiteCollection<Exam> Exams => _db.GetCollection<Exam>("exams"); public ILiteCollection<Exam> Exams => _db.GetCollection<Exam>("exams");
public ILiteCollection<ExamResult> ExamResults => _db.GetCollection<ExamResult>("exam_results"); public ILiteCollection<ExamResult> ExamResults => _db.GetCollection<ExamResult>("exam_results");
@@ -32,6 +51,8 @@ public class LiteDbContext : IDisposable
public ILiteCollection<Unit> Units => _db.GetCollection<Unit>("units"); public ILiteCollection<Unit> Units => _db.GetCollection<Unit>("units");
public ILiteCollection<Lesson> Lessons => _db.GetCollection<Lesson>("lessons"); public ILiteCollection<Lesson> Lessons => _db.GetCollection<Lesson>("lessons");
public ILiteCollection<Documentation> Documentation => _db.GetCollection<Documentation>("documentation"); public ILiteCollection<Documentation> Documentation => _db.GetCollection<Documentation>("documentation");
public ILiteCollection<Vorgang> Vorgaenge => _db.GetCollection<Vorgang>("vorgaenge");
public ILiteStorage<string> Attachments => _db.GetStorage<string>("attachments", "attachments_chunks");
public ILiteCollection<WorkTask> Tasks => _db.GetCollection<WorkTask>("tasks"); public ILiteCollection<WorkTask> Tasks => _db.GetCollection<WorkTask>("tasks");
public ILiteCollection<TimeEntry> TimeEntries => _db.GetCollection<TimeEntry>("time_entries"); public ILiteCollection<TimeEntry> TimeEntries => _db.GetCollection<TimeEntry>("time_entries");
public ILiteCollection<ParticipationSession> ParticipationSessions => _db.GetCollection<ParticipationSession>("participation_sessions"); public ILiteCollection<ParticipationSession> ParticipationSessions => _db.GetCollection<ParticipationSession>("participation_sessions");
@@ -40,9 +61,223 @@ public class LiteDbContext : IDisposable
public ILiteCollection<ParticipationSection> ParticipationSections => _db.GetCollection<ParticipationSection>("participation_sections"); public ILiteCollection<ParticipationSection> ParticipationSections => _db.GetCollection<ParticipationSection>("participation_sections");
public ILiteCollection<Subject> Subjects => _db.GetCollection<Subject>("subjects"); public ILiteCollection<Subject> Subjects => _db.GetCollection<Subject>("subjects");
public ILiteCollection<CompetencyDomain> CompetencyDomains => _db.GetCollection<CompetencyDomain>("competency_domains"); public ILiteCollection<CompetencyDomain> CompetencyDomains => _db.GetCollection<CompetencyDomain>("competency_domains");
public ILiteCollection<ShorthandCode> ShorthandCodes => _db.GetCollection<ShorthandCode>("shorthand_codes");
public ILiteCollection<AlternativeLessonPath> AlternativeLessonPaths => _db.GetCollection<AlternativeLessonPath>("alternative_lesson_paths");
public ILiteCollection<TimetableSlot> TimetableSlots => _db.GetCollection<TimetableSlot>("timetable_slots");
public ILiteCollection<SchoolHoliday> SchoolHolidays => _db.GetCollection<SchoolHoliday>("school_holidays");
public ILiteCollection<SupervisionDuty> SupervisionDuties => _db.GetCollection<SupervisionDuty>("supervision_duties");
public ILiteCollection<SubstitutionEntry> SubstitutionEntries => _db.GetCollection<SubstitutionEntry>("substitution_entries");
public ILiteCollection<UntisSnapshotEntry> UntisSnapshotEntries => _db.GetCollection<UntisSnapshotEntry>("untis_snapshot_entries");
public ILiteCollection<UntisSlotMapping> UntisSlotMappings => _db.GetCollection<UntisSlotMapping>("untis_slot_mappings");
public ILiteCollection<UntisAbsenceCacheEntry> UntisAbsenceCache => _db.GetCollection<UntisAbsenceCacheEntry>("untis_absence_cache");
public ILiteCollection<UntisClassRegisterCacheEntry> UntisClassRegisterCache => _db.GetCollection<UntisClassRegisterCacheEntry>("untis_classregister_cache");
public ILiteCollection<UntisCacheFetchState> UntisCacheFetchStates => _db.GetCollection<UntisCacheFetchState>("untis_cache_fetch_state");
public ILiteCollection<UntisHubJobState> UntisHubJobStates => _db.GetCollection<UntisHubJobState>("untis_hub_job_states");
public ILiteCollection<UntisStudentRosterCacheEntry> UntisStudentRosterCache => _db.GetCollection<UntisStudentRosterCacheEntry>("untis_student_roster_cache");
public ILiteCollection<AnnualPlanEvent> AnnualPlanEvents => _db.GetCollection<AnnualPlanEvent>("annual_plan_events");
public ILiteCollection<TrashedItem> TrashedItems => _db.GetCollection<TrashedItem>("trash");
public void Checkpoint() => _db.Checkpoint(); public void Checkpoint() => _db.Checkpoint();
public int SchemaVersion => ReadSchemaVersion();
/// Wird von den Repositories nach jedem Save/Delete aufgerufen (payload = die gespeicherte
/// Entität bzw. null bei Delete). Bewusst hier statt in LehrerApp.Sync definiert, damit Data
/// weiterhin ohne Verweis auf Sync auskommt — die eigentliche Sync-Anbindung setzt diesen
/// Hook von außen (siehe AppBootstrapper).
public ChangeHandler? OnChange { get; set; }
/// Führt dieselbe Kaskade wie <c>GroupRepository.Delete</c> aus. Hier auf dem Context statt
/// im Repository, damit ein später eingehendes Sync-Ereignis (Baustein 5) dieselbe Kaskade
/// nachvollziehen kann, ohne über die Repository-Save/Delete-Methoden (und damit erneut über
/// <see cref="OnChange"/>) zu laufen.
internal void CascadeDeleteGroup(Guid id)
{
ExecuteInTransaction(() =>
{
foreach (var membership in Memberships.Find(e => e.GroupId == id).ToList())
Memberships.Delete(membership.Id);
foreach (var exam in Exams.Find(e => e.GroupId == id).ToList())
{
foreach (var result in ExamResults.Find(r => r.ExamId == exam.Id).ToList())
ExamResults.Delete(result.Id);
Exams.Delete(exam.Id);
}
foreach (var grade in Grades.Find(g => g.GroupId == id).ToList())
Grades.Delete(grade.Id);
foreach (var reportGrade in ReportGrades.Find(g => g.GroupId == id).ToList())
ReportGrades.Delete(reportGrade.Id);
foreach (var scheme in GradingSchemes.Find(s => s.GroupId == id).ToList())
GradingSchemes.Delete(scheme.Id);
foreach (var unit in Units.Find(u => u.GroupId == id).ToList())
{
foreach (var lesson in Lessons.Find(l => l.UnitId == unit.Id).ToList())
Lessons.Delete(lesson.Id);
Units.Delete(unit.Id);
}
foreach (var lesson in Lessons.Find(l => l.GroupId == id).ToList())
Lessons.Delete(lesson.Id);
foreach (var session in ParticipationSessions.Find(s => s.GroupId == id).ToList())
{
foreach (var entry in ParticipationEntries.Find(e => e.SessionId == session.Id).ToList())
ParticipationEntries.Delete(entry.Id);
ParticipationSessions.Delete(session.Id);
}
foreach (var aspect in ParticipationAspects.Find(a => a.GroupId == id).ToList())
ParticipationAspects.Delete(aspect.Id);
foreach (var section in ParticipationSections.Find(s => s.GroupId == id).ToList())
ParticipationSections.Delete(section.Id);
foreach (var plan in SeatingPlans.Find(p => p.GroupId == id).ToList())
SeatingPlans.Delete(plan.Id);
// Dokumentation und Arbeitszeit sind historische Nachweise. Sie bleiben erhalten,
// werden aber von der nicht mehr existierenden Lerngruppe entkoppelt.
foreach (var documentation in Documentation.Find(d => d.GroupId == id).ToList())
{
documentation.GroupId = null;
documentation.UpdatedAt = DateTime.UtcNow;
Documentation.Update(documentation);
}
foreach (var task in Tasks.Find(t => t.GroupId == id).ToList())
{
task.GroupId = null;
task.UpdatedAt = DateTime.UtcNow;
Tasks.Update(task);
}
foreach (var timeEntry in TimeEntries.Find(t => t.GroupId == id).ToList())
{
timeEntry.GroupId = null;
TimeEntries.Update(timeEntry);
}
Groups.Delete(id);
});
}
/// Führt dieselbe Kaskade wie <c>ExamRepository.Delete</c> aus (siehe <see cref="CascadeDeleteGroup"/>).
internal void CascadeDeleteExam(Guid id)
{
foreach (var result in ExamResults.Find(r => r.ExamId == id).ToList())
ExamResults.Delete(result.Id);
Exams.Delete(id);
}
/// Führt dieselbe Kaskade wie <c>ParticipationSessionRepository.Delete</c> aus (siehe
/// <see cref="CascadeDeleteGroup"/>).
internal void CascadeDeleteParticipationSession(Guid id)
{
ParticipationSessions.Delete(id);
foreach (var e in ParticipationEntries.Find(e => e.SessionId == id).ToList())
ParticipationEntries.Delete(e.Id);
}
/// Führt dieselbe Kaskade wie <c>DocumentationRepository.HardDelete</c> aus (siehe
/// <see cref="CascadeDeleteGroup"/>).
internal void CascadeHardDeleteDocumentation(Guid id)
{
if (Documentation.FindById(id) is { } doc)
foreach (var attachment in doc.Attachments) Attachments.Delete(attachment.StorageId);
Documentation.Delete(id);
}
// ── Papierkorb (14.3) ────────────────────────────────────────────────────
//
// Absichtlich hier statt in einer eigenen TrashRepository-Konstruktorabhängigkeit: jede
// Fach-Repository hat db (LiteDbContext) bereits im Konstruktor, ein zusätzlicher Papierkorb-
// Parameter wäre nur Boilerplate. Snapshot/Restore sind generisch über den Modelltyp, die
// eigentliche Wiederherstellung ruft aber bewusst die jeweilige Repository.Save-Methode auf
// (nicht direkt die LiteDB-Collection) — nur die kennt Validierung und OnChange-Ereignis für
// ihr Modell.
/// <summary>Legt einen Papierkorb-Eintrag an, BEVOR die aufrufende Repository die Entität aus
/// ihrer eigentlichen Collection löscht.</summary>
internal void MoveToTrash<T>(string entityType, Guid entityId, T entity, string summary) =>
TrashedItems.Insert(new TrashedItem
{
EntityType = entityType,
EntityId = entityId,
Snapshot = System.Text.Json.JsonSerializer.Serialize(entity),
Summary = summary,
});
/// <summary>Liefert die gelöschte Entität aus dem Papierkorb-Eintrag zurück und entfernt den
/// Eintrag - unabhängig davon, ob die aufrufende Repository den Schnappschuss danach
/// erfolgreich speichert (ein fehlgeschlagenes Restore soll nicht endlos wiederholbar sein,
/// derselbe Datensatz landet notfalls einfach nicht wieder im Papierkorb).</summary>
internal T? RestoreFromTrash<T>(Guid trashId)
{
var item = TrashedItems.FindById(trashId);
if (item is null) return default;
TrashedItems.Delete(trashId);
return System.Text.Json.JsonSerializer.Deserialize<T>(item.Snapshot);
}
internal void ExecuteInTransaction(Action action)
{
_db.BeginTrans();
try
{
action();
_db.Commit();
}
catch
{
_db.Rollback();
throw;
}
}
private void RunVersionedMigrations()
{
var version = ReadSchemaVersion();
if (version < 1)
{
MigrateExistingData();
version = 1;
}
if (version < 2)
{
MigrateLessonPhases();
version = 2;
}
if (version < 3)
{
MigrateLessonShorthand();
version = 3;
}
if (version < 4)
{
MigrateLessonAlternativePaths();
version = 4;
}
WriteSchemaVersion(version);
}
private int ReadSchemaVersion()
{
var meta = _db.GetCollection<BsonDocument>("meta").FindById(1);
return meta?["SchemaVersion"].AsInt32 ?? 0;
}
private void WriteSchemaVersion(int version) =>
_db.GetCollection<BsonDocument>("meta").Upsert(new BsonDocument
{
["_id"] = 1,
["SchemaVersion"] = version,
});
private void MigrateExistingData() private void MigrateExistingData()
{ {
MigrateMemberships(); MigrateMemberships();
@@ -118,6 +353,119 @@ public class LiteDbContext : IDisposable
} }
} }
/// Fasst die frühere flache Struktur einer Stunde (einzelnes Phase-Textfeld, Methoden-/
/// Materialien-Listen) verlustfrei in eine einzige <see cref="LessonPhaseStep"/>-Zeile der
/// neuen Verlaufsplan-Tabelle zusammen. Nur die alten Felder werden gelesen — die typisierte
/// <see cref="Lesson"/>-Klasse kennt sie nicht mehr, ein Zugriff über die typisierte
/// Lessons-Collection würde sie beim Deserialisieren bereits verwerfen.
private void MigrateLessonPhases()
{
var lessons = _db.GetCollection<BsonDocument>("lessons");
foreach (var lesson in lessons.FindAll().ToList())
{
if (lesson.ContainsKey(nameof(Lesson.Phases))) continue;
var phase = lesson.TryGetValue("Phase", out var p) && p.IsString ? p.AsString.Trim() : "";
var methods = lesson.TryGetValue("Methods", out var m) && m.IsArray
? string.Join("; ", m.AsArray.Select(x => x.AsString)) : "";
var materials = lesson.TryGetValue("Materials", out var mat) && mat.IsArray
? string.Join(", ", mat.AsArray.Select(x => x.AsString)) : "";
var phases = new BsonArray();
if (phase.Length > 0 || methods.Length > 0 || materials.Length > 0)
{
phases.Add(new BsonDocument
{
[nameof(LessonPhaseStep.Id)] = Guid.NewGuid(),
[nameof(LessonPhaseStep.Name)] = phase,
[nameof(LessonPhaseStep.DurationMinutes)] = 0,
[nameof(LessonPhaseStep.Activity)] = methods,
[nameof(LessonPhaseStep.Material)] = materials,
[nameof(LessonPhaseStep.Shorthand)] = "",
});
}
lesson[nameof(Lesson.Phases)] = phases;
lesson.Remove("Phase");
lesson.Remove("Methods");
lesson.Remove("Materials");
lessons.Update(lesson);
}
}
/// Führt die anfangs erzwungene Von/Nach-Struktur des Kurzsymbols (<c>ShorthandFrom</c>/
/// <c>ShorthandTo</c>) in ein einzelnes Freitextfeld zusammen — der Praxis nach ist ein
/// Kurzsymbol nicht immer ein Materialfluss-Pfeil, manchmal nur eine Sozialform ("Plenum").
/// Beide Felder gesetzt ergeben "Von->Nach", nur eines gesetzt bleibt als Einzelwert erhalten.
private void MigrateLessonShorthand()
{
var lessons = _db.GetCollection<BsonDocument>("lessons");
foreach (var lesson in lessons.FindAll().ToList())
{
if (!lesson.TryGetValue(nameof(Lesson.Phases), out var phasesValue) || !phasesValue.IsArray) continue;
var changed = false;
foreach (var phaseValue in phasesValue.AsArray)
{
if (phaseValue is not BsonDocument phase) continue;
if (phase.ContainsKey(nameof(LessonPhaseStep.Shorthand))) continue;
var from = phase.TryGetValue("ShorthandFrom", out var f) && f.IsString ? f.AsString : "";
var to = phase.TryGetValue("ShorthandTo", out var t) && t.IsString ? t.AsString : "";
phase[nameof(LessonPhaseStep.Shorthand)] = (from.Length > 0, to.Length > 0) switch
{
(true, true) => $"{from}->{to}",
(true, false) => from,
(false, true) => to,
_ => "",
};
phase.Remove("ShorthandFrom");
phase.Remove("ShorthandTo");
changed = true;
}
if (changed) lessons.Update(lesson);
}
}
/// Führt das anfangs freie Textfeld für den alternativen Ablauf einer Phase
/// (<c>AlternativePath</c>, String) in einen Verweis auf einen Katalogeintrag
/// (<see cref="LessonPhaseStep.AlternativePathId"/>) über — pro bisher verwendetem, distinktem
/// Namen wird ein <see cref="AlternativeLessonPath"/> angelegt (bzw. ein gleichnamiger
/// wiederverwendet) und referenziert. Die neue Collection ist von dieser Migration selbst nicht
/// betroffen (keine Altstruktur), daher direkter Zugriff über die typisierte Collection.
private void MigrateLessonAlternativePaths()
{
var lessons = _db.GetCollection<BsonDocument>("lessons");
var nameToId = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
foreach (var existing in AlternativeLessonPaths.FindAll()) nameToId[existing.Name] = existing.Id;
foreach (var lesson in lessons.FindAll().ToList())
{
if (!lesson.TryGetValue(nameof(Lesson.Phases), out var phasesValue) || !phasesValue.IsArray) continue;
var changed = false;
foreach (var phaseValue in phasesValue.AsArray)
{
if (phaseValue is not BsonDocument phase) continue;
if (!phase.TryGetValue("AlternativePath", out var oldVal) || !oldVal.IsString) continue;
var name = oldVal.AsString.Trim();
phase.Remove("AlternativePath");
changed = true;
if (name.Length == 0) continue;
if (!nameToId.TryGetValue(name, out var id))
{
var entry = new AlternativeLessonPath { Name = name };
AlternativeLessonPaths.Insert(entry);
id = entry.Id;
nameToId[name] = id;
}
phase[nameof(LessonPhaseStep.AlternativePathId)] = id;
}
if (changed) lessons.Update(lesson);
}
}
private void RemoveRedundantLegacyFields() private void RemoveRedundantLegacyFields()
{ {
RemoveFields("exams", "Subject"); RemoveFields("exams", "Subject");
@@ -143,6 +491,7 @@ public class LiteDbContext : IDisposable
Students.EnsureIndex(x => x.IsActive); Students.EnsureIndex(x => x.IsActive);
Groups.EnsureIndex(x => x.SchoolYear); Groups.EnsureIndex(x => x.SchoolYear);
Groups.EnsureIndex(x => x.IsActive); Groups.EnsureIndex(x => x.IsActive);
SeatingPlans.EnsureIndex(x => x.GroupId);
Memberships.EnsureIndex(x => x.StudentId); Memberships.EnsureIndex(x => x.StudentId);
Memberships.EnsureIndex(x => x.GroupId); Memberships.EnsureIndex(x => x.GroupId);
Memberships.EnsureIndex("ux_student_group", Memberships.EnsureIndex("ux_student_group",
@@ -181,6 +530,24 @@ public class LiteDbContext : IDisposable
Subjects.EnsureIndex("ux_subject_name", BsonExpression.Create("LOWER(TRIM($.Name))"), unique: true); Subjects.EnsureIndex("ux_subject_name", BsonExpression.Create("LOWER(TRIM($.Name))"), unique: true);
CompetencyDomains.EnsureIndex(x => x.SubjectId); CompetencyDomains.EnsureIndex(x => x.SubjectId);
CompetencyDomains.EnsureIndex(x => x.GradeLevel); CompetencyDomains.EnsureIndex(x => x.GradeLevel);
ShorthandCodes.EnsureIndex("ux_shorthand_code", BsonExpression.Create("LOWER(TRIM($.Code))"), unique: true);
AlternativeLessonPaths.EnsureIndex("ux_alt_lesson_path_name", BsonExpression.Create("LOWER(TRIM($.Name))"), unique: true);
TimetableSlots.EnsureIndex(x => x.GroupId);
TimetableSlots.EnsureIndex("ux_timetable_weekday_period",
BsonExpression.Create("STRING($.Weekday) + ':' + STRING($.PeriodNumber)"), unique: true);
SupervisionDuties.EnsureIndex("ux_supervision_weekday_period",
BsonExpression.Create("STRING($.Weekday) + ':' + STRING($.AfterPeriod)"), unique: true);
SubstitutionEntries.EnsureIndex(x => x.Date);
AnnualPlanEvents.EnsureIndex(x => x.ExternalId, unique: true);
AnnualPlanEvents.EnsureIndex(x => x.StartDate);
AnnualPlanEvents.EnsureIndex(x => x.EndDate);
UntisAbsenceCache.EnsureIndex(x => x.ClassName);
UntisAbsenceCache.EnsureIndex(x => x.Date);
UntisClassRegisterCache.EnsureIndex(x => x.ClassName);
UntisClassRegisterCache.EnsureIndex(x => x.Date);
UntisStudentRosterCache.EnsureIndex(x => x.ClassName);
UntisCacheFetchStates.EnsureIndex("ux_class_kind",
BsonExpression.Create("STRING($.ClassName) + ':' + STRING($.Kind)"), unique: true);
} }
public void Dispose() => _db.Dispose(); public void Dispose() => _db.Dispose();
+734 -66
View File
@@ -3,6 +3,18 @@ using LehrerApp.Core.Models;
namespace LehrerApp.Data.Repositories; namespace LehrerApp.Data.Repositories;
internal static class ArchivedGroupWriteGuard
{
public const string Message =
"Diese Lerngruppe ist archiviert. Bitte reaktiviere sie, bevor du Änderungen vornimmst.";
public static void EnsureActive(LiteDbContext db, Guid groupId)
{
if (db.Groups.FindById(groupId) is { IsActive: false })
throw new InvalidOperationException(Message);
}
}
public class StudentRepository(LiteDbContext db) : IStudentRepository public class StudentRepository(LiteDbContext db) : IStudentRepository
{ {
public Student? GetById(Guid id) => db.Students.FindById(id); public Student? GetById(Guid id) => db.Students.FindById(id);
@@ -16,8 +28,30 @@ public class StudentRepository(LiteDbContext db) : IStudentRepository
.Select(e => e.StudentId).ToHashSet(); .Select(e => e.StudentId).ToHashSet();
return db.Students.Find(s => ids.Contains(s.Id)).OrderBy(s => s.LastName).ToList(); return db.Students.Find(s => ids.Contains(s.Id)).OrderBy(s => s.LastName).ToList();
} }
public void Save(Student s) { s.UpdatedAt = DateTime.UtcNow; db.Students.Upsert(s); } public StudentReferenceSummary GetReferenceSummary(Guid studentId) => new(
public void Delete(Guid id) => db.Students.Delete(id); db.Memberships.Count(m => m.StudentId == studentId),
db.ExamResults.Count(r => r.StudentId == studentId),
db.Grades.Count(g => g.StudentId == studentId),
db.ReportGrades.Count(g => g.StudentId == studentId),
db.ParticipationEntries.Count(e => e.StudentId == studentId),
db.Documentation.Count(d => d.StudentId == studentId));
public void Save(Student s)
{
s.ExternalIds ??= [];
s.Contacts ??= [];
s.UpdatedAt = DateTime.UtcNow;
db.Students.Upsert(s);
db.OnChange?.Invoke(nameof(Student), s.Id.ToString(), "Save", s);
}
public void Delete(Guid id)
{
var references = GetReferenceSummary(id);
if (references.HasReferences)
throw new InvalidOperationException(
"Der Schüler besitzt verknüpfte Daten und kann nur deaktiviert werden.");
db.Students.Delete(id);
db.OnChange?.Invoke(nameof(Student), id.ToString(), "Delete", null);
}
} }
public class GroupRepository(LiteDbContext db) : IGroupRepository public class GroupRepository(LiteDbContext db) : IGroupRepository
@@ -33,47 +67,111 @@ public class GroupRepository(LiteDbContext db) : IGroupRepository
.OrderBy(g => g.Name).ToList(); .OrderBy(g => g.Name).ToList();
public void Save(LearningGroup g) public void Save(LearningGroup g)
{ {
var existing = db.Groups.FindById(g.Id);
if (existing is { IsActive: false } && !g.IsActive)
throw new InvalidOperationException(ArchivedGroupWriteGuard.Message);
if (existing is { IsActive: false } && g.IsActive
&& (existing.Name != g.Name || existing.Type != g.Type || existing.SubjectId != g.SubjectId
|| existing.SchoolYear != g.SchoolYear || existing.GradeLevel != g.GradeLevel
|| existing.GradingSystem != g.GradingSystem || existing.HoursPerWeek != g.HoursPerWeek
|| existing.IsOwnClass != g.IsOwnClass || existing.IsDifferentiated != g.IsDifferentiated))
throw new InvalidOperationException(
"Bitte reaktiviere die Lerngruppe zuerst und nimm die Änderungen anschließend vor.");
if (g.SubjectId is Guid subjectId && db.Subjects.FindById(subjectId) is null) if (g.SubjectId is Guid subjectId && db.Subjects.FindById(subjectId) is null)
throw new InvalidOperationException("Das gewählte Fach existiert nicht mehr."); throw new InvalidOperationException("Das gewählte Fach existiert nicht mehr.");
g.UpdatedAt = DateTime.UtcNow; g.UpdatedAt = DateTime.UtcNow;
db.Groups.Upsert(g); db.Groups.Upsert(g);
db.OnChange?.Invoke(nameof(LearningGroup), g.Id.ToString(), "Save", g);
} }
public void Delete(Guid id) public void Delete(Guid id)
{ {
foreach (var membership in db.Memberships.Find(e => e.GroupId == id).ToList()) ArchivedGroupWriteGuard.EnsureActive(db, id);
db.Memberships.Delete(membership.Id); db.CascadeDeleteGroup(id);
db.OnChange?.Invoke(nameof(LearningGroup), id.ToString(), "Delete", null);
foreach (var exam in db.Exams.Find(e => e.GroupId == id).ToList()) }
{
foreach (var result in db.ExamResults.Find(r => r.ExamId == exam.Id).ToList())
db.ExamResults.Delete(result.Id);
db.Exams.Delete(exam.Id);
} }
foreach (var grade in db.Grades.Find(g => g.GroupId == id).ToList()) public class SeatingPlanRepository(LiteDbContext db) : ISeatingPlanRepository
db.Grades.Delete(grade.Id);
foreach (var unit in db.Units.Find(u => u.GroupId == id).ToList())
{ {
foreach (var lesson in db.Lessons.Find(l => l.UnitId == unit.Id).ToList()) public SeatingPlan? GetById(Guid id) => db.SeatingPlans.FindById(id);
db.Lessons.Delete(lesson.Id);
db.Units.Delete(unit.Id); public List<SeatingPlan> GetByGroup(Guid groupId) =>
db.SeatingPlans.Find(p => p.GroupId == groupId)
.OrderBy(p => p.Name).ThenBy(p => p.Room).ToList();
public void Save(SeatingPlan plan)
{
ArchivedGroupWriteGuard.EnsureActive(db, plan.GroupId);
// LiteDB can deserialize missing/legacy optional string fields as null even
// though the current model initializes them with an empty string.
plan.Name = plan.Name?.Trim() ?? "";
plan.Room = plan.Room?.Trim() ?? "";
plan.Assignments ??= [];
plan.ColumnGapWidths ??= [];
plan.HiddenSeats ??= [];
if (plan.Name.Length == 0)
throw new ArgumentException("Der Name des Sitzplans darf nicht leer sein.");
if (plan.Rows is < 1 or > 10 || plan.Columns is < 1 or > 10)
throw new ArgumentOutOfRangeException(nameof(plan), "Ein Sitzplan muss zwischen 1 und 10 Reihen und Spalten haben.");
if (db.Groups.FindById(plan.GroupId) is null)
throw new InvalidOperationException("Die zugehörige Lerngruppe existiert nicht.");
plan.ColumnGapWidths = Enumerable.Range(0, Math.Max(0, plan.Columns - 1))
.Select(i => i < plan.ColumnGapWidths.Count && double.IsFinite(plan.ColumnGapWidths[i])
? Math.Clamp(plan.ColumnGapWidths[i], 0, 300)
: 0)
.ToList();
var duplicateName = db.SeatingPlans.Find(p => p.GroupId == plan.GroupId)
.FirstOrDefault(p => p.Id != plan.Id
&& string.Equals(p.Name, plan.Name, StringComparison.OrdinalIgnoreCase)
&& string.Equals(p.Room, plan.Room, StringComparison.OrdinalIgnoreCase));
if (duplicateName is not null)
throw new InvalidOperationException("Für diese Lerngruppe existiert bereits ein gleichnamiger Sitzplan in diesem Raum.");
plan.Assignments = plan.Assignments
.Where(a => a.Row >= 0 && a.Row < plan.Rows && a.Column >= 0 && a.Column < plan.Columns)
.ToList();
if (plan.Assignments.GroupBy(a => (a.Row, a.Column)).Any(g => g.Count() > 1))
throw new InvalidOperationException("Ein Sitzplatz darf nur einmal belegt werden.");
if (plan.Assignments.GroupBy(a => a.StudentId).Any(g => g.Count() > 1))
throw new InvalidOperationException("Ein Schüler darf in einem Sitzplan nur einmal vorkommen.");
var memberIds = db.Memberships.Find(m => m.GroupId == plan.GroupId)
.Select(m => m.StudentId).ToHashSet();
if (plan.Assignments.Any(a => !memberIds.Contains(a.StudentId)))
throw new InvalidOperationException("Der Sitzplan enthält einen Schüler, der nicht zur Lerngruppe gehört.");
// Ein ausgeblendeter Platz darf nie belegt sein - die Zuordnung eines Schülers ist
// wertvoller als das Ausblenden, deshalb wird hier stillschweigend wieder eingeblendet
// statt die Zuordnung zu verwerfen. Sollte über die UI (SeatingPlanTabViewModel verhindert
// das Belegen ausgeblendeter Plätze) nie vorkommen - reine Absicherung bei fehlerhaften
// oder von einem älteren Client synchronisierten Daten.
var assignedPositions = plan.Assignments.Select(a => (a.Row, a.Column)).ToHashSet();
plan.HiddenSeats = plan.HiddenSeats
.Where(h => h.Row >= 0 && h.Row < plan.Rows && h.Column >= 0 && h.Column < plan.Columns)
.Where(h => !assignedPositions.Contains((h.Row, h.Column)))
.DistinctBy(h => (h.Row, h.Column))
.ToList();
plan.UpdatedAt = DateTime.UtcNow;
db.SeatingPlans.Upsert(plan);
db.OnChange?.Invoke(nameof(SeatingPlan), plan.Id.ToString(), "Save", plan);
} }
foreach (var lesson in db.Lessons.Find(l => l.GroupId == id).ToList()) public void Delete(Guid id)
db.Lessons.Delete(lesson.Id);
foreach (var session in db.ParticipationSessions.Find(s => s.GroupId == id).ToList())
{ {
foreach (var entry in db.ParticipationEntries.Find(e => e.SessionId == session.Id).ToList()) if (db.SeatingPlans.FindById(id) is not { } plan) return;
db.ParticipationEntries.Delete(entry.Id); ArchivedGroupWriteGuard.EnsureActive(db, plan.GroupId);
db.ParticipationSessions.Delete(session.Id); db.MoveToTrash(nameof(SeatingPlan), id, plan,
string.IsNullOrWhiteSpace(plan.Room) ? plan.Name : $"{plan.Name} (Raum {plan.Room})");
db.SeatingPlans.Delete(id);
db.OnChange?.Invoke(nameof(SeatingPlan), id.ToString(), "Delete", null);
} }
foreach (var aspect in db.ParticipationAspects.Find(a => a.GroupId == id).ToList()) public void Restore(Guid trashId)
db.ParticipationAspects.Delete(aspect.Id); {
if (db.RestoreFromTrash<SeatingPlan>(trashId) is { } plan) Save(plan);
db.Groups.Delete(id);
} }
} }
@@ -87,25 +185,41 @@ public class GroupMembershipRepository(LiteDbContext db) : IGroupMembershipRepos
db.Memberships.FindOne(e => e.StudentId == studentId && e.GroupId == groupId); db.Memberships.FindOne(e => e.StudentId == studentId && e.GroupId == groupId);
public void Save(GroupMembership membership) public void Save(GroupMembership membership)
{ {
ArchivedGroupWriteGuard.EnsureActive(db, membership.GroupId);
var existing = GetByStudentAndGroup(membership.StudentId, membership.GroupId); var existing = GetByStudentAndGroup(membership.StudentId, membership.GroupId);
if (existing is not null && existing.Id != membership.Id) if (existing is not null && existing.Id != membership.Id)
throw new InvalidOperationException("Der Schüler ist dieser Lerngruppe bereits zugeordnet."); throw new InvalidOperationException("Der Schüler ist dieser Lerngruppe bereits zugeordnet.");
db.Memberships.Upsert(membership); db.Memberships.Upsert(membership);
db.OnChange?.Invoke(nameof(GroupMembership), membership.Id.ToString(), "Save", membership);
}
public void Delete(Guid id)
{
if (db.Memberships.FindById(id) is { } membership)
ArchivedGroupWriteGuard.EnsureActive(db, membership.GroupId);
db.Memberships.Delete(id);
db.OnChange?.Invoke(nameof(GroupMembership), id.ToString(), "Delete", null);
} }
public void Delete(Guid id) => db.Memberships.Delete(id);
} }
public class ExamRepository(LiteDbContext db) : IExamRepository public class ExamRepository(LiteDbContext db) : IExamRepository
{ {
public Exam? GetById(Guid id) => db.Exams.FindById(id); public Exam? GetById(Guid id) => db.Exams.FindById(id);
public List<Exam> GetAll() => db.Exams.FindAll().OrderBy(e => e.Date).ToList();
public List<Exam> GetByGroup(Guid groupId) => public List<Exam> GetByGroup(Guid groupId) =>
db.Exams.Find(e => e.GroupId == groupId).OrderByDescending(e => e.Date).ToList(); db.Exams.Find(e => e.GroupId == groupId).OrderByDescending(e => e.Date).ToList();
public void Save(Exam e) { e.UpdatedAt = DateTime.UtcNow; db.Exams.Upsert(e); } public void Save(Exam e)
{
ArchivedGroupWriteGuard.EnsureActive(db, e.GroupId);
e.UpdatedAt = DateTime.UtcNow;
db.Exams.Upsert(e);
db.OnChange?.Invoke(nameof(Exam), e.Id.ToString(), "Save", e);
}
public void Delete(Guid id) public void Delete(Guid id)
{ {
foreach (var result in db.ExamResults.Find(r => r.ExamId == id).ToList()) if (db.Exams.FindById(id) is { } exam)
db.ExamResults.Delete(result.Id); ArchivedGroupWriteGuard.EnsureActive(db, exam.GroupId);
db.Exams.Delete(id); db.CascadeDeleteExam(id);
db.OnChange?.Invoke(nameof(Exam), id.ToString(), "Delete", null);
} }
} }
@@ -117,12 +231,24 @@ public class ExamResultRepository(LiteDbContext db) : IExamResultRepository
db.ExamResults.Find(r => r.StudentId == id).ToList(); db.ExamResults.Find(r => r.StudentId == id).ToList();
public ExamResult? GetByExamAndStudent(Guid examId, Guid studentId) => public ExamResult? GetByExamAndStudent(Guid examId, Guid studentId) =>
db.ExamResults.FindOne(r => r.ExamId == examId && r.StudentId == studentId); db.ExamResults.FindOne(r => r.ExamId == examId && r.StudentId == studentId);
public void Save(ExamResult r) { r.UpdatedAt = DateTime.UtcNow; db.ExamResults.Upsert(r); } public void Save(ExamResult r)
{
if (db.Exams.FindById(r.ExamId) is { } exam)
ArchivedGroupWriteGuard.EnsureActive(db, exam.GroupId);
r.UpdatedAt = DateTime.UtcNow;
db.ExamResults.Upsert(r);
db.OnChange?.Invoke(nameof(ExamResult), r.Id.ToString(), "Save", r);
}
public void SaveMany(List<ExamResult> results) public void SaveMany(List<ExamResult> results)
{ {
foreach (var examId in results.Select(r => r.ExamId).Distinct())
if (db.Exams.FindById(examId) is { } exam)
ArchivedGroupWriteGuard.EnsureActive(db, exam.GroupId);
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
foreach (var r in results) r.UpdatedAt = now; foreach (var r in results) r.UpdatedAt = now;
db.ExamResults.Upsert(results); db.ExamResults.Upsert(results);
foreach (var r in results)
db.OnChange?.Invoke(nameof(ExamResult), r.Id.ToString(), "Save", r);
} }
} }
@@ -133,8 +259,24 @@ public class GradingKeyTemplateRepository(LiteDbContext db) : IGradingKeyTemplat
public List<GradingKeyTemplate> GetByGradingSystem(GradingSystem system) => public List<GradingKeyTemplate> GetByGradingSystem(GradingSystem system) =>
db.GradingKeyTemplates.Find(t => t.GradingSystem == system).OrderBy(t => t.Name).ToList(); db.GradingKeyTemplates.Find(t => t.GradingSystem == system).OrderBy(t => t.Name).ToList();
public GradingKeyTemplate? GetById(Guid id) => db.GradingKeyTemplates.FindById(id); public GradingKeyTemplate? GetById(Guid id) => db.GradingKeyTemplates.FindById(id);
public void Save(GradingKeyTemplate t) { t.UpdatedAt = DateTime.UtcNow; db.GradingKeyTemplates.Upsert(t); } public void Save(GradingKeyTemplate t)
public void Delete(Guid id) => db.GradingKeyTemplates.Delete(id); {
t.UpdatedAt = DateTime.UtcNow;
db.GradingKeyTemplates.Upsert(t);
db.OnChange?.Invoke(nameof(GradingKeyTemplate), t.Id.ToString(), "Save", t);
}
public void Delete(Guid id)
{
if (db.GradingKeyTemplates.FindById(id) is { } template)
db.MoveToTrash(nameof(GradingKeyTemplate), id, template, template.Name);
db.GradingKeyTemplates.Delete(id);
db.OnChange?.Invoke(nameof(GradingKeyTemplate), id.ToString(), "Delete", null);
}
public void Restore(Guid trashId)
{
if (db.RestoreFromTrash<GradingKeyTemplate>(trashId) is { } template) Save(template);
}
} }
public class GradeRepository(LiteDbContext db) : IGradeRepository public class GradeRepository(LiteDbContext db) : IGradeRepository
@@ -143,8 +285,26 @@ public class GradeRepository(LiteDbContext db) : IGradeRepository
db.Grades.Find(g => g.StudentId == sid && g.GroupId == gid).OrderBy(g => g.Date).ToList(); db.Grades.Find(g => g.StudentId == sid && g.GroupId == gid).OrderBy(g => g.Date).ToList();
public List<Grade> GetByGroup(Guid id) => public List<Grade> GetByGroup(Guid id) =>
db.Grades.Find(g => g.GroupId == id).OrderBy(g => g.Date).ToList(); db.Grades.Find(g => g.GroupId == id).OrderBy(g => g.Date).ToList();
public void Save(Grade g) => db.Grades.Upsert(g); public void Save(Grade g)
public void Delete(Guid id) => db.Grades.Delete(id); {
ArchivedGroupWriteGuard.EnsureActive(db, g.GroupId);
db.Grades.Upsert(g);
db.OnChange?.Invoke(nameof(Grade), g.Id.ToString(), "Save", g);
}
public void Delete(Guid id)
{
if (db.Grades.FindById(id) is not { } grade) return;
ArchivedGroupWriteGuard.EnsureActive(db, grade.GroupId);
db.MoveToTrash(nameof(Grade), id, grade,
$"Note {grade.Value} ({grade.Date:dd.MM.yyyy})");
db.Grades.Delete(id);
db.OnChange?.Invoke(nameof(Grade), id.ToString(), "Delete", null);
}
public void Restore(Guid trashId)
{
if (db.RestoreFromTrash<Grade>(trashId) is { } grade) Save(grade);
}
} }
public class GradingSchemeRepository(LiteDbContext db) : IGradingSchemeRepository public class GradingSchemeRepository(LiteDbContext db) : IGradingSchemeRepository
@@ -152,8 +312,20 @@ public class GradingSchemeRepository(LiteDbContext db) : IGradingSchemeRepositor
public GradingScheme? GetByGroup(Guid groupId) => db.GradingSchemes.FindOne(s => s.GroupId == groupId); public GradingScheme? GetByGroup(Guid groupId) => db.GradingSchemes.FindOne(s => s.GroupId == groupId);
public GradingScheme? GetDefaultForType(GroupType type) => public GradingScheme? GetDefaultForType(GroupType type) =>
db.GradingSchemes.FindOne(s => s.GroupId == null && s.GroupType == type); db.GradingSchemes.FindOne(s => s.GroupId == null && s.GroupType == type);
public void Save(GradingScheme s) { s.UpdatedAt = DateTime.UtcNow; db.GradingSchemes.Upsert(s); } public void Save(GradingScheme s)
public void Delete(Guid id) => db.GradingSchemes.Delete(id); {
if (s.GroupId is Guid groupId) ArchivedGroupWriteGuard.EnsureActive(db, groupId);
s.UpdatedAt = DateTime.UtcNow;
db.GradingSchemes.Upsert(s);
db.OnChange?.Invoke(nameof(GradingScheme), s.Id.ToString(), "Save", s);
}
public void Delete(Guid id)
{
if (db.GradingSchemes.FindById(id)?.GroupId is Guid groupId)
ArchivedGroupWriteGuard.EnsureActive(db, groupId);
db.GradingSchemes.Delete(id);
db.OnChange?.Invoke(nameof(GradingScheme), id.ToString(), "Delete", null);
}
} }
public class ReportGradeRepository(LiteDbContext db) : IReportGradeRepository public class ReportGradeRepository(LiteDbContext db) : IReportGradeRepository
@@ -161,8 +333,20 @@ public class ReportGradeRepository(LiteDbContext db) : IReportGradeRepository
public List<ReportGrade> GetByGroup(Guid groupId) => db.ReportGrades.Find(r => r.GroupId == groupId).ToList(); public List<ReportGrade> GetByGroup(Guid groupId) => db.ReportGrades.Find(r => r.GroupId == groupId).ToList();
public ReportGrade? GetByStudentGroupPeriod(Guid studentId, Guid groupId, string period) => public ReportGrade? GetByStudentGroupPeriod(Guid studentId, Guid groupId, string period) =>
db.ReportGrades.FindOne(r => r.StudentId == studentId && r.GroupId == groupId && r.Period == period); db.ReportGrades.FindOne(r => r.StudentId == studentId && r.GroupId == groupId && r.Period == period);
public void Save(ReportGrade r) { r.UpdatedAt = DateTime.UtcNow; db.ReportGrades.Upsert(r); } public void Save(ReportGrade r)
public void Delete(Guid id) => db.ReportGrades.Delete(id); {
ArchivedGroupWriteGuard.EnsureActive(db, r.GroupId);
r.UpdatedAt = DateTime.UtcNow;
db.ReportGrades.Upsert(r);
db.OnChange?.Invoke(nameof(ReportGrade), r.Id.ToString(), "Save", r);
}
public void Delete(Guid id)
{
if (db.ReportGrades.FindById(id) is { } grade)
ArchivedGroupWriteGuard.EnsureActive(db, grade.GroupId);
db.ReportGrades.Delete(id);
db.OnChange?.Invoke(nameof(ReportGrade), id.ToString(), "Delete", null);
}
} }
public class UnitRepository(LiteDbContext db) : IUnitRepository public class UnitRepository(LiteDbContext db) : IUnitRepository
@@ -170,32 +354,127 @@ public class UnitRepository(LiteDbContext db) : IUnitRepository
public Unit? GetById(Guid id) => db.Units.FindById(id); public Unit? GetById(Guid id) => db.Units.FindById(id);
public List<Unit> GetByGroup(Guid id) => public List<Unit> GetByGroup(Guid id) =>
db.Units.Find(u => u.GroupId == id).OrderBy(u => u.StartDate).ToList(); db.Units.Find(u => u.GroupId == id).OrderBy(u => u.StartDate).ToList();
public void Save(Unit u) { u.UpdatedAt = DateTime.UtcNow; db.Units.Upsert(u); } public void Save(Unit u)
public void Delete(Guid id) => db.Units.Delete(id); {
ArchivedGroupWriteGuard.EnsureActive(db, u.GroupId);
// Persistenz-Invariante: auch Importe und künftig hinzukommende Aufrufer dürfen keine
// zweite laufende Einheit in derselben Lerngruppe hinterlassen. Der Einheiten-Dialog
// kündigt diese automatische Ablösung vorher sichtbar an.
if (u.Status == UnitStatus.Active)
{
foreach (var previous in db.Units.Find(x => x.GroupId == u.GroupId &&
x.Status == UnitStatus.Active && x.Id != u.Id))
{
previous.Status = UnitStatus.Completed;
previous.UpdatedAt = DateTime.UtcNow;
db.Units.Upsert(previous);
db.OnChange?.Invoke(nameof(Unit), previous.Id.ToString(), "Save", previous);
}
}
u.UpdatedAt = DateTime.UtcNow;
db.Units.Upsert(u);
db.OnChange?.Invoke(nameof(Unit), u.Id.ToString(), "Save", u);
}
public void Delete(Guid id)
{
if (db.Units.FindById(id) is { } unit)
ArchivedGroupWriteGuard.EnsureActive(db, unit.GroupId);
db.Units.Delete(id);
db.OnChange?.Invoke(nameof(Unit), id.ToString(), "Delete", null);
}
} }
public class LessonRepository(LiteDbContext db) : ILessonRepository public class LessonRepository(LiteDbContext db) : ILessonRepository
{ {
public Lesson? GetById(Guid id) => db.Lessons.FindById(id);
public List<Lesson> GetByUnit(Guid id) => public List<Lesson> GetByUnit(Guid id) =>
db.Lessons.Find(l => l.UnitId == id).OrderBy(l => l.Date).ToList(); db.Lessons.Find(l => l.UnitId == id).OrderBy(l => l.Date).ThenBy(l => l.LessonNumber).ToList();
public List<Lesson> GetByGroupAndDate(Guid gid, DateOnly date) => public List<Lesson> GetByGroupAndDate(Guid gid, DateOnly date) =>
db.Lessons.Find(l => l.GroupId == gid && l.Date == date).ToList(); db.Lessons.Find(l => l.GroupId == gid && l.Date == date).ToList();
public List<Lesson> GetByGroupAndRange(Guid gid, DateOnly from, DateOnly to) => public List<Lesson> GetByGroupAndRange(Guid gid, DateOnly from, DateOnly to) =>
db.Lessons.Find(l => l.GroupId == gid && l.Date >= from && l.Date <= to) db.Lessons.Find(l => l.GroupId == gid && l.Date >= from && l.Date <= to)
.OrderBy(l => l.Date).ToList(); .OrderBy(l => l.Date).ToList();
public void Save(Lesson l) { l.UpdatedAt = DateTime.UtcNow; db.Lessons.Upsert(l); } public void Save(Lesson l)
public void Delete(Guid id) => db.Lessons.Delete(id); {
ArchivedGroupWriteGuard.EnsureActive(db, l.GroupId);
l.UpdatedAt = DateTime.UtcNow;
db.Lessons.Upsert(l);
db.OnChange?.Invoke(nameof(Lesson), l.Id.ToString(), "Save", l);
}
public void Delete(Guid id)
{
if (db.Lessons.FindById(id) is { } lesson)
{
ArchivedGroupWriteGuard.EnsureActive(db, lesson.GroupId);
foreach (var attachment in lesson.Attachments) db.Attachments.Delete(attachment.StorageId);
}
db.Lessons.Delete(id);
db.OnChange?.Invoke(nameof(Lesson), id.ToString(), "Delete", null);
}
} }
public class DocumentationRepository(LiteDbContext db) : IDocumentationRepository public class DocumentationRepository(LiteDbContext db) : IDocumentationRepository
{ {
public List<Documentation> GetByStudent(Guid id) => public List<Documentation> GetByStudent(Guid id) =>
db.Documentation.Find(d => d.StudentId == id).OrderByDescending(d => d.Date).ToList(); db.Documentation.Find(d => d.StudentId == id && !d.IsDeleted).OrderByDescending(d => d.Date).ToList();
public List<Documentation> GetByStudentAndType(Guid sid, DocumentationType type) => public List<Documentation> GetByStudentAndType(Guid sid, DocumentationType type) =>
db.Documentation.Find(d => d.StudentId == sid && d.Type == type) db.Documentation.Find(d => d.StudentId == sid && d.Type == type && !d.IsDeleted)
.OrderByDescending(d => d.Date).ToList(); .OrderByDescending(d => d.Date).ToList();
public void Save(Documentation d) { d.UpdatedAt = DateTime.UtcNow; db.Documentation.Upsert(d); } public List<Documentation> GetAll() =>
public void Delete(Guid id) => db.Documentation.Delete(id); db.Documentation.Find(d => !d.IsDeleted).OrderByDescending(d => d.Date).ToList();
public void Save(Documentation d)
{
d.UpdatedAt = DateTime.UtcNow;
db.Documentation.Upsert(d);
db.OnChange?.Invoke(nameof(Documentation), d.Id.ToString(), "Save", d);
}
public void Delete(Guid id)
{
var doc = db.Documentation.FindById(id);
if (doc is null) return;
doc.IsDeleted = true;
doc.DeletedAt = DateTime.UtcNow;
db.Documentation.Update(doc);
// Weiches Löschen ist inhaltlich eine Änderung, kein Entfernen -> "Save", damit ein
// anwendendes Gerät den IsDeleted-Stand einfach übernimmt statt den Datensatz zu entfernen.
db.OnChange?.Invoke(nameof(Documentation), id.ToString(), "Save", doc);
}
public void HardDelete(Guid id)
{
db.CascadeHardDeleteDocumentation(id);
db.OnChange?.Invoke(nameof(Documentation), id.ToString(), "Delete", null);
}
}
public class VorgangRepository(LiteDbContext db) : IVorgangRepository
{
public List<Vorgang> GetAll() =>
db.Vorgaenge.Find(v => !v.IsDeleted).OrderByDescending(v => v.UpdatedAt).ToList();
public List<Vorgang> GetByStudent(Guid studentId) =>
GetAll().Where(v => v.StudentIds.Contains(studentId)).ToList();
public Vorgang? GetById(Guid id)
{
var vorgang = db.Vorgaenge.FindById(id);
return vorgang is null || vorgang.IsDeleted ? null : vorgang;
}
public void Save(Vorgang vorgang)
{
vorgang.UpdatedAt = DateTime.UtcNow;
db.Vorgaenge.Upsert(vorgang);
db.OnChange?.Invoke(nameof(Vorgang), vorgang.Id.ToString(), "Save", vorgang);
}
public void Delete(Guid id)
{
var vorgang = db.Vorgaenge.FindById(id);
if (vorgang is null) return;
vorgang.IsDeleted = true;
vorgang.DeletedAt = DateTime.UtcNow;
db.Vorgaenge.Update(vorgang);
// Weiches Löschen ist inhaltlich eine Änderung, kein Entfernen -> "Save", damit ein
// anwendendes Gerät den IsDeleted-Stand einfach übernimmt statt den Datensatz zu entfernen
// (gleiches Muster wie DocumentationRepository.Delete).
db.OnChange?.Invoke(nameof(Vorgang), id.ToString(), "Save", vorgang);
}
} }
public class WorkTaskRepository(LiteDbContext db) : IWorkTaskRepository public class WorkTaskRepository(LiteDbContext db) : IWorkTaskRepository
@@ -204,8 +483,26 @@ public class WorkTaskRepository(LiteDbContext db) : IWorkTaskRepository
db.Tasks.Find(t => t.Status == s).OrderBy(t => t.DueDate).ToList(); db.Tasks.Find(t => t.Status == s).OrderBy(t => t.DueDate).ToList();
public List<WorkTask> GetAll() => public List<WorkTask> GetAll() =>
db.Tasks.FindAll().OrderBy(t => t.Status).ThenBy(t => t.DueDate).ToList(); db.Tasks.FindAll().OrderBy(t => t.Status).ThenBy(t => t.DueDate).ToList();
public void Save(WorkTask t) { t.UpdatedAt = DateTime.UtcNow; db.Tasks.Upsert(t); } public List<WorkTask> GetByGroup(Guid groupId) =>
public void Delete(Guid id) => db.Tasks.Delete(id); db.Tasks.Find(t => t.GroupId == groupId).OrderBy(t => t.Status).ThenBy(t => t.DueDate).ToList();
public void Save(WorkTask t)
{
t.UpdatedAt = DateTime.UtcNow;
db.Tasks.Upsert(t);
db.OnChange?.Invoke(nameof(WorkTask), t.Id.ToString(), "Save", t);
}
public void Delete(Guid id)
{
if (db.Tasks.FindById(id) is { } task)
db.MoveToTrash(nameof(WorkTask), id, task, task.Title);
db.Tasks.Delete(id);
db.OnChange?.Invoke(nameof(WorkTask), id.ToString(), "Delete", null);
}
public void Restore(Guid trashId)
{
if (db.RestoreFromTrash<WorkTask>(trashId) is { } task) Save(task);
}
} }
public class TimeEntryRepository(LiteDbContext db) : ITimeEntryRepository public class TimeEntryRepository(LiteDbContext db) : ITimeEntryRepository
@@ -216,8 +513,24 @@ public class TimeEntryRepository(LiteDbContext db) : ITimeEntryRepository
db.TimeEntries.Find(e => e.Date >= from && e.Date <= to).OrderBy(e => e.Date).ToList(); db.TimeEntries.Find(e => e.Date >= from && e.Date <= to).OrderBy(e => e.Date).ToList();
public List<TimeEntry> GetByTask(Guid id) => public List<TimeEntry> GetByTask(Guid id) =>
db.TimeEntries.Find(e => e.TaskId == id).ToList(); db.TimeEntries.Find(e => e.TaskId == id).ToList();
public void Save(TimeEntry e) => db.TimeEntries.Upsert(e); public void Save(TimeEntry e)
public void Delete(Guid id) => db.TimeEntries.Delete(id); {
db.TimeEntries.Upsert(e);
db.OnChange?.Invoke(nameof(TimeEntry), e.Id.ToString(), "Save", e);
}
public void Delete(Guid id)
{
if (db.TimeEntries.FindById(id) is { } entry)
db.MoveToTrash(nameof(TimeEntry), id, entry,
$"{entry.DurationMinutes} Min. {entry.Category} ({entry.Date:dd.MM.yyyy})");
db.TimeEntries.Delete(id);
db.OnChange?.Invoke(nameof(TimeEntry), id.ToString(), "Delete", null);
}
public void Restore(Guid trashId)
{
if (db.RestoreFromTrash<TimeEntry>(trashId) is { } entry) Save(entry);
}
} }
public class ParticipationSessionRepository(LiteDbContext db) : IParticipationSessionRepository public class ParticipationSessionRepository(LiteDbContext db) : IParticipationSessionRepository
@@ -225,12 +538,20 @@ public class ParticipationSessionRepository(LiteDbContext db) : IParticipationSe
public List<ParticipationSession> GetByGroup(Guid groupId) => public List<ParticipationSession> GetByGroup(Guid groupId) =>
db.ParticipationSessions.Find(s => s.GroupId == groupId).OrderByDescending(s => s.Date).ToList(); db.ParticipationSessions.Find(s => s.GroupId == groupId).OrderByDescending(s => s.Date).ToList();
public ParticipationSession? GetById(Guid id) => db.ParticipationSessions.FindById(id); public ParticipationSession? GetById(Guid id) => db.ParticipationSessions.FindById(id);
public void Save(ParticipationSession s) { s.UpdatedAt = DateTime.UtcNow; db.ParticipationSessions.Upsert(s); } public List<ParticipationSession> GetAll() => db.ParticipationSessions.FindAll().ToList();
public void Save(ParticipationSession s)
{
ArchivedGroupWriteGuard.EnsureActive(db, s.GroupId);
s.UpdatedAt = DateTime.UtcNow;
db.ParticipationSessions.Upsert(s);
db.OnChange?.Invoke(nameof(ParticipationSession), s.Id.ToString(), "Save", s);
}
public void Delete(Guid id) public void Delete(Guid id)
{ {
db.ParticipationSessions.Delete(id); if (db.ParticipationSessions.FindById(id) is { } session)
foreach (var e in db.ParticipationEntries.Find(e => e.SessionId == id).ToList()) ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId);
db.ParticipationEntries.Delete(e.Id); db.CascadeDeleteParticipationSession(id);
db.OnChange?.Invoke(nameof(ParticipationSession), id.ToString(), "Delete", null);
} }
} }
@@ -242,17 +563,34 @@ public class ParticipationRepository(LiteDbContext db) : IParticipationRepositor
db.ParticipationEntries.Find(e => e.StudentId == studentId).ToList(); db.ParticipationEntries.Find(e => e.StudentId == studentId).ToList();
public ParticipationEntry? GetBySessionAndStudent(Guid sessionId, Guid studentId) => public ParticipationEntry? GetBySessionAndStudent(Guid sessionId, Guid studentId) =>
db.ParticipationEntries.FindOne(e => e.SessionId == sessionId && e.StudentId == studentId); db.ParticipationEntries.FindOne(e => e.SessionId == sessionId && e.StudentId == studentId);
public void Save(ParticipationEntry e) { e.UpdatedAt = DateTime.UtcNow; db.ParticipationEntries.Upsert(e); } public void Save(ParticipationEntry e)
{
if (db.ParticipationSessions.FindById(e.SessionId) is { } session)
ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId);
e.UpdatedAt = DateTime.UtcNow;
db.ParticipationEntries.Upsert(e);
db.OnChange?.Invoke(nameof(ParticipationEntry), e.Id.ToString(), "Save", e);
}
public void SaveMany(List<ParticipationEntry> entries) public void SaveMany(List<ParticipationEntry> entries)
{ {
foreach (var sessionId in entries.Select(e => e.SessionId).Distinct())
if (db.ParticipationSessions.FindById(sessionId) is { } session)
ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId);
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
foreach (var e in entries) e.UpdatedAt = now; foreach (var e in entries) e.UpdatedAt = now;
db.ParticipationEntries.Upsert(entries); db.ParticipationEntries.Upsert(entries);
foreach (var e in entries)
db.OnChange?.Invoke(nameof(ParticipationEntry), e.Id.ToString(), "Save", e);
} }
public void DeleteBySession(Guid sessionId) public void DeleteBySession(Guid sessionId)
{ {
if (db.ParticipationSessions.FindById(sessionId) is { } session)
ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId);
foreach (var e in db.ParticipationEntries.Find(e => e.SessionId == sessionId).ToList()) foreach (var e in db.ParticipationEntries.Find(e => e.SessionId == sessionId).ToList())
{
db.ParticipationEntries.Delete(e.Id); db.ParticipationEntries.Delete(e.Id);
db.OnChange?.Invoke(nameof(ParticipationEntry), e.Id.ToString(), "Delete", null);
}
} }
} }
@@ -262,16 +600,54 @@ public class ParticipationAspectRepository(LiteDbContext db) : IParticipationAsp
db.ParticipationAspects.Find(a => a.GroupId == null && a.IsActive).OrderBy(a => a.SortOrder).ToList(); db.ParticipationAspects.Find(a => a.GroupId == null && a.IsActive).OrderBy(a => a.SortOrder).ToList();
public List<ParticipationAspect> GetByGroup(Guid groupId) => public List<ParticipationAspect> GetByGroup(Guid groupId) =>
db.ParticipationAspects.Find(a => a.GroupId == groupId && a.IsActive).OrderBy(a => a.SortOrder).ToList(); db.ParticipationAspects.Find(a => a.GroupId == groupId && a.IsActive).OrderBy(a => a.SortOrder).ToList();
public void Save(ParticipationAspect a) { a.UpdatedAt = DateTime.UtcNow; db.ParticipationAspects.Upsert(a); } public List<ParticipationAspect> GetAllByGroup(Guid groupId) =>
public void Delete(Guid id) => db.ParticipationAspects.Delete(id); db.ParticipationAspects.Find(a => a.GroupId == groupId).OrderBy(a => a.SortOrder).ToList();
public void Save(ParticipationAspect a)
{
if (a.GroupId is Guid groupId) ArchivedGroupWriteGuard.EnsureActive(db, groupId);
a.Key = a.Key.Trim();
a.Label = a.Label.Trim();
if (a.Key.Length == 0) throw new ArgumentException("Der Schlüssel darf nicht leer sein.");
if (a.Label.Length == 0) throw new ArgumentException("Die Bezeichnung darf nicht leer sein.");
// Ein Schlüssel muss innerhalb dessen, was für eine Gruppe tatsächlich gilt, eindeutig
// sein — das sind die globalen Standardaspekte UND die gruppenspezifischen zusammen
// (siehe ParticipationTabViewModel.LoadAspects, das beide konkateniert). Sonst entstünde
// in der Bewertungsübersicht eine mehrdeutige Spalte mit identischem Schlüssel.
var relevant = db.ParticipationAspects.Find(x => x.GroupId == null || x.GroupId == a.GroupId);
var duplicate = relevant.FirstOrDefault(x =>
x.Id != a.Id && string.Equals(x.Key, a.Key, StringComparison.OrdinalIgnoreCase));
if (duplicate is not null)
throw new InvalidOperationException("Ein Aspekt mit diesem Schlüssel existiert für diese Gruppe bereits.");
a.UpdatedAt = DateTime.UtcNow;
db.ParticipationAspects.Upsert(a);
db.OnChange?.Invoke(nameof(ParticipationAspect), a.Id.ToString(), "Save", a);
}
public void Delete(Guid id)
{
if (db.ParticipationAspects.FindById(id)?.GroupId is Guid groupId)
ArchivedGroupWriteGuard.EnsureActive(db, groupId);
db.ParticipationAspects.Delete(id);
db.OnChange?.Invoke(nameof(ParticipationAspect), id.ToString(), "Delete", null);
}
} }
public class ParticipationSectionRepository(LiteDbContext db) : IParticipationSectionRepository public class ParticipationSectionRepository(LiteDbContext db) : IParticipationSectionRepository
{ {
public List<ParticipationSection> GetByGroup(Guid groupId) => public List<ParticipationSection> GetByGroup(Guid groupId) =>
db.ParticipationSections.Find(s => s.GroupId == groupId).OrderBy(s => s.StartDate).ToList(); db.ParticipationSections.Find(s => s.GroupId == groupId).OrderBy(s => s.StartDate).ToList();
public void Save(ParticipationSection s) => db.ParticipationSections.Upsert(s); public void Save(ParticipationSection s)
public void Delete(Guid id) => db.ParticipationSections.Delete(id); {
ArchivedGroupWriteGuard.EnsureActive(db, s.GroupId);
db.ParticipationSections.Upsert(s);
db.OnChange?.Invoke(nameof(ParticipationSection), s.Id.ToString(), "Save", s);
}
public void Delete(Guid id)
{
if (db.ParticipationSections.FindById(id) is { } section)
ArchivedGroupWriteGuard.EnsureActive(db, section.GroupId);
db.ParticipationSections.Delete(id);
db.OnChange?.Invoke(nameof(ParticipationSection), id.ToString(), "Delete", null);
}
} }
public class SubjectRepository(LiteDbContext db) : ISubjectRepository public class SubjectRepository(LiteDbContext db) : ISubjectRepository
@@ -290,12 +666,251 @@ public class SubjectRepository(LiteDbContext db) : ISubjectRepository
throw new InvalidOperationException("Ein Fach mit diesem Namen existiert bereits."); throw new InvalidOperationException("Ein Fach mit diesem Namen existiert bereits.");
s.UpdatedAt = DateTime.UtcNow; s.UpdatedAt = DateTime.UtcNow;
db.Subjects.Upsert(s); db.Subjects.Upsert(s);
db.OnChange?.Invoke(nameof(Subject), s.Id.ToString(), "Save", s);
} }
public void Delete(Guid id) public void Delete(Guid id)
{ {
if (db.Groups.Exists(g => g.SubjectId == id) || db.CompetencyDomains.Exists(d => d.SubjectId == id)) if (db.Groups.Exists(g => g.SubjectId == id) || db.CompetencyDomains.Exists(d => d.SubjectId == id))
throw new InvalidOperationException("Das Fach wird noch von einer Lerngruppe oder einem Kompetenzkatalog verwendet."); throw new InvalidOperationException("Das Fach wird noch von einer Lerngruppe oder einem Kompetenzkatalog verwendet.");
db.Subjects.Delete(id); db.Subjects.Delete(id);
db.OnChange?.Invoke(nameof(Subject), id.ToString(), "Delete", null);
}
}
public class ShorthandCodeRepository(LiteDbContext db) : IShorthandCodeRepository
{
public List<ShorthandCode> GetAll() => db.ShorthandCodes.FindAll().OrderBy(c => c.Code).ToList();
public void Save(ShorthandCode c)
{
c.Code = c.Code.Trim();
c.Label = c.Label.Trim();
if (c.Code.Length == 0) throw new ArgumentException("Das Kürzel darf nicht leer sein.");
var duplicate = db.ShorthandCodes.FindAll()
.FirstOrDefault(x => string.Equals(x.Code, c.Code, StringComparison.OrdinalIgnoreCase));
if (duplicate is not null && duplicate.Id != c.Id)
throw new InvalidOperationException("Ein Kürzel mit diesem Code existiert bereits.");
c.UpdatedAt = DateTime.UtcNow;
db.ShorthandCodes.Upsert(c);
db.OnChange?.Invoke(nameof(ShorthandCode), c.Id.ToString(), "Save", c);
}
public void Delete(Guid id)
{
db.ShorthandCodes.Delete(id);
db.OnChange?.Invoke(nameof(ShorthandCode), id.ToString(), "Delete", null);
}
}
public class AlternativeLessonPathRepository(LiteDbContext db) : IAlternativeLessonPathRepository
{
public List<AlternativeLessonPath> GetAll() => db.AlternativeLessonPaths.FindAll().OrderBy(p => p.Name).ToList();
public AlternativeLessonPath? GetById(Guid id) => db.AlternativeLessonPaths.FindById(id);
public void Save(AlternativeLessonPath p)
{
p.Name = p.Name.Trim();
p.Description = string.IsNullOrWhiteSpace(p.Description) ? null : p.Description.Trim();
if (p.Name.Length == 0) throw new ArgumentException("Der Name darf nicht leer sein.");
var duplicate = db.AlternativeLessonPaths.FindAll()
.FirstOrDefault(x => string.Equals(x.Name, p.Name, StringComparison.OrdinalIgnoreCase));
if (duplicate is not null && duplicate.Id != p.Id)
throw new InvalidOperationException("Ein alternativer Ablauf mit diesem Namen existiert bereits.");
p.UpdatedAt = DateTime.UtcNow;
db.AlternativeLessonPaths.Upsert(p);
db.OnChange?.Invoke(nameof(AlternativeLessonPath), p.Id.ToString(), "Save", p);
}
public void Delete(Guid id)
{
db.AlternativeLessonPaths.Delete(id);
db.OnChange?.Invoke(nameof(AlternativeLessonPath), id.ToString(), "Delete", null);
}
}
public class TimetableSlotRepository(LiteDbContext db) : ITimetableSlotRepository
{
public List<TimetableSlot> GetAll() =>
db.TimetableSlots.FindAll().OrderBy(s => s.Weekday).ThenBy(s => s.PeriodNumber).ToList();
public List<TimetableSlot> GetByGroup(Guid groupId) =>
db.TimetableSlots.Find(s => s.GroupId == groupId).OrderBy(s => s.Weekday).ThenBy(s => s.PeriodNumber).ToList();
public void Save(TimetableSlot slot)
{
ArchivedGroupWriteGuard.EnsureActive(db, slot.GroupId);
var occupied = db.TimetableSlots.FindAll()
.FirstOrDefault(s => s.Weekday == slot.Weekday && s.PeriodNumber == slot.PeriodNumber);
if (occupied is not null && occupied.Id != slot.Id)
throw new InvalidOperationException("Diese Stunde ist bereits belegt.");
db.TimetableSlots.Upsert(slot);
db.OnChange?.Invoke(nameof(TimetableSlot), slot.Id.ToString(), "Save", slot);
}
public void Delete(Guid id)
{
if (db.TimetableSlots.FindById(id) is { } slot)
ArchivedGroupWriteGuard.EnsureActive(db, slot.GroupId);
db.TimetableSlots.Delete(id);
db.OnChange?.Invoke(nameof(TimetableSlot), id.ToString(), "Delete", null);
}
}
public class SchoolHolidayRepository(LiteDbContext db) : ISchoolHolidayRepository
{
public List<SchoolHoliday> GetAll() => db.SchoolHolidays.FindAll().OrderBy(h => h.StartDate).ToList();
public void Save(SchoolHoliday holiday)
{
db.SchoolHolidays.Upsert(holiday);
db.OnChange?.Invoke(nameof(SchoolHoliday), holiday.Id.ToString(), "Save", holiday);
}
public void Delete(Guid id)
{
db.SchoolHolidays.Delete(id);
db.OnChange?.Invoke(nameof(SchoolHoliday), id.ToString(), "Delete", null);
}
}
public class SupervisionDutyRepository(LiteDbContext db) : ISupervisionDutyRepository
{
public List<SupervisionDuty> GetAll() =>
db.SupervisionDuties.FindAll().OrderBy(d => d.Weekday).ThenBy(d => d.AfterPeriod).ToList();
public void Save(SupervisionDuty duty)
{
var occupied = db.SupervisionDuties.FindAll()
.FirstOrDefault(d => d.Weekday == duty.Weekday && d.AfterPeriod == duty.AfterPeriod);
if (occupied is not null && occupied.Id != duty.Id)
throw new InvalidOperationException("Für diese Pause ist bereits eine Aufsicht eingetragen.");
db.SupervisionDuties.Upsert(duty);
db.OnChange?.Invoke(nameof(SupervisionDuty), duty.Id.ToString(), "Save", duty);
}
public void Delete(Guid id)
{
db.SupervisionDuties.Delete(id);
db.OnChange?.Invoke(nameof(SupervisionDuty), id.ToString(), "Delete", null);
}
}
public class SubstitutionEntryRepository(LiteDbContext db) : ISubstitutionEntryRepository
{
public List<SubstitutionEntry> GetAll() => db.SubstitutionEntries.FindAll().OrderBy(e => e.Date).ToList();
public List<SubstitutionEntry> GetByDate(DateOnly date) =>
db.SubstitutionEntries.Find(e => e.Date == date).ToList();
public SubstitutionEntry? GetByExternalId(string externalId) =>
db.SubstitutionEntries.FindOne(e => e.ExternalId == externalId);
public void Save(SubstitutionEntry entry)
{
db.SubstitutionEntries.Upsert(entry);
db.OnChange?.Invoke(nameof(SubstitutionEntry), entry.Id.ToString(), "Save", entry);
}
public void Delete(Guid id)
{
db.SubstitutionEntries.Delete(id);
db.OnChange?.Invoke(nameof(SubstitutionEntry), id.ToString(), "Delete", null);
}
}
// Bewusst kein db.OnChange hier (anders als sonst überall): UntisSnapshotEntry ist eine rein
// lokale, hochfrequente Abgleich-Zwischenablage (jeder Poll aktualisiert alle Zeilen), deren
// Sync nur Rauschen erzeugen würde. UntisSlotMapping ist an die pro Gerät hinterlegte
// WebUntis-URL gebunden (WebUntisSettingsService, wie SyncSettingsService nicht synchronisiert)
// und deshalb ebenfalls sinnvollerweise lokal — jedes Gerät bestätigt seine Zuordnung einmal
// selbst über den Review-Dialog.
public class UntisSnapshotRepository(LiteDbContext db) : IUntisSnapshotRepository
{
public List<UntisSnapshotEntry> GetAll() => db.UntisSnapshotEntries.FindAll().ToList();
public void Save(UntisSnapshotEntry entry) => db.UntisSnapshotEntries.Upsert(entry);
public void Delete(Guid id) => db.UntisSnapshotEntries.Delete(id);
}
public class UntisSlotMappingRepository(LiteDbContext db) : IUntisSlotMappingRepository
{
public List<UntisSlotMapping> GetAll() => db.UntisSlotMappings.FindAll().ToList();
public void Save(UntisSlotMapping mapping) => db.UntisSlotMappings.Upsert(mapping);
public void Delete(Guid id) => db.UntisSlotMappings.Delete(id);
}
// Wie UntisSnapshotEntry rein lokal: Der Jahresplan ist ein wiederherstellbarer Cache eines
// externen Feeds. db.OnChange würde bei jedem vollständigen Abruf hunderte Sync-Ereignisse erzeugen.
public class AnnualPlanEventRepository(LiteDbContext db) : IAnnualPlanEventRepository
{
public List<AnnualPlanEvent> GetAll() =>
db.AnnualPlanEvents.FindAll().OrderBy(e => e.StartDate).ThenBy(e => e.StartTime).ToList();
public List<AnnualPlanEvent> GetByRange(DateOnly from, DateOnly to) =>
db.AnnualPlanEvents.Find(e => e.StartDate <= to && e.EndDate >= from)
.OrderBy(e => e.StartDate).ThenBy(e => e.StartTime).ToList();
public AnnualPlanEvent? GetByExternalId(string externalId) =>
db.AnnualPlanEvents.FindOne(e => e.ExternalId == externalId);
public void Save(AnnualPlanEvent entry) => db.AnnualPlanEvents.Upsert(entry);
public void Delete(Guid id) => db.AnnualPlanEvents.Delete(id);
}
// Wie UntisSnapshotEntry/AnnualPlanEvent rein lokal, bewusst kein db.OnChange: Fehlzeiten- und
// Klassenbuch-Cache fürs Klassenlehrer-Feature (siehe TODO.md) wachsen über ein Schuljahr auf viele
// hundert Zeilen an - über Sync würde das nur Rauschen erzeugen, und jedes Gerät ruft WebUntis
// ohnehin selbst ab (siehe UntisReportCacheService).
public class UntisAbsenceCacheRepository(LiteDbContext db) : IUntisAbsenceCacheRepository
{
public List<UntisAbsenceCacheEntry> GetByClassAndRange(string className, int startDate, int endDate) =>
db.UntisAbsenceCache
.Find(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate)
.ToList();
public void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisAbsenceCacheEntry> entries)
{
db.UntisAbsenceCache.DeleteMany(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate);
db.UntisAbsenceCache.InsertBulk(entries);
}
public void InsertRange(IEnumerable<UntisAbsenceCacheEntry> entries) => db.UntisAbsenceCache.InsertBulk(entries);
}
public class UntisClassRegisterCacheRepository(LiteDbContext db) : IUntisClassRegisterCacheRepository
{
public List<UntisClassRegisterCacheEntry> GetByClassAndRange(string className, int startDate, int endDate) =>
db.UntisClassRegisterCache
.Find(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate)
.ToList();
public void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisClassRegisterCacheEntry> entries)
{
db.UntisClassRegisterCache.DeleteMany(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate);
db.UntisClassRegisterCache.InsertBulk(entries);
}
public void InsertRange(IEnumerable<UntisClassRegisterCacheEntry> entries) => db.UntisClassRegisterCache.InsertBulk(entries);
}
// Gleiches "bewusst kein db.OnChange"-Prinzip wie UntisAbsenceCacheRepository oben, auch wenn der
// Roster selbst klein bleibt (aktuelle Klassenliste, keine Historie) - jedes Gerät ruft ihn ohnehin
// selbst ab.
public class UntisStudentRosterCacheRepository(LiteDbContext db) : IUntisStudentRosterCacheRepository
{
public List<UntisStudentRosterCacheEntry> GetByClass(string className) =>
db.UntisStudentRosterCache.Find(e => e.ClassName == className).ToList();
public void ReplaceAll(string className, IEnumerable<UntisStudentRosterCacheEntry> entries)
{
db.UntisStudentRosterCache.DeleteMany(e => e.ClassName == className);
db.UntisStudentRosterCache.InsertBulk(entries);
}
}
public class UntisCacheFetchStateRepository(LiteDbContext db) : IUntisCacheFetchStateRepository
{
public UntisCacheFetchState? Get(string className, UntisCacheKind kind) =>
db.UntisCacheFetchStates.FindOne(s => s.ClassName == className && s.Kind == kind);
public void Save(UntisCacheFetchState state) => db.UntisCacheFetchStates.Upsert(state);
}
public class UntisHubJobStateRepository(LiteDbContext db) : IUntisHubJobStateRepository
{
public UntisHubJobState? Get(UntisHubJobKind kind, Guid? groupId) =>
db.UntisHubJobStates.FindOne(s => s.Kind == kind && s.GroupId == groupId);
public List<UntisHubJobState> GetAll() => db.UntisHubJobStates.FindAll().ToList();
public void Save(UntisHubJobState state)
{
db.UntisHubJobStates.Upsert(state);
db.OnChange?.Invoke(nameof(UntisHubJobState), state.Id.ToString(), "Save", state);
} }
} }
@@ -306,14 +921,67 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep
.Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel) .Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel)
.OrderBy(d => d.SortOrder) .OrderBy(d => d.SortOrder)
.ToList(); .ToList();
public List<CompetencyDomain> GetBySubject(Guid subjectId) =>
db.CompetencyDomains
.Find(d => d.SubjectId == subjectId)
.OrderBy(d => d.GradeLevel).ThenBy(d => d.SortOrder)
.ToList();
public CompetencyDomain? GetById(Guid id) => db.CompetencyDomains.FindById(id); public CompetencyDomain? GetById(Guid id) => db.CompetencyDomains.FindById(id);
public void Save(CompetencyDomain d) { d.UpdatedAt = DateTime.UtcNow; db.CompetencyDomains.Upsert(d); } public void Save(CompetencyDomain d)
public void Delete(Guid id) => db.CompetencyDomains.Delete(id); {
d.UpdatedAt = DateTime.UtcNow;
db.CompetencyDomains.Upsert(d);
db.OnChange?.Invoke(nameof(CompetencyDomain), d.Id.ToString(), "Save", d);
}
public void Delete(Guid id)
{
db.CompetencyDomains.Delete(id);
db.OnChange?.Invoke(nameof(CompetencyDomain), id.ToString(), "Delete", null);
}
public void DeleteBySubjectAndGrade(Guid subjectId, int gradeLevel) public void DeleteBySubjectAndGrade(Guid subjectId, int gradeLevel)
{ {
foreach (var d in db.CompetencyDomains foreach (var d in db.CompetencyDomains
.Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel) .Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel)
.ToList()) .ToList())
{
db.CompetencyDomains.Delete(d.Id); db.CompetencyDomains.Delete(d.Id);
db.OnChange?.Invoke(nameof(CompetencyDomain), d.Id.ToString(), "Delete", null);
}
}
public void ReplaceForSubjectAndGrade(Guid subjectId, int gradeLevel, List<CompetencyDomain> domains)
{
db.ExecuteInTransaction(() =>
{
foreach (var domain in db.CompetencyDomains
.Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel).ToList())
{
db.CompetencyDomains.Delete(domain.Id);
db.OnChange?.Invoke(nameof(CompetencyDomain), domain.Id.ToString(), "Delete", null);
}
var now = DateTime.UtcNow;
foreach (var domain in domains)
{
domain.SubjectId = subjectId;
domain.GradeLevel = gradeLevel;
domain.UpdatedAt = now;
db.CompetencyDomains.Upsert(domain);
db.OnChange?.Invoke(nameof(CompetencyDomain), domain.Id.ToString(), "Save", domain);
}
});
}
}
/// <summary>Papierkorb (14.3) — bewusst nicht sync-fähig: TrashedItem wird nie über
/// db.OnChange gemeldet, bleibt also rein lokal auf dem Gerät, auf dem gelöscht wurde. Ein
/// "Fehlklick sofort rückgängig machen"-Werkzeug, kein geräteübergreifendes Archiv.</summary>
public class TrashRepository(LiteDbContext db) : ITrashRepository
{
public List<TrashedItem> GetAll() =>
db.TrashedItems.FindAll().OrderByDescending(t => t.DeletedAt).ToList();
public void PurgeOlderThan(DateTime cutoffUtc)
{
foreach (var item in db.TrashedItems.Find(t => t.DeletedAt < cutoffUtc).ToList())
db.TrashedItems.Delete(item.Id);
} }
} }

Some files were not shown because too many files have changed in this diff Show More