Author SHA1 Message Date
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
417 changed files with 49469 additions and 3084 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"
+1
View File
@@ -2,6 +2,7 @@
bin/ bin/
obj/ obj/
out/ out/
build/
# ── .NET ────────────────────────────────────────────────────────────────────── # ── .NET ──────────────────────────────────────────────────────────────────────
*.user *.user
+15 -1
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",
+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",
+4
View File
@@ -60,12 +60,16 @@ The API can also run via `docker/docker-compose.yml` (reads `JWT_SECRET` from th
orchestration, timer-driven), `SnapshotService`, `Crypto/SyncCrypto` (AES-256-GCM payload orchestration, timer-driven), `SnapshotService`, `Crypto/SyncCrypto` (AES-256-GCM payload
encryption — desktop events are encrypted at rest and in transit; Companion/WebApp events are encryption — desktop events are encrypted at rest and in transit; Companion/WebApp events are
plaintext, see `PlainSyncEvent` vs `SyncEvent`). 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 - **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 `SnapshotStore`/`ReadableSnapshotStore` per device, mapped in `Endpoints/Endpoints.cs`. Sync is
optional — Desktop only registers `SyncEngine`/`SnapshotService` in DI when a server URL is optional — Desktop only registers `SyncEngine`/`SnapshotService` in DI when a server URL is
configured (`AppBootstrapper.LoadServerUrl`). configured (`AppBootstrapper.LoadServerUrl`).
- Each library has a matching `*.Tests` project (`LehrerApp.Tests` → Core, `LehrerApp.Data.Tests` - Each library has a matching `*.Tests` project (`LehrerApp.Tests` → Core, `LehrerApp.Data.Tests`
Data, `LehrerApp.Desktop.Tests` → Desktop, `LehrerApp.Sync.Tests` → Sync), all xUnit. 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) ### MVVM conventions (Desktop)
+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>
+5
View File
@@ -17,6 +17,11 @@
<!-- 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" />
+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; } = "";
}
@@ -119,3 +119,58 @@ public class AiExplainResponse
{ {
public string Explanation { get; set; } = ""; 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; } = "";
}
@@ -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();
}
@@ -25,6 +25,9 @@ public interface ISeatingPlanRepository
List<SeatingPlan> GetByGroup(Guid groupId); List<SeatingPlan> GetByGroup(Guid groupId);
void Save(SeatingPlan plan); void Save(SeatingPlan plan);
void Delete(Guid id); 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
{ {
@@ -57,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
{ {
@@ -64,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
{ {
@@ -117,9 +124,70 @@ public interface ISubstitutionEntryRepository
{ {
List<SubstitutionEntry> GetAll(); List<SubstitutionEntry> GetAll();
List<SubstitutionEntry> GetByDate(DateOnly date); 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 Save(SubstitutionEntry entry);
void Delete(Guid id); 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);
}
/// 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);
@@ -136,8 +204,12 @@ 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
{ {
@@ -146,11 +218,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);
} }
@@ -209,3 +286,11 @@ public interface IAlternativeLessonPathRepository
void Save(AlternativeLessonPath path); void Save(AlternativeLessonPath path);
void Delete(Guid id); 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.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,
}
+20
View File
@@ -13,6 +13,26 @@ 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>
/// 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;
} }
+46
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>
@@ -155,3 +177,27 @@ public static class ParticipationRatingScale
_ => value, _ => 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,
};
}
}
+21 -2
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; }
@@ -56,8 +56,18 @@ public class Lesson
public TimeOnly? StartTime { get; set; } public TimeOnly? StartTime { get; set; }
public List<LessonPhaseStep> Phases { get; set; } = []; public List<LessonPhaseStep> Phases { 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; }
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;
} }
@@ -90,7 +100,10 @@ public class LessonPhaseStep
} }
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 }
/// <summary> /// <summary>
/// Katalogeintrag für einen wiederverwendbaren "alternativen Ablauf" (z.B. "Kurzversion" bei /// Katalogeintrag für einen wiederverwendbaren "alternativen Ablauf" (z.B. "Kurzversion" bei
@@ -200,6 +213,12 @@ public class SubstitutionEntry
/// Bezeichnung (bei SpecialAssignment, z.B. "Ausflug ins Museum", "Berufsmesse"). /// Bezeichnung (bei SpecialAssignment, z.B. "Ausflug ins Museum", "Berufsmesse").
public string Description { get; set; } = ""; public string Description { get; set; } = "";
public string? Notes { 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>
+23
View File
@@ -12,7 +12,24 @@ public class SeatingPlan
public string Room { get; set; } = ""; public string Room { get; set; } = "";
public int Rows { get; set; } = 4; public int Rows { get; set; } = 4;
public int Columns { 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; } = []; 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 CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
} }
@@ -23,3 +40,9 @@ public class SeatAssignment
public int Column { get; set; } public int Column { get; set; }
public Guid StudentId { get; set; } public Guid StudentId { get; set; }
} }
public class HiddenSeat
{
public int Row { get; set; }
public int Column { get; set; }
}
+3
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;
@@ -22,6 +24,7 @@ public class Contact
/// <summary>Vollständige Anrede für Briefe, z.B. „Sehr geehrte Frau Mustermann,“.</summary> /// <summary>Vollständige Anrede für Briefe, z.B. „Sehr geehrte Frau Mustermann,“.</summary>
public string? LetterSalutation { get; set; } 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; }
+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; }
}
+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;
}
+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; }
}
+51 -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; } = "";
@@ -17,7 +21,13 @@ public class Documentation
public List<DocumentAttachment> Attachments { get; set; } = []; public List<DocumentAttachment> Attachments { get; set; } = [];
/// Freie Labels zur Nachverfolgung, z.B. "Kritisch", "Nacharbeiten" — siehe `DocumentationTagDisplay`. /// Freie Labels zur Nachverfolgung, z.B. "Kritisch", "Nacharbeiten" — siehe `DocumentationTagDisplay`.
public List<string> Tags { get; set; } = []; 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). // Löschen entfernt den Eintrag nicht hart, sondern markiert ihn nur (Nachvollziehbarkeit, 5.1.4).
@@ -71,8 +81,19 @@ public class DocumentAttachment
public long SizeBytes { get; set; } public long SizeBytes { get; set; }
public DateTime UploadedAt { get; set; } = DateTime.UtcNow; 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. // Hinten angefügt, damit die numerischen Werte bereits gespeicherter LiteDB-Einträge stabil bleiben.
public enum DocumentationType { Conversation, Incident, SupportPlan, Absence, ParentCall, ParentLetter } 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
@@ -87,9 +108,34 @@ public class WorkTask
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). // 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; 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();
@@ -103,6 +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 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}.");
}
@@ -25,10 +25,13 @@ public class AttendanceBalanceService
.Select(e => e.Status!.Value) .Select(e => e.Status!.Value)
.ToList(); .ToList();
var present = relevant.Count(s => s == AttendanceStatus.Present); var present = relevant.Count(s => s is AttendanceStatus.Present
or AttendanceStatus.Late or AttendanceStatus.SignificantlyLate);
var excused = relevant.Count(s => s == AttendanceStatus.Excused); var excused = relevant.Count(s => s == AttendanceStatus.Excused);
var unexcused = relevant.Count(s => s is AttendanceStatus.Unexcused or AttendanceStatus.Truant); var unexcused = relevant.Count(s => s is AttendanceStatus.Unexcused
var schoolEvent = relevant.Count(s => s == AttendanceStatus.OtherSchoolEvent); 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 pending = relevant.Count(s => s == AttendanceStatus.ExcusePending);
var total = relevant.Count; var total = relevant.Count;
+34
View File
@@ -14,6 +14,13 @@ public class BackupService
public string BackupDirectory => _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) public BackupService(string appDataPath)
{ {
_backupDirectory = Path.Combine(appDataPath, "backups"); _backupDirectory = Path.Combine(appDataPath, "backups");
@@ -34,9 +41,36 @@ public class BackupService
File.Copy(databasePath, target, overwrite: true); File.Copy(databasePath, target, overwrite: true);
PruneOldBackups(keepCount); PruneOldBackups(keepCount);
if (!string.IsNullOrWhiteSpace(SecondaryBackupDirectory))
TryMirrorToSecondary(target, fileName, keepCount);
return target; 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() => public List<BackupInfo> ListBackups() =>
Directory.GetFiles(_backupDirectory, "lehrerapp-*.db") Directory.GetFiles(_backupDirectory, "lehrerapp-*.db")
.Select(p => new BackupInfo(p, File.GetLastWriteTime(p), new FileInfo(p).Length)) .Select(p => new BackupInfo(p, File.GetLastWriteTime(p), new FileInfo(p).Length))
@@ -0,0 +1,63 @@
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
{
public static readonly string[] DefaultCardOrder =
[
"today", "tasks", "calendar", "excuses", "upcoming",
"corrections", "unplanned", "alerts", "attendance", "support", "groups", "examload",
"missingteachingtime",
];
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
@@ -60,6 +60,9 @@ public sealed class GroupRolloverService(
IsActive = true, IsActive = true,
IsOwnClass = source.IsOwnClass, IsOwnClass = source.IsOwnClass,
IsDifferentiated = source.IsDifferentiated, 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; var sourceWasActive = source.IsActive;
+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 == LessonStatus.Conducted || 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 == LessonStatus.Conducted ? 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,
};
}
@@ -1,316 +0,0 @@
using System.IO.Compression;
using System.Text.Json;
using System.Xml.Linq;
namespace LehrerApp.Core.Services;
public enum TemplateIssueSeverity
{
Warning,
StrongWarning,
Error,
}
public sealed record LetterPlaceholder(string Tag, string Description);
public sealed record TemplateValidationIssue(
TemplateIssueSeverity Severity,
string Message,
string? Tag = null,
string? SuggestedTag = null);
public sealed record TemplateValidationResult(
IReadOnlyList<string> Tags,
IReadOnlyList<TemplateValidationIssue> Issues)
{
public bool CanGenerate => Issues.All(i => i.Severity != TemplateIssueSeverity.Error);
public bool HasStrongWarnings => Issues.Any(i => i.Severity == TemplateIssueSeverity.StrongWarning);
}
public sealed class LetterTemplateInfo
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = "";
public string StoredFileName { get; set; } = "";
public string OriginalFileName { get; set; } = "";
public DateTime ImportedAt { get; set; } = DateTime.UtcNow;
}
/// <summary>
/// Verwaltet DOCX-Briefvorlagen und befüllt Word-Inhaltssteuerelemente anhand ihres Tags.
/// Die Originalvorlage wird nie verändert.
/// </summary>
public sealed class LetterTemplateService
{
private static readonly XNamespace W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
public static IReadOnlyList<LetterPlaceholder> SupportedPlaceholders { get; } =
[
new("Student.FirstName", "Vorname des Schülers"),
new("Student.LastName", "Nachname des Schülers"),
new("Contact.Name", "Name des ausgewählten Kontakts"),
new("Contact.Address", "Vollständige Anschrift (mehrzeilig)"),
new("Contact.Street", "Straße und Hausnummer"),
new("Contact.PostalCode", "Postleitzahl"),
new("Contact.City", "Ort"),
new("Letter.Salutation", "Gespeicherte Briefanrede des Kontakts"),
new("Group.Name", "Ausgewählte Lerngruppe"),
new("SchoolYear", "Schuljahr der ausgewählten Gruppe"),
new("CurrentDate", "Gewähltes Briefdatum"),
];
private readonly string _templateDirectory;
private readonly string _indexPath;
public LetterTemplateService(string appDataPath)
{
_templateDirectory = Path.Combine(appDataPath, "letter-templates");
_indexPath = Path.Combine(_templateDirectory, "templates.json");
Directory.CreateDirectory(_templateDirectory);
}
public IReadOnlyList<LetterTemplateInfo> GetTemplates() => LoadIndex()
.OrderBy(t => t.Name, StringComparer.CurrentCultureIgnoreCase)
.ToList();
public string GetTemplatePath(LetterTemplateInfo template) =>
Path.Combine(_templateDirectory, template.StoredFileName);
public LetterTemplateInfo Import(string sourcePath, string? displayName = null)
{
var validation = Validate(sourcePath);
if (!validation.CanGenerate)
throw new InvalidDataException(validation.Issues.First(i => i.Severity == TemplateIssueSeverity.Error).Message);
var templates = LoadIndex();
var info = new LetterTemplateInfo
{
Name = string.IsNullOrWhiteSpace(displayName)
? Path.GetFileNameWithoutExtension(sourcePath)
: displayName.Trim(),
OriginalFileName = Path.GetFileName(sourcePath),
};
info.StoredFileName = $"{info.Id:N}.docx";
File.Copy(sourcePath, GetTemplatePath(info), overwrite: false);
templates.Add(info);
SaveIndex(templates);
return info;
}
public void Delete(Guid id)
{
var templates = LoadIndex();
var template = templates.FirstOrDefault(t => t.Id == id);
if (template is null) return;
var path = GetTemplatePath(template);
if (File.Exists(path)) File.Delete(path);
templates.Remove(template);
SaveIndex(templates);
}
public TemplateValidationResult Validate(LetterTemplateInfo template) =>
Validate(GetTemplatePath(template));
public TemplateValidationResult Validate(string path)
{
var tags = new List<string>();
var issues = new List<TemplateValidationIssue>();
if (!File.Exists(path))
return Error("Die Vorlagendatei wurde nicht gefunden.");
if (!string.Equals(Path.GetExtension(path), ".docx", StringComparison.OrdinalIgnoreCase))
return Error("Die Vorlage muss eine DOCX-Datei sein.");
try
{
using var archive = ZipFile.OpenRead(path);
var xmlEntries = WordXmlEntries(archive).ToList();
if (xmlEntries.All(e => !string.Equals(e.FullName, "word/document.xml", StringComparison.OrdinalIgnoreCase)))
return Error("Die Datei ist kein gültiges Word-DOCX-Dokument.");
foreach (var entry in xmlEntries)
{
using var stream = entry.Open();
var document = XDocument.Load(stream, LoadOptions.PreserveWhitespace);
foreach (var control in document.Descendants(W + "sdt"))
{
var tag = control.Element(W + "sdtPr")?.Element(W + "tag")?.Attribute(W + "val")?.Value?.Trim();
if (string.IsNullOrWhiteSpace(tag))
{
issues.Add(new(TemplateIssueSeverity.StrongWarning,
$"Ein Inhaltssteuerelement in {PartLabel(entry.FullName)} besitzt keinen Tag und kann nicht befüllt werden."));
continue;
}
tags.Add(tag);
}
}
}
catch (InvalidDataException)
{
return Error("Die Datei ist beschädigt oder kein gültiges DOCX-Dokument.");
}
catch (System.Xml.XmlException)
{
return Error("Die Word-Vorlage enthält ungültiges XML und kann nicht gelesen werden.");
}
catch (IOException ex)
{
return Error($"Die Vorlage konnte nicht geöffnet werden: {ex.Message}");
}
if (tags.Count == 0 && issues.Count == 0)
issues.Add(new(TemplateIssueSeverity.Warning,
"Die Vorlage enthält keine Inhaltssteuerelemente. Es werden keine Felder befüllt."));
foreach (var tag in tags.Distinct(StringComparer.Ordinal))
{
if (SupportedPlaceholders.Any(p => p.Tag == tag)) continue;
var suggestion = FindSuggestion(tag);
issues.Add(suggestion is null
? new(TemplateIssueSeverity.Warning,
$"Der unbekannte Tag „{tag}“ wird nicht befüllt.", tag)
: new(TemplateIssueSeverity.StrongWarning,
$"Wahrscheinlicher Schreibfehler: „{tag}“. Meinten Sie „{suggestion}“?", tag, suggestion));
}
return new(tags.Distinct(StringComparer.Ordinal).OrderBy(t => t).ToList(), issues);
TemplateValidationResult Error(string message) =>
new([], [new(TemplateIssueSeverity.Error, message)]);
}
public void Generate(string templatePath, string outputPath,
IReadOnlyDictionary<string, string?> values)
{
var validation = Validate(templatePath);
if (!validation.CanGenerate)
throw new InvalidDataException(validation.Issues.First(i => i.Severity == TemplateIssueSeverity.Error).Message);
var outputDirectory = Path.GetDirectoryName(outputPath);
if (!string.IsNullOrEmpty(outputDirectory)) Directory.CreateDirectory(outputDirectory);
using var source = ZipFile.OpenRead(templatePath);
using var target = ZipFile.Open(outputPath, ZipArchiveMode.Create);
foreach (var entry in source.Entries)
{
var targetEntry = target.CreateEntry(entry.FullName, CompressionLevel.Optimal);
targetEntry.LastWriteTime = entry.LastWriteTime;
using var input = entry.Open();
using var output = targetEntry.Open();
if (!IsWordXmlEntry(entry))
{
input.CopyTo(output);
continue;
}
var document = XDocument.Load(input, LoadOptions.PreserveWhitespace);
foreach (var control in document.Descendants(W + "sdt").ToList())
{
var tag = control.Element(W + "sdtPr")?.Element(W + "tag")?.Attribute(W + "val")?.Value?.Trim();
if (tag is null || !values.TryGetValue(tag, out var value)) continue;
ReplaceContent(control.Element(W + "sdtContent"), value ?? "");
}
document.Save(output, SaveOptions.DisableFormatting);
}
}
private static void ReplaceContent(XElement? content, string value)
{
if (content is null) return;
var paragraph = content.Elements(W + "p").FirstOrDefault();
XElement run;
if (paragraph is not null)
{
run = paragraph.Descendants(W + "r").FirstOrDefault() ?? new XElement(W + "r");
var paragraphProperties = paragraph.Element(W + "pPr");
var runProperties = run.Element(W + "rPr") is { } rp ? new XElement(rp) : null;
paragraph.RemoveNodes();
if (paragraphProperties is not null) paragraph.Add(paragraphProperties);
run = new XElement(W + "r");
if (runProperties is not null) run.Add(runProperties);
paragraph.Add(run);
foreach (var extra in content.Elements(W + "p").Skip(1).ToList()) extra.Remove();
foreach (var node in content.Nodes().Where(n => n is XElement e && e.Name != W + "p").ToList()) node.Remove();
}
else
{
var firstRun = content.Descendants(W + "r").FirstOrDefault();
var runProperties = firstRun?.Element(W + "rPr") is { } rp ? new XElement(rp) : null;
content.RemoveNodes();
run = new XElement(W + "r");
if (runProperties is not null) run.Add(runProperties);
content.Add(run);
}
var lines = value.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').Split('\n');
for (var i = 0; i < lines.Length; i++)
{
if (i > 0) run.Add(new XElement(W + "br"));
run.Add(new XElement(W + "t", new XAttribute(XNamespace.Xml + "space", "preserve"), lines[i]));
}
}
private static IEnumerable<ZipArchiveEntry> WordXmlEntries(ZipArchive archive) =>
archive.Entries.Where(IsWordXmlEntry);
private static bool IsWordXmlEntry(ZipArchiveEntry entry) =>
entry.FullName.StartsWith("word/", StringComparison.OrdinalIgnoreCase)
&& entry.FullName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase);
private static string PartLabel(string path) => path switch
{
"word/document.xml" => "Dokumenttext",
var p when p.StartsWith("word/header", StringComparison.OrdinalIgnoreCase) => "einer Kopfzeile",
var p when p.StartsWith("word/footer", StringComparison.OrdinalIgnoreCase) => "einer Fußzeile",
_ => $"dem Dokumentteil „{Path.GetFileName(path)}“",
};
private static string? FindSuggestion(string unknown)
{
var candidate = SupportedPlaceholders
.Select(p => (p.Tag, Distance: Levenshtein(unknown.ToLowerInvariant(), p.Tag.ToLowerInvariant())))
.OrderBy(x => x.Distance)
.ThenBy(x => x.Tag)
.First();
var threshold = candidate.Tag.Length >= 12 ? 3 : 2;
return candidate.Distance <= threshold ? candidate.Tag : null;
}
private static int Levenshtein(string left, string right)
{
var previous = Enumerable.Range(0, right.Length + 1).ToArray();
for (var i = 1; i <= left.Length; i++)
{
var current = new int[right.Length + 1];
current[0] = i;
for (var j = 1; j <= right.Length; j++)
current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1),
previous[j - 1] + (left[i - 1] == right[j - 1] ? 0 : 1));
previous = current;
}
return previous[right.Length];
}
private List<LetterTemplateInfo> LoadIndex()
{
if (!File.Exists(_indexPath)) return [];
try
{
return JsonSerializer.Deserialize<List<LetterTemplateInfo>>(File.ReadAllText(_indexPath), JsonOptions) ?? [];
}
catch (JsonException)
{
return [];
}
}
private void SaveIndex(List<LetterTemplateInfo> templates)
{
var temporaryPath = _indexPath + ".tmp";
File.WriteAllText(temporaryPath, JsonSerializer.Serialize(templates, JsonOptions));
File.Move(temporaryPath, _indexPath, overwrite: true);
}
}
@@ -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,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,196 @@
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);
}
}
@@ -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];
}
+60
View File
@@ -0,0 +1,60 @@
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);
}
}
@@ -60,6 +60,65 @@ public sealed class DatabaseEncryptionServiceTests
Assert.Single(reopened.GetCollection<BsonDocument>("t").FindAll()); 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 sealed class TempDatabase : IDisposable
{ {
private readonly string _directory = System.IO.Path.Combine( private readonly string _directory = System.IO.Path.Combine(
+342 -1
View File
@@ -267,6 +267,8 @@ public sealed class RepositoryTests
repo.Save(new SeatingPlan repo.Save(new SeatingPlan
{ {
GroupId = group.Id, Name = "Standard", Room = "Physik", Rows = 2, Columns = 3, GroupId = group.Id, Name = "Standard", Room = "Physik", Rows = 2, Columns = 3,
IsBoardAtBottom = true,
ColumnGapWidths = [0, 90],
Assignments = [new SeatAssignment { Row = 1, Column = 2, StudentId = ben.Id }], Assignments = [new SeatAssignment { Row = 1, Column = 2, StudentId = ben.Id }],
}); });
@@ -274,7 +276,9 @@ public sealed class RepositoryTests
Assert.Equal(2, plans.Count); Assert.Equal(2, plans.Count);
Assert.Contains(plans, p => p.Room == "B204" && p.Assignments.Single().StudentId == anna.Id); Assert.Contains(plans, p => p.Room == "B204" && p.Assignments.Single().StudentId == anna.Id);
Assert.Contains(plans, p => p.Room == "Physik" && p.Assignments.Single().StudentId == ben.Id); Assert.Contains(plans, p => p.Room == "Physik" && p.IsBoardAtBottom
&& p.ColumnGapWidths.SequenceEqual([0, 90])
&& p.Assignments.Single().StudentId == ben.Id);
} }
[Fact] [Fact]
@@ -299,6 +303,90 @@ public sealed class RepositoryTests
Assert.Throws<InvalidOperationException>(() => new SeatingPlanRepository(db).Save(plan)); Assert.Throws<InvalidOperationException>(() => new SeatingPlanRepository(db).Save(plan));
} }
[Fact]
public void SeatingPlanRepository_FiltertAusgeblendetePlaetzeAusserhalbDesRasters()
{
using var db = NewInMemoryContext();
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
new GroupRepository(db).Save(group);
var plan = new SeatingPlan
{
GroupId = group.Id, Name = "Standard", Rows = 2, Columns = 2,
HiddenSeats =
[
new HiddenSeat { Row = 0, Column = 1 },
new HiddenSeat { Row = 5, Column = 5 }, // außerhalb des 2x2-Rasters
],
};
new SeatingPlanRepository(db).Save(plan);
var saved = db.SeatingPlans.FindById(plan.Id);
Assert.NotNull(saved);
var hidden = Assert.Single(saved.HiddenSeats);
Assert.Equal((0, 1), (hidden.Row, hidden.Column));
}
/// Ein ausgeblendeter Platz darf nie belegt sein - die Zuordnung eines Schülers ist wertvoller
/// als das Ausblenden. SeatingPlanTabViewModel verhindert das Belegen ausgeblendeter Plätze
/// bereits in der UI; diese Absicherung greift für Daten, die diesen Weg umgehen (z. B. Sync
/// von einem älteren Client).
[Fact]
public void SeatingPlanRepository_BlendetPlatzWiederEinWennErBelegtWird()
{
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);
db.Memberships.Insert(new GroupMembership { GroupId = group.Id, StudentId = student.Id });
var plan = new SeatingPlan
{
GroupId = group.Id, Name = "Standard", Rows = 2, Columns = 2,
HiddenSeats = [new HiddenSeat { Row = 0, Column = 0 }],
Assignments = [new SeatAssignment { Row = 0, Column = 0, StudentId = student.Id }],
};
new SeatingPlanRepository(db).Save(plan);
var saved = db.SeatingPlans.FindById(plan.Id);
Assert.NotNull(saved);
Assert.Empty(saved.HiddenSeats);
Assert.Equal(student.Id, saved.Assignments.Single().StudentId);
}
[Fact]
public void SeatingPlanRepository_AktualisiertPlanMitFehlendemOptionalenRaum()
{
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);
db.Memberships.Insert(new GroupMembership { GroupId = group.Id, StudentId = student.Id });
var plan = new SeatingPlan
{
GroupId = group.Id,
Name = "Standard",
Room = null!,
Rows = 2,
Columns = 2,
};
db.SeatingPlans.Insert(plan);
plan.Assignments.Add(new SeatAssignment { Row = 0, Column = 0, StudentId = student.Id });
new SeatingPlanRepository(db).Save(plan);
var saved = db.SeatingPlans.FindById(plan.Id);
Assert.NotNull(saved);
Assert.True(string.IsNullOrEmpty(saved.Room));
Assert.Equal(student.Id, saved.Assignments.Single().StudentId);
// LiteDB may materialize the empty optional value as null again. A later
// drag/drop save must therefore remain safe as well.
new SeatingPlanRepository(db).Save(saved);
}
[Fact] [Fact]
public void GroupMembershipRepository_Save_LehntZweiteZuordnungFuerGleichesPaarAb() public void GroupMembershipRepository_Save_LehntZweiteZuordnungFuerGleichesPaarAb()
{ {
@@ -652,6 +740,25 @@ public sealed class RepositoryTests
Assert.Equal(2, result[2].LessonNumber); Assert.Equal(2, result[2].LessonNumber);
} }
[Fact]
public void LessonRepository_DeleteRaeumtAnhaengeAusDerAttachmentAblageAuf()
{
using var db = NewInMemoryContext();
var repo = new LessonRepository(db);
var lesson = new Lesson
{
UnitId = Guid.NewGuid(), GroupId = Guid.NewGuid(),
Attachments = [new DocumentAttachment { StorageId = "gbu-1", FileName = "gbu.pdf" }],
};
db.Attachments.Upload("gbu-1", "gbu.pdf", new MemoryStream([1, 2, 3]));
repo.Save(lesson);
repo.Delete(lesson.Id);
Assert.Null(db.Lessons.FindById(lesson.Id));
Assert.False(db.Attachments.Exists("gbu-1"));
}
// ── ShorthandCodeRepository ─────────────────────────────────────────────── // ── ShorthandCodeRepository ───────────────────────────────────────────────
[Fact] [Fact]
@@ -847,4 +954,238 @@ public sealed class RepositoryTests
Assert.Single(result); Assert.Single(result);
Assert.Equal("Vertretung 8a", result[0].Description); Assert.Equal("Vertretung 8a", result[0].Description);
} }
[Fact]
public void SubstitutionEntryRepository_GetByExternalId_FindetAutomatischErzeugtenEintrag()
{
using var db = NewInMemoryContext();
var repo = new SubstitutionEntryRepository(db);
repo.Save(new SubstitutionEntry { Date = new DateOnly(2026, 3, 12), Kind = SubstitutionKind.Cancelled, PeriodNumber = 3, ExternalId = "38818-2004584-2004587" });
repo.Save(new SubstitutionEntry { Date = new DateOnly(2026, 3, 13), Kind = SubstitutionKind.Cancelled, PeriodNumber = 4 });
var result = repo.GetByExternalId("38818-2004584-2004587");
Assert.NotNull(result);
Assert.Equal(new DateOnly(2026, 3, 12), result!.Date);
Assert.Null(repo.GetByExternalId("unbekannt"));
}
// ── UntisSnapshotRepository / UntisSlotMappingRepository ─────────────────
[Fact]
public void UntisSnapshotRepository_SaveUndGetAll_RoundTrip()
{
using var db = NewInMemoryContext();
var repo = new UntisSnapshotRepository(db);
repo.Save(new UntisSnapshotEntry
{
Uid = "38926-2013812-2013815", Date = new DateOnly(2026, 8, 17),
StartTime = new TimeOnly(7, 50), EndTime = new TimeOnly(9, 20),
Summary = "SOL", Location = "Medien", Description = "10c HED",
});
var result = repo.GetAll();
Assert.Single(result);
Assert.Equal("38926-2013812-2013815", result[0].Uid);
Assert.Equal("SOL", result[0].Summary);
}
[Fact]
public void UntisSnapshotRepository_Save_AktualisiertVorhandenenEintrag()
{
using var db = NewInMemoryContext();
var repo = new UntisSnapshotRepository(db);
var entry = new UntisSnapshotEntry { Uid = "38926-2013812-2013815", Location = "Medien" };
repo.Save(entry);
entry.Location = "A1";
entry.Status = "CANCELLED";
repo.Save(entry);
var result = Assert.Single(repo.GetAll());
Assert.Equal("A1", result.Location);
Assert.Equal("CANCELLED", result.Status);
}
[Fact]
public void UntisSlotMappingRepository_SaveDeleteUndGetAll_RoundTrip()
{
using var db = NewInMemoryContext();
var repo = new UntisSlotMappingRepository(db);
var groupId = Guid.NewGuid();
var mapping = new UntisSlotMapping
{
Weekday = DayOfWeek.Monday, StartTime = new TimeOnly(7, 50),
Summary = "SOL", ClassToken = "10c", GroupId = groupId, Confirmed = true,
};
repo.Save(mapping);
Assert.Single(repo.GetAll());
repo.Delete(mapping.Id);
Assert.Empty(repo.GetAll());
}
// ── AnnualPlanEventRepository ────────────────────────────────────────────
[Fact]
public void AnnualPlanEventRepository_GetByRange_FindetAuchUeberlappendeMehrtagstermine()
{
using var db = NewInMemoryContext();
var repo = new AnnualPlanEventRepository(db);
repo.Save(new AnnualPlanEvent
{
ExternalId = "fahrt", Title = "Klassenfahrt",
StartDate = new DateOnly(2026, 6, 15), EndDate = new DateOnly(2026, 6, 19), IsAllDay = true,
});
repo.Save(new AnnualPlanEvent
{
ExternalId = "spaeter", Title = "Sommerfest",
StartDate = new DateOnly(2026, 7, 1), EndDate = new DateOnly(2026, 7, 1), IsAllDay = true,
});
var result = repo.GetByRange(new DateOnly(2026, 6, 17), new DateOnly(2026, 6, 17));
Assert.Single(result);
Assert.Equal("fahrt", result[0].ExternalId);
Assert.Equal("fahrt", repo.GetByExternalId("fahrt")!.ExternalId);
}
// ── UntisAbsenceCacheRepository / UntisClassRegisterCacheRepository / UntisCacheFetchStateRepository ──
[Fact]
public void UntisAbsenceCacheRepository_GetByClassAndRange_FiltertKlasseUndDatumsbereich()
{
using var db = NewInMemoryContext();
var repo = new UntisAbsenceCacheRepository(db);
repo.InsertRange(
[
new UntisAbsenceCacheEntry { ClassName = "10c", StudentName = "Max", Date = 20260824 },
new UntisAbsenceCacheEntry { ClassName = "10c", StudentName = "Max", Date = 20260901 },
new UntisAbsenceCacheEntry { ClassName = "10d", StudentName = "Ben", Date = 20260824 },
]);
var result = repo.GetByClassAndRange("10c", 20260801, 20260831);
Assert.Single(result);
Assert.Equal(20260824, result[0].Date);
}
[Fact]
public void UntisAbsenceCacheRepository_ReplaceRange_ErsetztNurDenAngegebenenBereich()
{
using var db = NewInMemoryContext();
var repo = new UntisAbsenceCacheRepository(db);
repo.InsertRange(
[
new UntisAbsenceCacheEntry { ClassName = "10c", StudentName = "Alt", Date = 20260810 },
new UntisAbsenceCacheEntry { ClassName = "10c", StudentName = "Alt", Date = 20260901 },
]);
repo.ReplaceRange("10c", 20260801, 20260831,
[new UntisAbsenceCacheEntry { ClassName = "10c", StudentName = "Neu", Date = 20260815 }]);
var inRange = repo.GetByClassAndRange("10c", 20260801, 20260831);
var untouched = repo.GetByClassAndRange("10c", 20260901, 20260901);
Assert.Equal("Neu", Assert.Single(inRange).StudentName);
Assert.Single(untouched);
}
[Fact]
public void UntisClassRegisterCacheRepository_InsertUndGetByClassAndRange_RoundTrip()
{
using var db = NewInMemoryContext();
var repo = new UntisClassRegisterCacheRepository(db);
repo.InsertRange([new UntisClassRegisterCacheEntry
{
ClassName = "10c", Date = 20260824, StudentName = "Max", Subject = "Deu",
TeacherUsername = "mueller", CategoryName = "Positiv",
}]);
var result = Assert.Single(repo.GetByClassAndRange("10c", 20260801, 20260831));
Assert.Equal("mueller", result.TeacherUsername);
}
[Fact]
public void UntisStudentRosterCacheRepository_ReplaceAll_ErsetztNurDieAngegebeneKlasse()
{
using var db = NewInMemoryContext();
var repo = new UntisStudentRosterCacheRepository(db);
repo.ReplaceAll("10c", [new UntisStudentRosterCacheEntry { ClassName = "10c", DisplayName = "Alt" }]);
repo.ReplaceAll("10d", [new UntisStudentRosterCacheEntry { ClassName = "10d", DisplayName = "Andere Klasse" }]);
repo.ReplaceAll("10c", [new UntisStudentRosterCacheEntry { ClassName = "10c", DisplayName = "Neu", ExternKey = 42 }]);
var tenC = repo.GetByClass("10c");
var tenD = repo.GetByClass("10d");
Assert.Equal("Neu", Assert.Single(tenC).DisplayName);
Assert.Equal(42, tenC[0].ExternKey);
Assert.Single(tenD); // andere Klasse bleibt unberührt
}
[Fact]
public void UntisCacheFetchStateRepository_SaveUndGet_RoundTripJeKlasseUndArt()
{
using var db = NewInMemoryContext();
var repo = new UntisCacheFetchStateRepository(db);
var fetchedAt = new DateTime(2026, 8, 25, 10, 0, 0, DateTimeKind.Utc);
repo.Save(new UntisCacheFetchState
{
ClassName = "10c", Kind = UntisCacheKind.Absences,
HotWindowFetchedAt = fetchedAt, ColdCoverageStartDate = 20260101,
});
var result = repo.Get("10c", UntisCacheKind.Absences);
var missing = repo.Get("10c", UntisCacheKind.ClassRegister);
Assert.NotNull(result);
// LiteDB rundet Kind Utc->Local, verschiebt dabei die Ticks um denselben Zeitpunkt zu
// erhalten (siehe CLAUDE.md) - deshalb erst beide Seiten auf UTC normalisieren.
Assert.Equal(fetchedAt.ToUniversalTime(), result!.HotWindowFetchedAt!.Value.ToUniversalTime());
Assert.Equal(20260101, result.ColdCoverageStartDate);
Assert.Null(missing);
}
// ── WorkTaskRepository ────────────────────────────────────────────────────
[Fact]
public void WorkTaskRepository_GetByGroup_FindetNurAufgabenDieserGruppe()
{
using var db = NewInMemoryContext();
var repo = new WorkTaskRepository(db);
var groupId = Guid.NewGuid();
repo.Save(new WorkTask { Title = "Klassenaufgabe", GroupId = groupId });
repo.Save(new WorkTask { Title = "Andere Gruppe", GroupId = Guid.NewGuid() });
repo.Save(new WorkTask { Title = "Ohne Gruppe", GroupId = null });
var result = repo.GetByGroup(groupId);
Assert.Single(result);
Assert.Equal("Klassenaufgabe", result[0].Title);
}
[Fact]
public void WorkTaskRepository_SaveUndLoad_RundetPrioritaetUndChecklistItemsRoundTrip()
{
using var db = NewInMemoryContext();
var repo = new WorkTaskRepository(db);
var studentId = Guid.NewGuid();
var task = new WorkTask
{
Title = "Material einsammeln",
Priority = TaskPriority.High,
ChecklistItems = [new ChecklistItem { Label = "Anna", StudentId = studentId, IsDone = true }],
};
repo.Save(task);
var loaded = repo.GetAll().Single(t => t.Id == task.Id);
Assert.Equal(TaskPriority.High, loaded.Priority);
var item = Assert.Single(loaded.ChecklistItems);
Assert.Equal("Anna", item.Label);
Assert.Equal(studentId, item.StudentId);
Assert.True(item.IsDone);
}
} }
+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")]
@@ -37,6 +37,29 @@ public class DatabaseEncryptionService
} }
} }
/// Ö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 /// Setzt (newPassword != null), ändert oder entfernt (newPassword == null) das
/// Passwort der Datenbank. Der Aufrufer muss sicherstellen, dass keine andere /// Passwort der Datenbank. Der Aufrufer muss sicherstellen, dass keine andere
/// Verbindung (z.B. der laufende <see cref="LiteDbContext"/>) die Datei offen hält. /// Verbindung (z.B. der laufende <see cref="LiteDbContext"/>) die Datei offen hält.
+165
View File
@@ -3,6 +3,10 @@ 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>
@@ -62,11 +66,162 @@ public class LiteDbContext : IDisposable
public ILiteCollection<SchoolHoliday> SchoolHolidays => _db.GetCollection<SchoolHoliday>("school_holidays"); public ILiteCollection<SchoolHoliday> SchoolHolidays => _db.GetCollection<SchoolHoliday>("school_holidays");
public ILiteCollection<SupervisionDuty> SupervisionDuties => _db.GetCollection<SupervisionDuty>("supervision_duties"); public ILiteCollection<SupervisionDuty> SupervisionDuties => _db.GetCollection<SupervisionDuty>("supervision_duties");
public ILiteCollection<SubstitutionEntry> SubstitutionEntries => _db.GetCollection<SubstitutionEntry>("substitution_entries"); 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<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(); 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) internal void ExecuteInTransaction(Action action)
{ {
_db.BeginTrans(); _db.BeginTrans();
@@ -381,6 +536,16 @@ public class LiteDbContext : IDisposable
SupervisionDuties.EnsureIndex("ux_supervision_weekday_period", SupervisionDuties.EnsureIndex("ux_supervision_weekday_period",
BsonExpression.Create("STRING($.Weekday) + ':' + STRING($.AfterPeriod)"), unique: true); BsonExpression.Create("STRING($.Weekday) + ':' + STRING($.AfterPeriod)"), unique: true);
SubstitutionEntries.EnsureIndex(x => x.Date); 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();
+331 -102
View File
@@ -35,7 +35,14 @@ public class StudentRepository(LiteDbContext db) : IStudentRepository
db.ReportGrades.Count(g => g.StudentId == studentId), db.ReportGrades.Count(g => g.StudentId == studentId),
db.ParticipationEntries.Count(e => e.StudentId == studentId), db.ParticipationEntries.Count(e => e.StudentId == studentId),
db.Documentation.Count(d => d.StudentId == studentId)); db.Documentation.Count(d => d.StudentId == studentId));
public void Save(Student s) { s.UpdatedAt = DateTime.UtcNow; db.Students.Upsert(s); } 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) public void Delete(Guid id)
{ {
var references = GetReferenceSummary(id); var references = GetReferenceSummary(id);
@@ -43,6 +50,7 @@ public class StudentRepository(LiteDbContext db) : IStudentRepository
throw new InvalidOperationException( throw new InvalidOperationException(
"Der Schüler besitzt verknüpfte Daten und kann nur deaktiviert werden."); "Der Schüler besitzt verknüpfte Daten und kann nur deaktiviert werden.");
db.Students.Delete(id); db.Students.Delete(id);
db.OnChange?.Invoke(nameof(Student), id.ToString(), "Delete", null);
} }
} }
@@ -73,81 +81,13 @@ public class GroupRepository(LiteDbContext db) : IGroupRepository
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)
{ {
ArchivedGroupWriteGuard.EnsureActive(db, id); ArchivedGroupWriteGuard.EnsureActive(db, id);
db.ExecuteInTransaction(() => db.CascadeDeleteGroup(id);
{ db.OnChange?.Invoke(nameof(LearningGroup), id.ToString(), "Delete", null);
foreach (var membership in db.Memberships.Find(e => e.GroupId == id).ToList())
db.Memberships.Delete(membership.Id);
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())
db.Grades.Delete(grade.Id);
foreach (var reportGrade in db.ReportGrades.Find(g => g.GroupId == id).ToList())
db.ReportGrades.Delete(reportGrade.Id);
foreach (var scheme in db.GradingSchemes.Find(s => s.GroupId == id).ToList())
db.GradingSchemes.Delete(scheme.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())
db.Lessons.Delete(lesson.Id);
db.Units.Delete(unit.Id);
}
foreach (var lesson in db.Lessons.Find(l => l.GroupId == id).ToList())
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())
db.ParticipationEntries.Delete(entry.Id);
db.ParticipationSessions.Delete(session.Id);
}
foreach (var aspect in db.ParticipationAspects.Find(a => a.GroupId == id).ToList())
db.ParticipationAspects.Delete(aspect.Id);
foreach (var section in db.ParticipationSections.Find(s => s.GroupId == id).ToList())
db.ParticipationSections.Delete(section.Id);
foreach (var plan in db.SeatingPlans.Find(p => p.GroupId == id).ToList())
db.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 db.Documentation.Find(d => d.GroupId == id).ToList())
{
documentation.GroupId = null;
documentation.UpdatedAt = DateTime.UtcNow;
db.Documentation.Update(documentation);
}
foreach (var task in db.Tasks.Find(t => t.GroupId == id).ToList())
{
task.GroupId = null;
task.UpdatedAt = DateTime.UtcNow;
db.Tasks.Update(task);
}
foreach (var timeEntry in db.TimeEntries.Find(t => t.GroupId == id).ToList())
{
timeEntry.GroupId = null;
db.TimeEntries.Update(timeEntry);
}
db.Groups.Delete(id);
});
} }
} }
@@ -162,8 +102,13 @@ public class SeatingPlanRepository(LiteDbContext db) : ISeatingPlanRepository
public void Save(SeatingPlan plan) public void Save(SeatingPlan plan)
{ {
ArchivedGroupWriteGuard.EnsureActive(db, plan.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, plan.GroupId);
plan.Name = plan.Name.Trim(); // LiteDB can deserialize missing/legacy optional string fields as null even
plan.Room = plan.Room.Trim(); // 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) if (plan.Name.Length == 0)
throw new ArgumentException("Der Name des Sitzplans darf nicht leer sein."); throw new ArgumentException("Der Name des Sitzplans darf nicht leer sein.");
if (plan.Rows is < 1 or > 10 || plan.Columns is < 1 or > 10) if (plan.Rows is < 1 or > 10 || plan.Columns is < 1 or > 10)
@@ -171,6 +116,12 @@ public class SeatingPlanRepository(LiteDbContext db) : ISeatingPlanRepository
if (db.Groups.FindById(plan.GroupId) is null) if (db.Groups.FindById(plan.GroupId) is null)
throw new InvalidOperationException("Die zugehörige Lerngruppe existiert nicht."); 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) var duplicateName = db.SeatingPlans.Find(p => p.GroupId == plan.GroupId)
.FirstOrDefault(p => p.Id != plan.Id .FirstOrDefault(p => p.Id != plan.Id
&& string.Equals(p.Name, plan.Name, StringComparison.OrdinalIgnoreCase) && string.Equals(p.Name, plan.Name, StringComparison.OrdinalIgnoreCase)
@@ -191,15 +142,36 @@ public class SeatingPlanRepository(LiteDbContext db) : ISeatingPlanRepository
if (plan.Assignments.Any(a => !memberIds.Contains(a.StudentId))) 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."); 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; plan.UpdatedAt = DateTime.UtcNow;
db.SeatingPlans.Upsert(plan); db.SeatingPlans.Upsert(plan);
db.OnChange?.Invoke(nameof(SeatingPlan), plan.Id.ToString(), "Save", plan);
} }
public void Delete(Guid id) public void Delete(Guid id)
{ {
if (db.SeatingPlans.FindById(id) is { } plan) if (db.SeatingPlans.FindById(id) is not { } plan) return;
ArchivedGroupWriteGuard.EnsureActive(db, plan.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, plan.GroupId);
db.MoveToTrash(nameof(SeatingPlan), id, plan,
string.IsNullOrWhiteSpace(plan.Room) ? plan.Name : $"{plan.Name} (Raum {plan.Room})");
db.SeatingPlans.Delete(id); db.SeatingPlans.Delete(id);
db.OnChange?.Invoke(nameof(SeatingPlan), id.ToString(), "Delete", null);
}
public void Restore(Guid trashId)
{
if (db.RestoreFromTrash<SeatingPlan>(trashId) is { } plan) Save(plan);
} }
} }
@@ -218,12 +190,14 @@ public class GroupMembershipRepository(LiteDbContext db) : IGroupMembershipRepos
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) public void Delete(Guid id)
{ {
if (db.Memberships.FindById(id) is { } membership) if (db.Memberships.FindById(id) is { } membership)
ArchivedGroupWriteGuard.EnsureActive(db, membership.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, membership.GroupId);
db.Memberships.Delete(id); db.Memberships.Delete(id);
db.OnChange?.Invoke(nameof(GroupMembership), id.ToString(), "Delete", null);
} }
} }
@@ -238,14 +212,14 @@ public class ExamRepository(LiteDbContext db) : IExamRepository
ArchivedGroupWriteGuard.EnsureActive(db, e.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, e.GroupId);
e.UpdatedAt = DateTime.UtcNow; e.UpdatedAt = DateTime.UtcNow;
db.Exams.Upsert(e); db.Exams.Upsert(e);
db.OnChange?.Invoke(nameof(Exam), e.Id.ToString(), "Save", e);
} }
public void Delete(Guid id) public void Delete(Guid id)
{ {
if (db.Exams.FindById(id) is { } exam) if (db.Exams.FindById(id) is { } exam)
ArchivedGroupWriteGuard.EnsureActive(db, exam.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, exam.GroupId);
foreach (var result in db.ExamResults.Find(r => r.ExamId == id).ToList()) db.CascadeDeleteExam(id);
db.ExamResults.Delete(result.Id); db.OnChange?.Invoke(nameof(Exam), id.ToString(), "Delete", null);
db.Exams.Delete(id);
} }
} }
@@ -263,6 +237,7 @@ public class ExamResultRepository(LiteDbContext db) : IExamResultRepository
ArchivedGroupWriteGuard.EnsureActive(db, exam.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, exam.GroupId);
r.UpdatedAt = DateTime.UtcNow; r.UpdatedAt = DateTime.UtcNow;
db.ExamResults.Upsert(r); 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)
{ {
@@ -272,6 +247,8 @@ public class ExamResultRepository(LiteDbContext db) : IExamResultRepository
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);
} }
} }
@@ -282,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
@@ -296,12 +289,21 @@ public class GradeRepository(LiteDbContext db) : IGradeRepository
{ {
ArchivedGroupWriteGuard.EnsureActive(db, g.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, g.GroupId);
db.Grades.Upsert(g); db.Grades.Upsert(g);
db.OnChange?.Invoke(nameof(Grade), g.Id.ToString(), "Save", g);
} }
public void Delete(Guid id) public void Delete(Guid id)
{ {
if (db.Grades.FindById(id) is { } grade) if (db.Grades.FindById(id) is not { } grade) return;
ArchivedGroupWriteGuard.EnsureActive(db, grade.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, grade.GroupId);
db.MoveToTrash(nameof(Grade), id, grade,
$"Note {grade.Value} ({grade.Date:dd.MM.yyyy})");
db.Grades.Delete(id); 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);
} }
} }
@@ -315,12 +317,14 @@ public class GradingSchemeRepository(LiteDbContext db) : IGradingSchemeRepositor
if (s.GroupId is Guid groupId) ArchivedGroupWriteGuard.EnsureActive(db, groupId); if (s.GroupId is Guid groupId) ArchivedGroupWriteGuard.EnsureActive(db, groupId);
s.UpdatedAt = DateTime.UtcNow; s.UpdatedAt = DateTime.UtcNow;
db.GradingSchemes.Upsert(s); db.GradingSchemes.Upsert(s);
db.OnChange?.Invoke(nameof(GradingScheme), s.Id.ToString(), "Save", s);
} }
public void Delete(Guid id) public void Delete(Guid id)
{ {
if (db.GradingSchemes.FindById(id)?.GroupId is Guid groupId) if (db.GradingSchemes.FindById(id)?.GroupId is Guid groupId)
ArchivedGroupWriteGuard.EnsureActive(db, groupId); ArchivedGroupWriteGuard.EnsureActive(db, groupId);
db.GradingSchemes.Delete(id); db.GradingSchemes.Delete(id);
db.OnChange?.Invoke(nameof(GradingScheme), id.ToString(), "Delete", null);
} }
} }
@@ -334,12 +338,14 @@ public class ReportGradeRepository(LiteDbContext db) : IReportGradeRepository
ArchivedGroupWriteGuard.EnsureActive(db, r.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, r.GroupId);
r.UpdatedAt = DateTime.UtcNow; r.UpdatedAt = DateTime.UtcNow;
db.ReportGrades.Upsert(r); db.ReportGrades.Upsert(r);
db.OnChange?.Invoke(nameof(ReportGrade), r.Id.ToString(), "Save", r);
} }
public void Delete(Guid id) public void Delete(Guid id)
{ {
if (db.ReportGrades.FindById(id) is { } grade) if (db.ReportGrades.FindById(id) is { } grade)
ArchivedGroupWriteGuard.EnsureActive(db, grade.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, grade.GroupId);
db.ReportGrades.Delete(id); db.ReportGrades.Delete(id);
db.OnChange?.Invoke(nameof(ReportGrade), id.ToString(), "Delete", null);
} }
} }
@@ -353,12 +359,14 @@ public class UnitRepository(LiteDbContext db) : IUnitRepository
ArchivedGroupWriteGuard.EnsureActive(db, u.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, u.GroupId);
u.UpdatedAt = DateTime.UtcNow; u.UpdatedAt = DateTime.UtcNow;
db.Units.Upsert(u); db.Units.Upsert(u);
db.OnChange?.Invoke(nameof(Unit), u.Id.ToString(), "Save", u);
} }
public void Delete(Guid id) public void Delete(Guid id)
{ {
if (db.Units.FindById(id) is { } unit) if (db.Units.FindById(id) is { } unit)
ArchivedGroupWriteGuard.EnsureActive(db, unit.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, unit.GroupId);
db.Units.Delete(id); db.Units.Delete(id);
db.OnChange?.Invoke(nameof(Unit), id.ToString(), "Delete", null);
} }
} }
@@ -376,12 +384,17 @@ public class LessonRepository(LiteDbContext db) : ILessonRepository
ArchivedGroupWriteGuard.EnsureActive(db, l.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, l.GroupId);
l.UpdatedAt = DateTime.UtcNow; l.UpdatedAt = DateTime.UtcNow;
db.Lessons.Upsert(l); db.Lessons.Upsert(l);
db.OnChange?.Invoke(nameof(Lesson), l.Id.ToString(), "Save", l);
} }
public void Delete(Guid id) public void Delete(Guid id)
{ {
if (db.Lessons.FindById(id) is { } lesson) if (db.Lessons.FindById(id) is { } lesson)
{
ArchivedGroupWriteGuard.EnsureActive(db, lesson.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, lesson.GroupId);
foreach (var attachment in lesson.Attachments) db.Attachments.Delete(attachment.StorageId);
}
db.Lessons.Delete(id); db.Lessons.Delete(id);
db.OnChange?.Invoke(nameof(Lesson), id.ToString(), "Delete", null);
} }
} }
@@ -394,7 +407,12 @@ public class DocumentationRepository(LiteDbContext db) : IDocumentationRepositor
.OrderByDescending(d => d.Date).ToList(); .OrderByDescending(d => d.Date).ToList();
public List<Documentation> GetAll() => public List<Documentation> GetAll() =>
db.Documentation.Find(d => !d.IsDeleted).OrderByDescending(d => d.Date).ToList(); db.Documentation.Find(d => !d.IsDeleted).OrderByDescending(d => d.Date).ToList();
public void Save(Documentation d) { d.UpdatedAt = DateTime.UtcNow; db.Documentation.Upsert(d); } 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) public void Delete(Guid id)
{ {
var doc = db.Documentation.FindById(id); var doc = db.Documentation.FindById(id);
@@ -402,13 +420,14 @@ public class DocumentationRepository(LiteDbContext db) : IDocumentationRepositor
doc.IsDeleted = true; doc.IsDeleted = true;
doc.DeletedAt = DateTime.UtcNow; doc.DeletedAt = DateTime.UtcNow;
db.Documentation.Update(doc); 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) public void HardDelete(Guid id)
{ {
var doc = db.Documentation.FindById(id); db.CascadeHardDeleteDocumentation(id);
if (doc is not null) db.OnChange?.Invoke(nameof(Documentation), id.ToString(), "Delete", null);
foreach (var attachment in doc.Attachments) db.Attachments.Delete(attachment.StorageId);
db.Documentation.Delete(id);
} }
} }
@@ -418,8 +437,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
@@ -430,8 +467,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
@@ -439,19 +492,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 List<ParticipationSession> GetAll() => db.ParticipationSessions.FindAll().ToList();
public void Save(ParticipationSession s) public void Save(ParticipationSession s)
{ {
ArchivedGroupWriteGuard.EnsureActive(db, s.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, s.GroupId);
s.UpdatedAt = DateTime.UtcNow; s.UpdatedAt = DateTime.UtcNow;
db.ParticipationSessions.Upsert(s); db.ParticipationSessions.Upsert(s);
db.OnChange?.Invoke(nameof(ParticipationSession), s.Id.ToString(), "Save", s);
} }
public void Delete(Guid id) public void Delete(Guid id)
{ {
if (db.ParticipationSessions.FindById(id) is { } session) if (db.ParticipationSessions.FindById(id) is { } session)
ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId);
db.ParticipationSessions.Delete(id); db.CascadeDeleteParticipationSession(id);
foreach (var e in db.ParticipationEntries.Find(e => e.SessionId == id).ToList()) db.OnChange?.Invoke(nameof(ParticipationSession), id.ToString(), "Delete", null);
db.ParticipationEntries.Delete(e.Id);
} }
} }
@@ -469,6 +523,7 @@ public class ParticipationRepository(LiteDbContext db) : IParticipationRepositor
ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId);
e.UpdatedAt = DateTime.UtcNow; e.UpdatedAt = DateTime.UtcNow;
db.ParticipationEntries.Upsert(e); 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)
{ {
@@ -478,13 +533,18 @@ public class ParticipationRepository(LiteDbContext db) : IParticipationRepositor
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) if (db.ParticipationSessions.FindById(sessionId) is { } session)
ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId); 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);
}
} }
} }
@@ -514,12 +574,14 @@ public class ParticipationAspectRepository(LiteDbContext db) : IParticipationAsp
throw new InvalidOperationException("Ein Aspekt mit diesem Schlüssel existiert für diese Gruppe bereits."); throw new InvalidOperationException("Ein Aspekt mit diesem Schlüssel existiert für diese Gruppe bereits.");
a.UpdatedAt = DateTime.UtcNow; a.UpdatedAt = DateTime.UtcNow;
db.ParticipationAspects.Upsert(a); db.ParticipationAspects.Upsert(a);
db.OnChange?.Invoke(nameof(ParticipationAspect), a.Id.ToString(), "Save", a);
} }
public void Delete(Guid id) public void Delete(Guid id)
{ {
if (db.ParticipationAspects.FindById(id)?.GroupId is Guid groupId) if (db.ParticipationAspects.FindById(id)?.GroupId is Guid groupId)
ArchivedGroupWriteGuard.EnsureActive(db, groupId); ArchivedGroupWriteGuard.EnsureActive(db, groupId);
db.ParticipationAspects.Delete(id); db.ParticipationAspects.Delete(id);
db.OnChange?.Invoke(nameof(ParticipationAspect), id.ToString(), "Delete", null);
} }
} }
@@ -531,12 +593,14 @@ public class ParticipationSectionRepository(LiteDbContext db) : IParticipationSe
{ {
ArchivedGroupWriteGuard.EnsureActive(db, s.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, s.GroupId);
db.ParticipationSections.Upsert(s); db.ParticipationSections.Upsert(s);
db.OnChange?.Invoke(nameof(ParticipationSection), s.Id.ToString(), "Save", s);
} }
public void Delete(Guid id) public void Delete(Guid id)
{ {
if (db.ParticipationSections.FindById(id) is { } section) if (db.ParticipationSections.FindById(id) is { } section)
ArchivedGroupWriteGuard.EnsureActive(db, section.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, section.GroupId);
db.ParticipationSections.Delete(id); db.ParticipationSections.Delete(id);
db.OnChange?.Invoke(nameof(ParticipationSection), id.ToString(), "Delete", null);
} }
} }
@@ -556,12 +620,14 @@ 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);
} }
} }
@@ -579,8 +645,13 @@ public class ShorthandCodeRepository(LiteDbContext db) : IShorthandCodeRepositor
throw new InvalidOperationException("Ein Kürzel mit diesem Code existiert bereits."); throw new InvalidOperationException("Ein Kürzel mit diesem Code existiert bereits.");
c.UpdatedAt = DateTime.UtcNow; c.UpdatedAt = DateTime.UtcNow;
db.ShorthandCodes.Upsert(c); 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 void Delete(Guid id) => db.ShorthandCodes.Delete(id);
} }
public class AlternativeLessonPathRepository(LiteDbContext db) : IAlternativeLessonPathRepository public class AlternativeLessonPathRepository(LiteDbContext db) : IAlternativeLessonPathRepository
@@ -598,8 +669,13 @@ public class AlternativeLessonPathRepository(LiteDbContext db) : IAlternativeLes
throw new InvalidOperationException("Ein alternativer Ablauf mit diesem Namen existiert bereits."); throw new InvalidOperationException("Ein alternativer Ablauf mit diesem Namen existiert bereits.");
p.UpdatedAt = DateTime.UtcNow; p.UpdatedAt = DateTime.UtcNow;
db.AlternativeLessonPaths.Upsert(p); 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 void Delete(Guid id) => db.AlternativeLessonPaths.Delete(id);
} }
public class TimetableSlotRepository(LiteDbContext db) : ITimetableSlotRepository public class TimetableSlotRepository(LiteDbContext db) : ITimetableSlotRepository
@@ -616,20 +692,30 @@ public class TimetableSlotRepository(LiteDbContext db) : ITimetableSlotRepositor
if (occupied is not null && occupied.Id != slot.Id) if (occupied is not null && occupied.Id != slot.Id)
throw new InvalidOperationException("Diese Stunde ist bereits belegt."); throw new InvalidOperationException("Diese Stunde ist bereits belegt.");
db.TimetableSlots.Upsert(slot); db.TimetableSlots.Upsert(slot);
db.OnChange?.Invoke(nameof(TimetableSlot), slot.Id.ToString(), "Save", slot);
} }
public void Delete(Guid id) public void Delete(Guid id)
{ {
if (db.TimetableSlots.FindById(id) is { } slot) if (db.TimetableSlots.FindById(id) is { } slot)
ArchivedGroupWriteGuard.EnsureActive(db, slot.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, slot.GroupId);
db.TimetableSlots.Delete(id); db.TimetableSlots.Delete(id);
db.OnChange?.Invoke(nameof(TimetableSlot), id.ToString(), "Delete", null);
} }
} }
public class SchoolHolidayRepository(LiteDbContext db) : ISchoolHolidayRepository public class SchoolHolidayRepository(LiteDbContext db) : ISchoolHolidayRepository
{ {
public List<SchoolHoliday> GetAll() => db.SchoolHolidays.FindAll().OrderBy(h => h.StartDate).ToList(); public List<SchoolHoliday> GetAll() => db.SchoolHolidays.FindAll().OrderBy(h => h.StartDate).ToList();
public void Save(SchoolHoliday holiday) => db.SchoolHolidays.Upsert(holiday); public void Save(SchoolHoliday holiday)
public void Delete(Guid id) => db.SchoolHolidays.Delete(id); {
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 class SupervisionDutyRepository(LiteDbContext db) : ISupervisionDutyRepository
@@ -643,8 +729,13 @@ public class SupervisionDutyRepository(LiteDbContext db) : ISupervisionDutyRepos
if (occupied is not null && occupied.Id != duty.Id) if (occupied is not null && occupied.Id != duty.Id)
throw new InvalidOperationException("Für diese Pause ist bereits eine Aufsicht eingetragen."); throw new InvalidOperationException("Für diese Pause ist bereits eine Aufsicht eingetragen.");
db.SupervisionDuties.Upsert(duty); 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 void Delete(Guid id) => db.SupervisionDuties.Delete(id);
} }
public class SubstitutionEntryRepository(LiteDbContext db) : ISubstitutionEntryRepository public class SubstitutionEntryRepository(LiteDbContext db) : ISubstitutionEntryRepository
@@ -652,8 +743,115 @@ public class SubstitutionEntryRepository(LiteDbContext db) : ISubstitutionEntryR
public List<SubstitutionEntry> GetAll() => db.SubstitutionEntries.FindAll().OrderBy(e => e.Date).ToList(); public List<SubstitutionEntry> GetAll() => db.SubstitutionEntries.FindAll().OrderBy(e => e.Date).ToList();
public List<SubstitutionEntry> GetByDate(DateOnly date) => public List<SubstitutionEntry> GetByDate(DateOnly date) =>
db.SubstitutionEntries.Find(e => e.Date == date).ToList(); db.SubstitutionEntries.Find(e => e.Date == date).ToList();
public void Save(SubstitutionEntry entry) => db.SubstitutionEntries.Upsert(entry); public SubstitutionEntry? GetByExternalId(string externalId) =>
public void Delete(Guid id) => db.SubstitutionEntries.Delete(id); 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 CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository
@@ -664,14 +862,26 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep
.OrderBy(d => d.SortOrder) .OrderBy(d => d.SortOrder)
.ToList(); .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) public void ReplaceForSubjectAndGrade(Guid subjectId, int gradeLevel, List<CompetencyDomain> domains)
{ {
@@ -679,7 +889,10 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep
{ {
foreach (var domain in db.CompetencyDomains foreach (var domain in db.CompetencyDomains
.Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel).ToList()) .Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel).ToList())
{
db.CompetencyDomains.Delete(domain.Id); db.CompetencyDomains.Delete(domain.Id);
db.OnChange?.Invoke(nameof(CompetencyDomain), domain.Id.ToString(), "Delete", null);
}
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
foreach (var domain in domains) foreach (var domain in domains)
{ {
@@ -687,7 +900,23 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep
domain.GradeLevel = gradeLevel; domain.GradeLevel = gradeLevel;
domain.UpdatedAt = now; domain.UpdatedAt = now;
db.CompetencyDomains.Upsert(domain); 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);
}
}
@@ -0,0 +1,46 @@
using LehrerApp.Desktop.Services;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class AnnualPlanSettingsServiceTests
{
private const string SampleUrl = "https://example.org/export.php?type=ics&token=intern";
private static string TempPath()
{
var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-annualplansettingssvc-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(path);
return path;
}
[Fact]
public void IcalUrl_WirdVerschluesseltPersistiertUndWiederGeladen()
{
var path = TempPath();
var service = new AnnualPlanSettingsService(path);
service.SetIcalUrl(SampleUrl);
service.SetEnabled(true);
var raw = File.ReadAllText(Path.Combine(path, "annual-plan-settings.json"));
var reloaded = new AnnualPlanSettingsService(path);
Assert.DoesNotContain("token=intern", raw);
Assert.Equal(SampleUrl, reloaded.GetIcalUrl());
Assert.True(reloaded.Enabled);
}
[Fact]
public void ClearIcalUrl_EntferntUrlUndDeaktiviertDenImport()
{
var service = new AnnualPlanSettingsService(TempPath());
service.SetIcalUrl(SampleUrl);
service.SetEnabled(true);
service.ClearIcalUrl();
Assert.False(service.IsConfigured);
Assert.False(service.Enabled);
Assert.Null(service.GetIcalUrl());
}
}
@@ -0,0 +1,73 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class AnnualPlanSyncServiceTests
{
private static string Calendar(params string[] events) =>
"BEGIN:VCALENDAR\nVERSION:2.0\n" + string.Join("", events) + "END:VCALENDAR\n";
private static string Event(string uid, string title, string date = "20260823") =>
$"BEGIN:VEVENT\nUID:{uid}\nDTSTART;VALUE=DATE:{date}\nSUMMARY:{title}\nEND:VEVENT\n";
private static AnnualPlanSyncService Build(FakeAnnualPlanEvents events) =>
new(new HttpClient(), TestSupport.BuildAnnualPlanSettingsService(), events);
[Fact]
public void ProcessIcsText_LegtAn_AktualisiertIdempotent_UndEntferntVerschwundeneTermine()
{
var events = new FakeAnnualPlanEvents();
using var service = Build(events);
var first = service.ProcessIcsText(Calendar(Event("1", "Konferenz"), Event("2", "Messe")));
var originalId = events.GetByExternalId("1")!.Id;
var unchanged = service.ProcessIcsText(Calendar(Event("1", "Konferenz"), Event("2", "Messe")));
var changed = service.ProcessIcsText(Calendar(Event("1", "Konferenz verschoben", "20260824")));
Assert.Equal((2, 0, 0), (first.Added, first.Updated, first.Deleted));
Assert.Equal(0, unchanged.ChangeCount);
Assert.Equal((0, 1, 1), (changed.Added, changed.Updated, changed.Deleted));
Assert.Equal(originalId, events.GetByExternalId("1")!.Id);
Assert.Null(events.GetByExternalId("2"));
}
[Fact]
public void ProcessIcsText_DefekterTeilimport_VeraendertBestehendenBestandNicht()
{
var events = new FakeAnnualPlanEvents();
events.Add(new AnnualPlanEvent
{
ExternalId = "bestand", Title = "Bestehend",
StartDate = new DateOnly(2026, 8, 23), EndDate = new DateOnly(2026, 8, 23), IsAllDay = true,
});
using var service = Build(events);
var broken = Calendar(Event("neu", "Neu"),
"BEGIN:VEVENT\nUID:defekt\nSUMMARY:Ohne Datum\nEND:VEVENT\n");
Assert.Throws<FormatException>(() => service.ProcessIcsText(broken));
var remaining = Assert.Single(events.GetAll());
Assert.Equal("bestand", remaining.ExternalId);
}
[Fact]
public void Clear_EntferntDenLokalenFeedCache()
{
var events = new FakeAnnualPlanEvents();
events.Add(new AnnualPlanEvent
{
ExternalId = "1", StartDate = new DateOnly(2026, 8, 23),
EndDate = new DateOnly(2026, 8, 23), IsAllDay = true,
});
using var service = Build(events);
var notified = false;
service.DataChanged += () => notified = true;
service.Clear();
Assert.Empty(events.GetAll());
Assert.True(notified);
}
}
@@ -0,0 +1,62 @@
using LehrerApp.Desktop.Services;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class AppearanceSettingsServiceTests
{
private static string BuildTempPath()
{
var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-appearance-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(path);
return path;
}
[Fact]
public void Load_OhneVorhandeneDatei_LiefertSystemvorgabe() =>
Assert.Equal(AppTheme.System, new AppearanceSettingsService(BuildTempPath()).Load());
[Theory]
[InlineData(AppTheme.Light)]
[InlineData(AppTheme.Dark)]
[InlineData(AppTheme.System)]
public void SaveUndLoad_PersistiertUeberNeueInstanz(AppTheme theme)
{
var path = BuildTempPath();
new AppearanceSettingsService(path).Save(theme);
var reloaded = new AppearanceSettingsService(path).Load();
Assert.Equal(theme, reloaded);
}
[Fact]
public void Load_BeschaedigteDatei_FaelltAufSystemvorgabeZurueck()
{
var path = BuildTempPath();
File.WriteAllText(Path.Combine(path, "appearancesettings.json"), "{ kein json");
Assert.Equal(AppTheme.System, new AppearanceSettingsService(path).Load());
}
}
public sealed class AppThemeDisplayTests
{
[Theory]
[InlineData(AppTheme.System, "Systemvorgabe")]
[InlineData(AppTheme.Light, "Hell")]
[InlineData(AppTheme.Dark, "Dunkel")]
public void Label_LiefertDeutscheBeschriftung(AppTheme theme, string expected) =>
Assert.Equal(expected, AppThemeDisplay.Label(theme));
[Fact]
public void FromLabel_UndLabel_SindZueinanderInvers()
{
foreach (var theme in Enum.GetValues<AppTheme>())
Assert.Equal(theme, AppThemeDisplay.FromLabel(AppThemeDisplay.Label(theme)));
}
[Fact]
public void FromLabel_UnbekanntesLabel_LiefertSystemvorgabe() =>
Assert.Equal(AppTheme.System, AppThemeDisplay.FromLabel("Nonsens"));
}
+7
View File
@@ -0,0 +1,7 @@
using Xunit;
// Gleicher Grund wie in LehrerApp.Data.Tests/AssemblyInfo.cs: LiteDBs geteilter, statischer
// BsonMapper.Global verträgt keine parallele Erstzuordnung von Typ-Metadaten über mehrere
// Testklassen hinweg (SettingsViewModelTests/SyncStatusViewModelTests konstruieren beide
// LiteDbContext).
[assembly: CollectionBehavior(DisableTestParallelization = true)]
@@ -0,0 +1,587 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class ClassTeacherViewModelsTests
{
[Fact]
public void GroupByStudentAndDay_FasstFehlstundenProSchuelerUndTagZusammen()
{
var entries = new[]
{
new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260824, 1, 45, "HED", "Che", "Absent",
"Krank", 1, "24.08.26", true, null, 3, "nicht entsch.", false),
new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260824, 1, 45, "PAN", "Kunst", null,
"Krank", 2, "24.08.26", true, null, 5, "nicht entsch.", true),
new UntisClassAbsenceEntryDto("Andere Schuelerin", 2, "6a", 20260824, 1, 20, "BEN", "Deu",
"Verspätung", null, 3, "24.08.26", false, "Zug verpasst", 1, "entsch.", false),
};
var rows = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(entries);
Assert.Equal(2, rows.Count);
var max = rows[1];
Assert.Equal("Muster Max", max.StudentName);
Assert.Equal(2, max.TotalAbsentPeriods);
Assert.Equal(90, max.TotalAbsentMinutes);
Assert.Equal(["Che", "Kunst"], max.Subjects);
Assert.Equal([3, 5], max.PeriodNumbers.Order());
Assert.Equal(["nicht entsch."], max.Statuses);
Assert.Equal(["Absent"], max.AbsenceReasons);
Assert.Equal("Krank", max.Note);
Assert.Null(max.ExcuseNote);
Assert.True(max.CountsAsFullDay);
var andere = rows[0];
Assert.Equal("Andere Schuelerin", andere.StudentName);
Assert.Equal(1, andere.TotalAbsentPeriods);
Assert.Equal(20, andere.TotalAbsentMinutes);
Assert.Equal(["Deu"], andere.Subjects);
Assert.Null(andere.Note);
Assert.Equal("Zug verpasst", andere.ExcuseNote);
Assert.False(andere.CountsAsFullDay);
}
[Fact]
public void GroupByStudentAndDay_TrenntGleichenNamenAnUnterschiedlichenTagen()
{
var entries = new[]
{
new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260824, 1, 45, "HED", "Che", null,
null, 1, "24.08.26", true, null, 3, "nicht entsch.", false),
new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260825, 1, 45, "HED", "Che", null,
null, 2, "25.08.26", true, null, 3, "nicht entsch.", false),
};
var rows = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(entries);
Assert.Equal(2, rows.Count);
Assert.Equal(new DateOnly(2026, 8, 25), rows[0].Date);
Assert.Equal(new DateOnly(2026, 8, 24), rows[1].Date);
}
[Fact]
public void RosterBuild_OrdnetFehlzeitPerExternKeyUndKlassenbuchPerNameZu()
{
var students = new[]
{
Student(1001, "Ada Müller"),
Student(1002, "Ben Schmidt"),
Student(1003, "Cem Yilmaz"),
};
var todayAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Ada Müller", 1001, 2, 90,
["Che"], [3, 4], ["nicht entsch."], ["Absent"], null, null, false),
};
var recentClassRegister = new[]
{
new UntisForeignClassRegisterEventDto("6a", 20260824, "Deu", "Ben Schmidt", "mueller",
"Vergessen", "Organisation", "Buch vergessen"),
};
var roster = ClassTeacherRosterRow.Build(students, todayAbsences, recentClassRegister);
Assert.Equal(3, roster.Count);
var ada = Assert.Single(roster, r => r.StudentName == "Ada Müller");
Assert.True(ada.HasAbsenceToday);
Assert.Contains("nicht entsch.", ada.AbsenceTooltip);
Assert.False(ada.HasRecentClassRegisterEntry);
var ben = Assert.Single(roster, r => r.StudentName == "Ben Schmidt");
Assert.False(ben.HasAbsenceToday);
Assert.True(ben.HasRecentClassRegisterEntry);
var cem = Assert.Single(roster, r => r.StudentName == "Cem Yilmaz");
Assert.False(cem.HasAbsenceToday);
Assert.False(cem.HasRecentClassRegisterEntry);
}
[Fact]
public void RosterBuild_ErkenntNamenAuchInVertauschterReihenfolge()
{
// WebUntis liefert Namen im Schülerreport als "Vorname Nachname", in Fehlzeiten-/
// Klassenbuchberichten aber typischerweise als "Nachname Vorname" - der Namensabgleich
// muss das auch ohne ExternKey erkennen (Regressionstest für genau diesen Bug).
var students = new[] { Student(externKey: null, displayName: "Ben Schmidt") };
var todayAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Schmidt Ben", null, 1, 45,
["Che"], [3], ["nicht entsch."], ["Absent"], null, null, false),
};
var recentClassRegister = new[]
{
new UntisForeignClassRegisterEventDto("6a", 20260824, "Deu", "Schmidt Ben", "mueller",
"Vergessen", "Organisation", "Buch vergessen"),
};
var roster = ClassTeacherRosterRow.Build(students, todayAbsences, recentClassRegister);
var ben = Assert.Single(roster);
Assert.True(ben.HasAbsenceToday);
Assert.True(ben.HasRecentClassRegisterEntry);
}
[Fact]
public void RosterBuild_BeschreibtVerspaetungNichtAlsNullFehlstunden()
{
var students = new[] { Student(1001, "Ada Müller") };
var todayAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 26), "Müller Ada", 1001, 0, 20,
["Deu"], [1], ["nicht entsch."], ["Verspätung"], null, null, false),
};
var row = Assert.Single(ClassTeacherRosterRow.Build(students, todayAbsences, [],
new DateOnly(2026, 8, 26)));
Assert.True(row.IsLate);
Assert.True(row.IsUnexcused);
Assert.Equal("20 Min. verspätet · Unentschuldigt", row.StatusText);
Assert.Equal(ClassTeacherStatusKind.Danger, row.StatusKind);
}
[Fact]
public void RosterBuild_PriorisiertHandlungsbedarfVorUnauffaelligenSchuelern()
{
var students = new[]
{
Student(1001, "Zora Unauffällig"),
Student(1002, "Ada Fehlzeit"),
Student(1003, "Ben Klassenbuch"),
};
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 26), "Fehlzeit Ada", 1002, 2, 90,
["Deu"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
};
var register = new[]
{
new UntisForeignClassRegisterEventDto("10c", 20260825, "Deu", "Klassenbuch Ben", "test",
"Fehlende HA", "Negativ", "Hausaufgaben fehlen"),
};
var rows = ClassTeacherRosterRow.Build(students, absences, register, new DateOnly(2026, 8, 26));
Assert.Equal(["Ada Fehlzeit", "Ben Klassenbuch", "Zora Unauffällig"],
rows.Select(r => r.StudentName));
}
[Fact]
public void RosterBuild_BerechnetFehlquoteSeitSchuljahresbeginn()
{
var students = new[] { Student(1001, "Ada Müller") };
var yearAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10), "Müller Ada", 1001, 2, 90,
["Che"], [1, 2], ["entsch."], ["Krank"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 17), "Müller Ada", 1001, 6, 270,
["Che"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
};
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [],
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20,
termStart: new DateOnly(2026, 8, 1)));
Assert.True(row.HasYearSummary);
Assert.Equal(2, row.YearAbsenceDayCount);
Assert.Equal(1, row.YearUnexcusedDayCount);
Assert.Equal(10, row.YearAbsenceRatePercent);
Assert.Contains("10 %", row.YearSummaryLabel);
Assert.Contains("2 von 20", row.YearSummaryTooltip);
Assert.Contains("seit 01.08.", row.YearSummaryTooltip);
Assert.Contains("1 unentschuldigt", row.YearSummaryTooltip);
}
[Fact]
public void RosterBuild_ZeigtAbgezogeneFerientageImTooltip()
{
// Nutzer-Nachfrage: die Herkunft des Nenners soll sich ohne Blick in die WebUntis-Ferienliste
// direkt in der App nachvollziehen lassen, nachdem er zeitweise Ferientage mitzählte.
var students = new[] { Student(1001, "Ada Müller") };
var yearAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 17), "Müller Ada", 1001, 2, 90,
["Che"], [1, 2], ["entsch."], ["Krank"], null, null, false),
};
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [],
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 14, holidayWeekdaysExcluded: 7,
termStart: new DateOnly(2026, 8, 1)));
Assert.Contains("7 Ferientage abgezogen", row.YearSummaryTooltip);
}
[Fact]
public void RosterBuild_OhneJahresdatenZeigtKeineFehlquote()
{
var students = new[] { Student(1001, "Ada Müller") };
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], new DateOnly(2026, 8, 26)));
Assert.False(row.HasYearSummary);
Assert.Equal("", row.YearSummaryLabel);
Assert.Null(row.YearSummaryTooltip);
}
[Fact]
public void RosterBuild_OrdnetJahresfehlzeitenAuchOhneExternKeyUeberNamenZu()
{
var students = new[] { Student(externKey: null, displayName: "Ben Schmidt") };
var yearAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10), "Schmidt Ben", null, 2, 90,
["Che"], [1, 2], ["entsch."], ["Krank"], null, null, false),
};
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], new DateOnly(2026, 8, 26),
yearAbsences, schoolDaysElapsed: 10));
Assert.Equal(1, row.YearAbsenceDayCount);
}
[Fact]
public void TrendDay_AnteileSummierenSichZurGesamtquote()
{
var day = new ClassTeacherTrendDay("Mo", 5, 2, 1, 2, 0.4d, 0.2d, 0.4d);
Assert.Equal(1.0d, day.TotalFraction, precision: 10);
}
[Fact]
public void RosterBuild_StelltGefahrUndWarnungMitSymbolVoran()
{
var students = new[] { Student(1001, "Ada Müller"), Student(1002, "Ben Schmidt") };
var todayAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 26), "Müller Ada", 1001, 2, 90,
["Che"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 26), "Schmidt Ben", 1002, 2, 90,
["Che"], [1, 2], ["entsch."], ["Absent"], null, null, false),
};
var roster = ClassTeacherRosterRow.Build(students, todayAbsences, [], new DateOnly(2026, 8, 26));
var ada = Assert.Single(roster, r => r.StudentName == "Ada Müller");
Assert.Equal(ClassTeacherStatusKind.Danger, ada.StatusKind);
Assert.StartsWith("✕ ", ada.StatusTextWithGlyph);
var ben = Assert.Single(roster, r => r.StudentName == "Ben Schmidt");
Assert.Equal(ClassTeacherStatusKind.Warning, ben.StatusKind);
Assert.StartsWith("△ ", ben.StatusTextWithGlyph);
}
[Fact]
public void RosterBuild_LaesstUnauffaelligenStatusOhneSymbol()
{
var students = new[] { Student(1001, "Zora Unauffällig") };
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], new DateOnly(2026, 8, 26)));
Assert.Equal(row.StatusText, row.StatusTextWithGlyph);
}
// ── Nenner der Jahresfehlquote: echter WebUntis-Ferienkalender statt grober Werktagszählung ──
[Fact]
public void CountSchoolWeekdays_SchliesstFerientageAus()
{
// 1.-31.08.2026 hat 21 Werktage; die (fiktiven) Sommerferien bis 11.08. nehmen davon 7
// Werktage weg (Mo 03. - Fr 07., Mo 10. - Di 11.) - Nutzer-Feedback: Schüler, die seit
// Unterrichtsbeginn jeden Tag fehlten, kamen wegen dieser mitgezählten Ferienzeit nur auf
// ~57 % statt ~100 % Fehlquote.
var holidays = new[] { new CachedUntisHoliday("Sommerferien", new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 11)) };
var schoolDays = ClassTeacherOverviewViewModel.CountSchoolWeekdays(
new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), holidays);
Assert.Equal(14, schoolDays);
}
[Fact]
public void CountSchoolWeekdays_OhneFerienVerhaeltSichWieReineWerktagszaehlung()
{
var schoolDays = ClassTeacherOverviewViewModel.CountSchoolWeekdays(
new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), []);
Assert.Equal(21, schoolDays);
}
[Fact]
public void EstimateTermStart_NutztFruehestenFehlzeitEintragDerGanzenKlasse()
{
// Realer Befund (Nutzer-Feedback): WebUntis' getHolidays liefert für Bremen nie einen
// Sommerferien-Eintrag (über 11 Jahre Kontohistorie geprüft, kein einziger Juli-/August-
// Zeitraum dabei) - vermutlich weil die Sommerferien WebUntis-intern zwischen zwei
// Schuljahres-Datensätzen liegen (1.8./31.7.-Grenze), nicht "in" einem davon. Für den
// Schuljahresbeginn bleibt deshalb weiterhin der früheste Fehlzeiten-Eintrag der Klasse
// nötig statt CountSchoolWeekdays allein.
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 13), "Fehlt Cem", 1001, 1, 45,
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 19), "Andere Ada", 1002, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
};
var termStart = ClassTeacherOverviewViewModel.EstimateTermStart(absences, new DateOnly(2026, 8, 1));
Assert.Equal(new DateOnly(2026, 8, 13), termStart);
}
[Fact]
public void EstimateTermStart_FaelltOhneFehlzeitenAufSchuljahresbeginnZurueck()
{
var termStart = ClassTeacherOverviewViewModel.EstimateTermStart([], new DateOnly(2026, 8, 1));
Assert.Equal(new DateOnly(2026, 8, 1), termStart);
}
[Fact]
public void CountSchoolWeekdays_AbEchtemTerminstartOhneSommerferienEintragStimmtMitBeobachtungUeberein()
{
// Regressionstest für den konkret gemeldeten Fall: 13 tatsächliche Schultage seit
// Unterrichtsbeginn (13.08.2026, ein Donnerstag) bis 31.08.2026, keine WebUntis-Ferien im
// Bereich (die nächste, "Herbstferien", liegt erst im Oktober) - Kombination aus
// EstimateTermStart (Sommerferien-Lücke) und CountSchoolWeekdays (übrige Ferien) muss auf
// die vom Nutzer nachgezählte Zahl kommen, nicht auf die volle Werktagszahl ab 1.8. (21).
var termStart = new DateOnly(2026, 8, 13);
var noHolidaysInRange = Array.Empty<CachedUntisHoliday>();
var schoolDays = ClassTeacherOverviewViewModel.CountSchoolWeekdays(
termStart, new DateOnly(2026, 8, 31), noHolidaysInRange);
Assert.Equal(13, schoolDays);
}
// ── Offene Entschuldigungen (Feature-Idee 2) ─────────────────────────────
[Fact]
public void OpenExcuseBuild_SortiertNachTagenOffenAbsteigendUndIgnoriertEntschuldigte()
{
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 20), "Alt Ada", 1001, 2, 90,
["Che"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Neu Ben", 1002, 1, 45,
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Entschuldigt Cem", 1003, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
};
var rows = ClassTeacherOpenExcuseRow.Build(absences, new DateOnly(2026, 8, 26));
// StudentName kommt aus StudentDisplayName (WebUntis liefert "Nachname Vorname",
// hier für die Anzeige auf "Vorname Nachname" gedreht - siehe ClassAbsenceDaySummaryRow).
Assert.Equal(["Ada Alt", "Ben Neu"], rows.Select(r => r.StudentName));
Assert.Equal(6, rows[0].DaysOpen);
Assert.True(rows[0].IsOverdue);
Assert.Equal(1, rows[1].DaysOpen);
Assert.False(rows[1].IsOverdue);
}
[Fact]
public void OpenExcuseBuild_IgnoriertUnentschuldigteVerspaetungen()
{
// Verspätungen sind i.d.R. nicht entschuldigungsfähig - "unentschuldigt" darf hier keine
// Erinnerung an eine fehlende Entschuldigung auslösen (Nutzer-Feedback).
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Neu Ben", 1002, 0, 15,
["Deu"], [1], ["nicht entsch."], ["Verspätung"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Fehlt Cem", 1003, 1, 45,
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
};
var rows = ClassTeacherOpenExcuseRow.Build(absences, new DateOnly(2026, 8, 26));
Assert.Equal(["Cem Fehlt"], rows.Select(r => r.StudentName));
}
// ── Verspätungsmuster: Elterngespräch-Eskalation (Nutzer-Feedback) ───────
[Fact]
public void DetectLatePatterns_EskaliertBeiHoherJahressummeMitWiedervorlageOption()
{
var absences = Enumerable.Range(0, 5)
.Select(i => new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 3 + i), "Spaet Timo", 1001, 0, 10,
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false))
.ToArray();
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Spaet Timo")] = "Timo Spät" };
var notices = ClassTeacherOverviewViewModel.DetectLatePatterns(
absences, names, sevenDayStart: new DateOnly(2026, 8, 31));
var notice = Assert.Single(notices);
Assert.Equal("Timo Spät", notice.StudentName);
Assert.Contains("5 Verspätungen seit Schuljahresbeginn", notice.Message);
Assert.Equal(ClassTeacherStatusKind.Danger, notice.Kind);
Assert.True(notice.CanCreateReminder);
}
[Fact]
public void DetectLatePatterns_KurzfristigesClusterBleibtHinweisOhneAktion()
{
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Spaet Timo", 1001, 0, 10,
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Spaet Timo", 1001, 0, 10,
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
};
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Spaet Timo")] = "Timo Spät" };
var notices = ClassTeacherOverviewViewModel.DetectLatePatterns(
absences, names, sevenDayStart: new DateOnly(2026, 8, 20));
var notice = Assert.Single(notices);
Assert.Equal("2-mal verspätet in 7 Tagen", notice.Message);
Assert.Equal(ClassTeacherStatusKind.Warning, notice.Kind);
Assert.False(notice.CanCreateReminder);
}
[Fact]
public void DetectLatePatterns_UnentschuldigteFehltageGehenVorVerspaetungseskalation()
{
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Fehlt Cem", 1001, 1, 45,
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Fehlt Cem", 1001, 1, 45,
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
};
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Fehlt Cem")] = "Cem Fehlt" };
var notices = ClassTeacherOverviewViewModel.DetectLatePatterns(
absences, names, sevenDayStart: new DateOnly(2026, 8, 20));
var notice = Assert.Single(notices);
Assert.Contains("unentschuldigte Fehltage", notice.Message);
Assert.Equal(ClassTeacherStatusKind.Danger, notice.Kind);
Assert.False(notice.CanCreateReminder);
}
// ── Aggregierte Klassenbuch-Kategorien (Feature-Idee 5) ──────────────────
[Fact]
public void CategoryAggregateBuild_ZaehltProKategorieUndNenntTopSchueler()
{
var entries = new[]
{
new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 20), "Deu", "Schmidt Ben", "test",
"Hausaufgaben fehlen", "Negativ", "HA fehlt"),
new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 21), "Deu", "Schmidt Ben", "test",
"Hausaufgaben fehlen", "Negativ", "HA fehlt"),
new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 22), "Che", "Müller Ada", "test",
"Hausaufgaben fehlen", "Negativ", "HA fehlt"),
new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 23), "Che", "Müller Ada", "test",
"Lob", "Positiv", "Gute Mitarbeit"),
};
var rows = ClassTeacherCategoryAggregateRow.Build(entries);
Assert.Equal(["Hausaufgaben fehlen", "Lob"], rows.Select(r => r.CategoryName));
var homework = rows[0];
Assert.Equal(3, homework.Count);
Assert.Contains("2× Ben Schmidt", homework.TopStudentsLabel);
Assert.Contains("1× Ada Müller", homework.TopStudentsLabel);
}
// ── Erweiterte Mustererkennung: Wochentags-Häufung (Feature-Idee 4) ──────
[Fact]
public void DetectWeekdayPatterns_ErkenntDeutlicheHaeufungAnEinemWochentag()
{
// 24.08./31.08.2026 und 07.09.2026 sind allesamt Montage.
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 31), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 7), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
};
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Muster Mona")] = "Mona Muster" };
var notices = ClassTeacherOverviewViewModel.DetectWeekdayPatterns(
absences, names, new HashSet<string>());
var notice = Assert.Single(notices);
Assert.Equal("Mona Muster", notice.StudentName);
Assert.Contains("Montag", notice.Message);
Assert.Equal(ClassTeacherStatusKind.Info, notice.Kind);
}
[Fact]
public void DetectWeekdayPatterns_IgnoriertZuKleineStichprobe()
{
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 31), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
};
var notices = ClassTeacherOverviewViewModel.DetectWeekdayPatterns(
absences, new Dictionary<string, string>(), new HashSet<string>());
Assert.Empty(notices);
}
[Fact]
public void DetectWeekdayPatterns_LaesstBereitsGemeldeteSchuelerAus()
{
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 31), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 7), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
};
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Muster Mona")] = "Mona Muster" };
var notices = ClassTeacherOverviewViewModel.DetectWeekdayPatterns(
absences, names, new HashSet<string> { "Mona Muster" });
Assert.Empty(notices);
}
// ── Noten-/Mitarbeit-Brücke: Namensabgleich (Feature-Idee 7) ─────────────
[Fact]
public void MatchStudent_FindetUeberVorUndNachnameTrotzKommaInFullName()
{
// Student.FullName liefert "Nachname, Vorname" (mit Komma) - ein Abgleich darüber würde
// UntisNameMatching.NameKey verfälschen ("Müller," bliebe eigenes Wort). MatchStudent
// muss deshalb FirstName/LastName getrennt verwenden, nicht FullName.
var students = new List<Student>
{
new() { FirstName = "Ada", LastName = "Müller" },
new() { FirstName = "Ben", LastName = "Schmidt" },
};
var match = ClassTeacherOverviewViewModel.MatchStudent("Müller Ada", students);
Assert.NotNull(match);
Assert.Equal("Ada", match!.FirstName);
}
[Fact]
public void MatchStudent_LiefertNullOhneTreffer()
{
var students = new List<Student> { new() { FirstName = "Ada", LastName = "Müller" } };
Assert.Null(ClassTeacherOverviewViewModel.MatchStudent("Unbekannt Xyz", students));
}
private static UntisStudentRosterCacheEntry Student(int? externKey, string displayName) =>
new() { ClassName = "6a", ExternKey = externKey, DisplayName = displayName };
}
@@ -1,7 +1,6 @@
using System.IO.Compression;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Students; using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Templating;
using Xunit; using Xunit;
namespace LehrerApp.Desktop.Tests; namespace LehrerApp.Desktop.Tests;
@@ -9,16 +8,12 @@ namespace LehrerApp.Desktop.Tests;
public sealed class CreateLetterDialogViewModelTests : IDisposable public sealed class CreateLetterDialogViewModelTests : IDisposable
{ {
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-letter-vm-tests-{Guid.NewGuid():N}"); private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-letter-vm-tests-{Guid.NewGuid():N}");
public CreateLetterDialogViewModelTests() => Directory.CreateDirectory(_directory); public CreateLetterDialogViewModelTests() => Directory.CreateDirectory(_directory);
[Fact] [Fact]
public void OhneVorlage_KannKeinenBriefErzeugen() public void OhneVorlage_KannKeinenBriefErzeugen()
{ {
var student = StudentWithContact("Sehr geehrte Frau Muster,"); var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), new TemplateStore(_directory));
var vm = Build(student, new LetterTemplateService(_directory));
Assert.False(vm.CanGenerate); Assert.False(vm.CanGenerate);
Assert.Contains(vm.Issues, i => i.Message.Contains("Vorlage", StringComparison.OrdinalIgnoreCase)); Assert.Contains(vm.Issues, i => i.Message.Contains("Vorlage", StringComparison.OrdinalIgnoreCase));
} }
@@ -26,83 +21,63 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
[Fact] [Fact]
public void VerwendeteBriefanredeFehlt_BlockiertErzeugung() public void VerwendeteBriefanredeFehlt_BlockiertErzeugung()
{ {
var service = ServiceWithTemplate("Letter.Salutation"); var vm = Build(StudentWithContact(null), StoreWithTemplate(
var vm = Build(StudentWithContact(null), service); new PlaceholderDefinition("Anrede", PlaceholderType.Text, true)));
Assert.False(vm.CanGenerate); Assert.False(vm.CanGenerate);
Assert.Contains(vm.Issues, i => i.Message.Contains("Briefanrede")); Assert.Contains(vm.Issues, i => i.Message.Contains("Anrede", StringComparison.OrdinalIgnoreCase));
} }
[Fact] [Fact]
public void VollstaendigeDaten_ErzeugenEditierbareDocxKopie() public void VollstaendigeDaten_ErzeugenFlachesPdf()
{ {
var service = ServiceWithTemplate("Letter.Salutation", "Student.FirstName", "CurrentDate"); var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), service); new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
var output = Path.Combine(_directory, "Brief.docx"); new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true));
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store);
vm.LetterText = "Dies ist der Inhalt.";
var output = Path.Combine(_directory, "Brief.pdf");
var generated = vm.Generate(output); Assert.True(vm.Generate(output));
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
Assert.True(generated);
Assert.True(File.Exists(output));
using var archive = ZipFile.OpenRead(output);
using var reader = new StreamReader(archive.GetEntry("word/document.xml")!.Open());
var xml = reader.ReadToEnd();
Assert.Contains("Sehr geehrte Frau Muster,", xml);
Assert.Contains("Lena", xml);
} }
[Fact] [Fact]
public void KontaktBearbeiten_SpeichertExpliziteBriefanrede() public void KonstanterPflichttext_BenoetigtKeineExterneEingabe()
{ {
var vm = new ContactEditDialogViewModel(new Contact { Name = "Frau Muster" }); var store = StoreWithTemplate(new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline,
vm.LetterSalutation = "Sehr geehrte Frau Muster,"; Required: true, IsConstant: true, ConstantValue: "Fest im Vorlagenpaket"));
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store);
var output = Path.Combine(_directory, "Konstant.pdf");
vm.SaveCommand.Execute(null); Assert.True(vm.CanGenerate);
Assert.True(vm.Generate(output));
Assert.Equal("Sehr geehrte Frau Muster,", vm.Result!.LetterSalutation); Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
} }
private CreateLetterDialogViewModel Build(Student student, LetterTemplateService service) => private CreateLetterDialogViewModel Build(Student student, TemplateStore store) =>
new(student, service, new FakeMemberships([]), new FakeGroups([])); new(student, store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]));
private LetterTemplateService ServiceWithTemplate(params string[] tags) private TemplateStore StoreWithTemplate(params PlaceholderDefinition[] definitions)
{ {
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.docx"); var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage");
using (var archive = ZipFile.Open(source, ZipArchiveMode.Create)) var manifest = new TemplateManifest { Id = $"brief-{Guid.NewGuid():N}", Name = "Elternbrief", Placeholders = [.. definitions] };
var lines = new List<string> { "PAGE 210 297 mm" };
var y = 20;
foreach (var definition in definitions)
{ {
var entry = archive.CreateEntry("word/document.xml"); var element = definition.Type == PlaceholderType.Multiline ? "TEXTBOX" : "TEXT";
using var writer = new StreamWriter(entry.Open()); lines.Add(element == "TEXTBOX" ? $"TEXTBOX 20 {y} 170 80 ${definition.Name}" : $"TEXT 20 {y} ${definition.Name}");
var controls = string.Join("", tags.Select(tag => y += 20;
$"<w:sdt><w:sdtPr><w:tag w:val=\"{tag}\"/></w:sdtPr><w:sdtContent>" +
"<w:p><w:r><w:t>Platzhalter</w:t></w:r></w:p></w:sdtContent></w:sdt>"));
writer.Write("<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">" +
$"<w:body>{controls}</w:body></w:document>");
} }
var service = new LetterTemplateService(Path.Combine(_directory, $"service-{Guid.NewGuid():N}")); TemplatePackage.Create(source, manifest, string.Join('\n', lines), new Dictionary<string, byte[]>());
service.Import(source, "Elternbrief"); var store = new TemplateStore(Path.Combine(_directory, $"store-{Guid.NewGuid():N}")); store.Import(source); return store;
return service;
} }
private static Student StudentWithContact(string? salutation) => new() private static Student StudentWithContact(string? salutation) => new()
{ {
FirstName = "Lena", FirstName = "Lena", LastName = "Beispiel",
LastName = "Beispiel", Contacts = [new Contact { Name = "Frau Muster", Relation = "Mutter", LetterSalutation = salutation,
Contacts = Street = "Hauptstraße 1", PostalCode = "12345", City = "Musterstadt" }],
[
new Contact
{
Name = "Frau Muster",
Relation = "Mutter",
LetterSalutation = salutation,
Street = "Hauptstraße 1",
PostalCode = "12345",
City = "Musterstadt",
},
],
}; };
public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); }
public void Dispose()
{
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
}
} }
@@ -1,15 +1,33 @@
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels; using LehrerApp.Desktop.ViewModels;
using System.Globalization;
using Xunit; using Xunit;
namespace LehrerApp.Desktop.Tests; namespace LehrerApp.Desktop.Tests;
/// Tests für 9.1 (heutige Stunden mit Uhrzeit und Raum im Dashboard) und 9.2 (Absprung von einer /// Tests für 9.1 (heutige Stunden mit Uhrzeit und Raum im Dashboard) und 9.2 (Absprung von einer
/// Stunde). Deckt nur die neue Zeit-/Raum-Auflösung ab, nicht die übrigen, unveränderten /// Stunde), außerdem die Kalender-Sitzungsanzeige (3.3.4) und die Mindeststichprobe der
/// Dashboard-Bausteine (Kalender, offene Aufgaben, Fehlzeiten-Warnungen, ...). /// Fehlzeiten-Warnung (5.2.3). Deckt nicht die übrigen, unveränderten Dashboard-Bausteine
/// (offene Aufgaben, ...).
public sealed class DashboardViewModelTests public sealed class DashboardViewModelTests
{ {
[Fact]
public void LeereHinweisbereiche_WerdenAusgeblendetUndZusammenfassungBleibtKompakt()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today });
Assert.False(vm.ExcusesCard.EffectiveIsVisible);
Assert.False(vm.CorrectionsCard.EffectiveIsVisible);
Assert.False(vm.AlertsCard.EffectiveIsVisible);
Assert.Equal("0 offene Punkte", vm.AttentionSummary);
Assert.True(vm.TodayCard.EffectiveIsVisible);
Assert.True(vm.CalendarCard.EffectiveIsVisible);
}
private static PeriodScheduleService NewPeriodSchedule() private static PeriodScheduleService NewPeriodSchedule()
{ {
var tempPath = System.IO.Path.Combine( var tempPath = System.IO.Path.Combine(
@@ -18,17 +36,220 @@ public sealed class DashboardViewModelTests
return new PeriodScheduleService(tempPath); return new PeriodScheduleService(tempPath);
} }
private static DashboardViewModel BuildVm(LearningGroup group, Lesson lesson, private static DashboardSettingsService NewDashboardSettings()
FakeTimetableSlots? slots = null, PeriodScheduleService? periodSchedule = null)
{ {
var lessons = new FakeLessons(); var tempPath = System.IO.Path.Combine(
System.IO.Path.GetTempPath(), $"lehrerapp-dashboardsettings-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
return new DashboardSettingsService(tempPath);
}
private static SchoolCalendarSettingsService NewCalendarSettings()
{
var tempPath = System.IO.Path.Combine(
System.IO.Path.GetTempPath(), $"lehrerapp-schoolcalendar-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
return new SchoolCalendarSettingsService(tempPath);
}
private static DashboardViewModel BuildVm(LearningGroup group, Lesson lesson,
FakeTimetableSlots? slots = null, PeriodScheduleService? periodSchedule = null,
FakeExams? exams = null, FakeResults? results = null, FakeGrades? grades = null,
FakeReportGrades? reportGrades = null, FakeMemberships? memberships = null,
FakeWorkTasks? tasks = null, FakeStudents? students = null, FakeDocumentation? documentation = null,
DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null,
FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null,
FakeSessions? sessions = null, FakeEntries? entries = null,
FakeAnnualPlanEvents? annualPlanEvents = null, List<LearningGroup>? allGroups = null,
FakeTimeEntries? timeEntries = null)
{
lessons ??= new FakeLessons();
lessons.Add(lesson); lessons.Add(lesson);
return new DashboardViewModel( return new DashboardViewModel(
new FakeGroups([group]), new FakeSubjects([]), lessons, new FakeGroups(allGroups ?? [group]), new FakeSubjects([]), lessons,
new FakeExams([]), new FakeWorkTasks(), new FakeSessions([]), new FakeEntries(), exams ?? new FakeExams([]), results ?? new FakeResults(), grades ?? new FakeGrades(),
new FakeStudents([]), new FakeDocumentation(), reportGrades ?? new FakeReportGrades(), memberships ?? new FakeMemberships([]),
tasks ?? new FakeWorkTasks(), sessions ?? new FakeSessions([]), entries ?? new FakeEntries(),
students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(),
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(), slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(),
new AttendanceBalanceService(), new SchoolYearService()); new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(),
schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(),
substitutions ?? new FakeSubstitutionEntries(), timeEntries ?? new FakeTimeEntries(), annualPlanEvents);
}
/// Montag einer Woche, die garantiert in der Zukunft liegt und innerhalb der
/// Klausurwochen-Vorausschau — unabhängig davon, welcher Wochentag "heute" gerade ist.
private static DateOnly NextIsoWeekMonday()
{
var reference = DateTime.Today.AddDays(7);
return DateOnly.FromDateTime(
ISOWeek.ToDateTime(ISOWeek.GetYear(reference), ISOWeek.GetWeekOfYear(reference), DayOfWeek.Monday));
}
[Fact]
public void ExamWeekLoads_DreiGeplanteKlausurenInDerselbenWoche_ErzeugtEintrag()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var monday = NextIsoWeekMonday();
var exams = new FakeExams([
new Exam { GroupId = group.Id, Title = "a", Date = monday, Status = ExamStatus.Planned },
new Exam { GroupId = group.Id, Title = "b", Date = monday.AddDays(1), Status = ExamStatus.Planned },
new Exam { GroupId = group.Id, Title = "c", Date = monday.AddDays(2), Status = ExamStatus.Planned },
]);
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, exams: exams);
var entry = Assert.Single(vm.ExamWeekLoads);
Assert.Equal(3, entry.ExamCount);
Assert.True(vm.ExamLoadCard.EffectiveIsVisible);
}
[Fact]
public void ExamWeekLoads_WenigeKlausurenProWoche_BleibtLeer()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var monday = NextIsoWeekMonday();
var exams = new FakeExams([
new Exam { GroupId = group.Id, Title = "a", Date = monday, Status = ExamStatus.Planned },
new Exam { GroupId = group.Id, Title = "b", Date = monday.AddDays(1), Status = ExamStatus.Planned },
]);
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, exams: exams);
Assert.Empty(vm.ExamWeekLoads);
}
[Fact]
public void ExamWeekLoads_BereitsDurchgefuehrteKlausurenZaehlenNicht()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var monday = NextIsoWeekMonday();
var exams = new FakeExams([
new Exam { GroupId = group.Id, Title = "a", Date = monday, Status = ExamStatus.Conducted },
new Exam { GroupId = group.Id, Title = "b", Date = monday.AddDays(1), Status = ExamStatus.Graded },
new Exam { GroupId = group.Id, Title = "c", Date = monday.AddDays(2), Status = ExamStatus.Planned },
]);
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, exams: exams);
Assert.Empty(vm.ExamWeekLoads);
}
[Fact]
public void MissingTeachingTime_VergangenerUnterrichtstagOhneErfassung_WirdGemeldet()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var pastDay = today.AddDays(-1);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = NewPeriodSchedule();
periodSchedule.SetPeriods([new PeriodTimeEntry
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule);
Assert.Contains(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
Assert.True(vm.MissingTeachingTimeCard.EffectiveIsVisible);
}
[Fact]
public void MissingTeachingTime_BereitsErfassterTag_WirdNichtGemeldet()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var pastDay = today.AddDays(-1);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = NewPeriodSchedule();
periodSchedule.SetPeriods([new PeriodTimeEntry
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
var timeEntries = new FakeTimeEntries();
timeEntries.Add(new TimeEntry { Date = pastDay, Category = "Unterricht", DurationMinutes = 45 });
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
slots: slots, periodSchedule: periodSchedule, timeEntries: timeEntries);
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
}
[Fact]
public void MissingTeachingTime_KomplettAusgefallenerTag_WirdNichtGemeldet()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var pastDay = today.AddDays(-1);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = NewPeriodSchedule();
periodSchedule.SetPeriods([new PeriodTimeEntry
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
var substitutions = new FakeSubstitutionEntries();
substitutions.Add(new SubstitutionEntry { Date = pastDay, Kind = SubstitutionKind.Cancelled, PeriodNumber = 1 });
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
slots: slots, periodSchedule: periodSchedule, substitutions: substitutions);
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
}
[Fact]
public void MissingTeachingTime_SchulferienTagWirdNichtGemeldet()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var pastDay = today.AddDays(-1);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = NewPeriodSchedule();
periodSchedule.SetPeriods([new PeriodTimeEntry
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
var schoolHolidays = new FakeSchoolHolidays();
schoolHolidays.Add(new SchoolHoliday { Name = "Ferien", StartDate = pastDay, EndDate = pastDay });
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
slots: slots, periodSchedule: periodSchedule, schoolHolidays: schoolHolidays);
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
}
[Fact]
public void MissingTeachingTime_HeuteVorAblaufDerWartezeit_WirdNichtGemeldet()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = NewPeriodSchedule();
// Unterrichtsende liegt garantiert noch keine 30 Minuten zurück.
var futureEnd = TimeOnly.FromDateTime(DateTime.Now).AddHours(2);
periodSchedule.SetPeriods([new PeriodTimeEntry
{ PeriodNumber = 1, Start = futureEnd.AddHours(-1), End = futureEnd }]);
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule);
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == today);
}
[Fact]
public void MissingTeachingTime_HeuteNachAblaufDerWartezeit_WirdGemeldet()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = NewPeriodSchedule();
// Unterrichtsende liegt garantiert mehr als 30 Minuten zurück.
var pastEnd = TimeOnly.FromDateTime(DateTime.Now).AddHours(-2);
periodSchedule.SetPeriods([new PeriodTimeEntry
{ PeriodNumber = 1, Start = pastEnd.AddHours(-1), End = pastEnd }]);
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule);
Assert.Contains(vm.MissingTeachingTimeEntries, i => i.Date == today);
} }
[Fact] [Fact]
@@ -108,4 +329,429 @@ public sealed class DashboardViewModelTests
Assert.Equal(group.Id, navigatedTo); Assert.Equal(group.Id, navigatedTo);
} }
[Fact]
public void UpcomingDates_BuendeltKlausurenFristenUndFoerderplanPruefungen()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var exams = new FakeExams([
new Exam { GroupId = group.Id, Title = "Chemie-Test", Date = today.AddDays(5) },
]);
var tasks = new FakeWorkTasks();
tasks.Add(new WorkTask { Title = "Notenschluss", DueDate = today.AddDays(10) });
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
var documentation = new FakeDocumentation();
documentation.Add(new Documentation
{
StudentId = student.Id, Type = DocumentationType.SupportPlan, Title = "Leseförderung",
SupportData = new SupportData { Status = SupportStatus.Active, ReviewDate = today.AddDays(3) },
});
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, exams: exams,
tasks: tasks, students: new FakeStudents([student]), documentation: documentation);
Assert.Contains(vm.UpcomingDates, i => i.Kind == UpcomingDateKind.Exam && i.Title == "Chemie-Test");
Assert.Contains(vm.UpcomingDates, i => i.Kind == UpcomingDateKind.Deadline && i.Title == "Notenschluss");
Assert.Contains(vm.UpcomingDates, i => i.Kind == UpcomingDateKind.SupportPlan && i.StudentId == student.Id);
}
[Fact]
public void OpenCorrections_ZeigtBewertungsfortschrittJeKlausur()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var exam = new Exam
{ GroupId = group.Id, Title = "Klausur 1", Date = today.AddDays(-2), Status = ExamStatus.Conducted };
var anna = new Student { FirstName = "Anna", LastName = "A" };
var ben = new Student { FirstName = "Ben", LastName = "B" };
var memberships = new FakeMemberships([
new GroupMembership { GroupId = group.Id, StudentId = anna.Id },
new GroupMembership { GroupId = group.Id, StudentId = ben.Id },
]);
var results = new FakeResults();
results.Add(new ExamResult { ExamId = exam.Id, StudentId = anna.Id, Grade = "2" });
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
exams: new FakeExams([exam]), results: results, memberships: memberships,
students: new FakeStudents([anna, ben]));
var correction = Assert.Single(vm.OpenCorrections);
Assert.Equal(1, correction.Completed);
Assert.Equal(2, correction.Total);
Assert.Equal(50, correction.Percent);
}
[Fact]
public void Alerts_ErkenntDeutlichenNotenabfall()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c", GradingSystem = GradingSystem.Grades1To6 };
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
var memberships = new FakeMemberships([
new GroupMembership { GroupId = group.Id, StudentId = student.Id },
]);
var grades = new FakeGrades();
grades.Add(new Grade { GroupId = group.Id, StudentId = student.Id, Date = today.AddDays(-20), Value = "2" });
grades.Add(new Grade { GroupId = group.Id, StudentId = student.Id, Date = today.AddDays(-15), Value = "2" });
grades.Add(new Grade { GroupId = group.Id, StudentId = student.Id, Date = today.AddDays(-10), Value = "4" });
grades.Add(new Grade { GroupId = group.Id, StudentId = student.Id, Date = today.AddDays(-5), Value = "4" });
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, grades: grades,
memberships: memberships, students: new FakeStudents([student]));
Assert.Contains(vm.Alerts, a => a.StudentId == student.Id && a.KindLabel == "Notenabfall");
}
[Fact]
public void Kalenderauswahl_ZeigtTermineDesGeklicktenTages()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var examDate = today.AddDays(2);
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
exams: new FakeExams([new Exam { GroupId = group.Id, Title = "Test", Date = examDate }]));
var day = vm.CalendarDays.Single(d => d.Date == examDate);
vm.SelectCalendarDayCommand.Execute(day);
Assert.True(day.IsSelected);
Assert.Contains(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.Exam && e.Title == "Test");
}
[Fact]
public void Kalenderauswahl_SortiertKurseDesTagesVorDenAlphabetischenRest()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var selectedDate = today.AddDays(1);
var alphabeticallyFirst = new LearningGroup { Name = "10a" };
var selectedDayGroup = new LearningGroup { Name = "WAT 10c" };
var lessons = new FakeLessons();
lessons.Add(new Lesson { GroupId = selectedDayGroup.Id, Date = selectedDate });
var vm = BuildVm(alphabeticallyFirst,
new Lesson { GroupId = alphabeticallyFirst.Id, Date = today }, lessons: lessons,
allGroups: [alphabeticallyFirst, selectedDayGroup]);
vm.SelectCalendarDayCommand.Execute(vm.CalendarDays.Single(d => d.Date == selectedDate));
Assert.Equal(selectedDayGroup.Id, vm.CurrentGroups[0].GroupId);
Assert.True(vm.CurrentGroups[0].IsOnSelectedDay);
Assert.Equal(alphabeticallyFirst.Id, vm.CurrentGroups[1].GroupId);
}
[Fact]
public void Kalender_ZeigtMitarbeitssitzungenAlsEigenenTermintyp()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var sessionDate = today.AddDays(3);
var session = new ParticipationSession { GroupId = group.Id, Date = sessionDate, Comment = "Aufsatz" };
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
sessions: new FakeSessions([session]));
var day = vm.CalendarDays.Single(d => d.Date == sessionDate);
Assert.True(day.HasSession);
vm.SelectCalendarDayCommand.Execute(day);
Assert.Contains(vm.SelectedDayEvents,
e => e.Kind == CalendarEventKind.ParticipationSession && e.Subtitle == "Aufsatz");
}
[Fact]
public void Kalender_ZeigtMehrtaegigenJahresplanParallelZumUnterrichtAn()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var annualPlan = new FakeAnnualPlanEvents();
annualPlan.Add(new AnnualPlanEvent
{
ExternalId = "fahrt", Title = "Klassenfahrt 6a",
Description = "Aushang beachten", Location = "Jugendherberge",
CalendarGroup = "Lehrkräfte", StartDate = today, EndDate = today.AddDays(2),
StartTime = new TimeOnly(13, 30), EndTime = new TimeOnly(15, 0), IsAllDay = false,
});
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today, Topic = "Redox" },
annualPlanEvents: annualPlan);
var day = vm.CalendarDays.Single(d => d.Date == today);
Assert.True(day.HasLesson);
Assert.True(day.HasAnnualPlanEvent);
vm.SelectCalendarDayCommand.Execute(day);
Assert.Contains(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.Lesson);
var annual = Assert.Single(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.AnnualPlan);
Assert.Equal("Klassenfahrt 6a", annual.Title);
Assert.Equal("Aushang beachten", annual.Description);
Assert.Contains("13:30", annual.Subtitle);
Assert.Null(annual.GroupId);
}
[Fact]
public void Kalender_AusStundeErzeugteSitzungErzeugtKeinenDoppelteintrag()
{
// Regressionstest (Nutzer-Feedback): "Sitzung erzeugen" (3.3.1) verknüpft die Sitzung über
// LessonId mit der Stunde — dieselbe Unterrichtsstunde darf im Kalender nicht zusätzlich
// als eigener Sitzungstermin auftauchen.
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var lesson = new Lesson { GroupId = group.Id, Date = today, Topic = "Brechung" };
var session = new ParticipationSession { GroupId = group.Id, Date = today, LessonId = lesson.Id, Comment = "Brechung" };
var vm = BuildVm(group, lesson, sessions: new FakeSessions([session]));
var day = vm.CalendarDays.Single(d => d.Date == today);
Assert.True(day.HasLesson);
Assert.False(day.HasSession);
vm.SelectCalendarDayCommand.Execute(day);
Assert.Single(vm.SelectedDayEvents);
Assert.DoesNotContain(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.ParticipationSession);
}
[Fact]
public void Kalender_EigeneKlasseAmStundenplanTagOhneLessonZeigtDenRingTrotzdem()
{
// Regressionstest (Nutzer-Feedback): der "Meine Klasse"-Ring soll schon laut Stundenplan
// gelten, nicht erst, sobald für den Tag eine Lesson angelegt wurde.
var today = DateOnly.FromDateTime(DateTime.Today);
var tomorrow = today.AddDays(1);
var group = new LearningGroup { Name = "9c", IsOwnClass = true };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = tomorrow.DayOfWeek, PeriodNumber = 3 });
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots);
var day = vm.CalendarDays.Single(d => d.Date == tomorrow);
Assert.True(day.IsOwnClassDay);
}
[Fact]
public void DashboardKacheln_SichtbarkeitUndReihenfolgeWerdenGespeichert()
{
var settings = NewDashboardSettings();
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, dashboardSettings: settings);
vm.TodayCard.IsVisible = false;
vm.MoveCardDownCommand.Execute(vm.TasksCard);
var saved = settings.Load();
Assert.False(saved.Single(c => c.Key == "today").IsVisible);
Assert.True(saved.FindIndex(c => c.Key == "tasks") > 1);
}
// ── Ungeplante Stunden (Nutzer-Feedback) ──────────────────────────────────
[Fact]
public void UnplannedLessons_StundeplanSlotOhneLessonMitThema_ErscheintInDerListe()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
// "lesson" liegt bewusst weit in der Vergangenheit, damit sie nicht in den heute/morgen-
// Vorgriff fällt und den Test verfälscht.
var pastLesson = new Lesson { GroupId = group.Id, Date = today.AddYears(-1), LessonNumber = 1, Topic = "Alt" };
var vm = BuildVm(group, pastLesson, slots: slots);
var item = Assert.Single(vm.UnplannedLessons);
Assert.Equal(group.Id, item.GroupId);
Assert.Equal(1, item.PeriodNumber);
Assert.Equal("Heute", item.DateDisplay);
}
[Fact]
public void UnplannedLessons_LessonMitThemaVorhanden_ErscheintNicht()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
var lesson = new Lesson { GroupId = group.Id, Date = today, LessonNumber = 1, Topic = "Redoxreaktionen" };
var vm = BuildVm(group, lesson, slots: slots);
Assert.Empty(vm.UnplannedLessons);
}
[Fact]
public void UnplannedLessons_GruppeOhnePlanungsbedarf_WirdIgnoriert()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "Klassenrat", RequiresLessonPlanning = false };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
var pastLesson = new Lesson { GroupId = group.Id, Date = today.AddYears(-1), LessonNumber = 1, Topic = "Alt" };
var vm = BuildVm(group, pastLesson, slots: slots);
Assert.Empty(vm.UnplannedLessons);
}
[Fact]
public void UnplannedLessons_Feiertag_WirdUebersprungen()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
var pastLesson = new Lesson { GroupId = group.Id, Date = today.AddYears(-1), LessonNumber = 1, Topic = "Alt" };
var schoolHolidays = new FakeSchoolHolidays();
schoolHolidays.Add(new SchoolHoliday { Name = "Ferien", StartDate = today, EndDate = today.AddDays(1) });
var vm = BuildVm(group, pastLesson, slots: slots, schoolHolidays: schoolHolidays);
Assert.Empty(vm.UnplannedLessons);
}
[Fact]
public void UnplannedLessons_DoppelstundeMitThemaAmErstenSlot_ZweiterSlotWirdNichtGemeldet()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 3 });
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 4 });
var lesson = new Lesson { GroupId = group.Id, Date = today, LessonNumber = 3, Topic = "Redox" };
var vm = BuildVm(group, lesson, slots: slots);
Assert.Empty(vm.UnplannedLessons);
}
[Fact]
public void UnplannedLessons_DoppelstundeOhneThema_BeideSlotsWerdenGemeldet()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 3 });
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 4 });
var lesson = new Lesson { GroupId = group.Id, Date = today, LessonNumber = 3, Topic = "" };
var vm = BuildVm(group, lesson, slots: slots);
Assert.Equal(2, vm.UnplannedLessons.Count);
Assert.Contains(vm.UnplannedLessons, i => i.PeriodNumber == 3);
Assert.Contains(vm.UnplannedLessons, i => i.PeriodNumber == 4);
}
[Fact]
public void UnplannedLessons_StundenausfallEingetragen_WirdNichtGemeldet()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 5 });
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 6 });
var pastLesson = new Lesson { GroupId = group.Id, Date = today.AddYears(-1), LessonNumber = 5, Topic = "Alt" };
var substitutions = new FakeSubstitutionEntries();
substitutions.Add(new SubstitutionEntry { Date = today, Kind = SubstitutionKind.Cancelled, PeriodNumber = 5 });
substitutions.Add(new SubstitutionEntry { Date = today, Kind = SubstitutionKind.Cancelled, PeriodNumber = 6 });
var vm = BuildVm(group, pastLesson, slots: slots, substitutions: substitutions);
Assert.Empty(vm.UnplannedLessons);
}
[Fact]
public void AttendanceWarnings_KleineStichprobeMitHoherQuoteWirdNichtGemeldet()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
var sessions = new FakeSessions([]);
var entries = new FakeEntries();
// Nur 3 erfasste Termine, alle unentschuldigt (100 %) — Stichprobe zu klein für eine Meldung.
for (var i = 0; i < 3; i++)
{
var session = new ParticipationSession { GroupId = group.Id, Date = today.AddDays(-i - 1) };
sessions.Save(session);
entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = AttendanceStatus.Unexcused });
}
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
students: new FakeStudents([student]), sessions: sessions, entries: entries);
Assert.Empty(vm.AttendanceWarnings);
}
[Fact]
public void AttendanceWarnings_MeldetHoheQuoteAbMindeststichprobe()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
var sessions = new FakeSessions([]);
var entries = new FakeEntries();
// 10 erfasste Termine, 3 davon unentschuldigt (30 % > 20 % Schwelle) — genug Stichprobe.
for (var i = 0; i < 10; i++)
{
var session = new ParticipationSession { GroupId = group.Id, Date = today.AddDays(-i - 1) };
sessions.Save(session);
var status = i < 3 ? AttendanceStatus.Unexcused : AttendanceStatus.Present;
entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = status });
}
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
students: new FakeStudents([student]), sessions: sessions, entries: entries);
var item = Assert.Single(vm.AttendanceWarnings);
Assert.Equal(student.FullName, item.StudentName);
Assert.Equal(30.0, item.AbsenceRatePercent);
}
[Fact]
public async Task AddTask_SpeichertErgebnisUndAktualisiertOffeneAufgaben()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var tasks = new FakeWorkTasks();
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, tasks: tasks);
vm.OnAddTask = startAsReminder => Task.FromResult<WorkTask?>(
new WorkTask { Title = "Ansage an die Klasse", Kind = startAsReminder ? TaskKind.Reminder : TaskKind.WorkItem });
await vm.AddReminderCommand.ExecuteAsync(null);
Assert.Single(tasks.GetAll());
Assert.Contains(vm.OpenTasks, t => t.Title == "Ansage an die Klasse" && t.IsReminder);
}
[Fact]
public async Task AddTask_OhneDelegatMachtNichts()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var tasks = new FakeWorkTasks();
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, tasks: tasks);
await vm.AddTaskCommand.ExecuteAsync(null);
Assert.Empty(tasks.GetAll());
}
[Fact]
public void WeatherWarningItem_BereitetAmtlicheWarnungFuerDashboardAuf()
{
var warning = new WeatherWarning
{
Event = "GEWITTER", Headline = "Amtliche Warnung vor Gewitter",
Description = "Es treten Gewitter auf.", Instruction = "Gebäude aufsuchen.",
Severity = "Severe", Onset = new DateTime(2026, 8, 23, 16, 0, 0, DateTimeKind.Utc),
Expires = new DateTime(2026, 8, 23, 18, 0, 0, DateTimeKind.Utc),
};
var item = new DashboardWeatherWarningItem(warning);
Assert.Equal("Amtliche Warnung vor Gewitter", item.Headline);
Assert.Equal("#D32F2F", item.SeverityColor);
Assert.Contains("Uhr", item.PeriodDisplay);
}
} }
@@ -137,6 +137,24 @@ public sealed class DocumentationDialogViewModelTests
Assert.Equal("Gut verlaufen", vm.Result.ParentCallData.Impressions); Assert.Equal("Gut verlaufen", vm.Result.ParentCallData.Impressions);
} }
[Fact]
public void Save_SitzplanEntwurf_WirdAbgeschlossenUndBehaeltStundenbezug()
{
var sessionId = Guid.NewGuid();
var existing = new Documentation
{
StudentId = Guid.NewGuid(), Type = DocumentationType.Incident,
Date = DateOnly.FromDateTime(DateTime.Today), Title = "Streit/Konflikt",
IsDraft = true, ParticipationSessionId = sessionId, Tags = ["Streit/Konflikt"],
};
var vm = new DocumentationDialogViewModel(existing.StudentId, existing, new FakeAttachmentStorage());
vm.SaveCommand.Execute(null);
Assert.False(vm.Result!.IsDraft);
Assert.Equal(sessionId, vm.Result.ParticipationSessionId);
}
[Fact] [Fact]
public void Save_Elternbrief_UebernimmtVersandUndRueckmeldung() public void Save_Elternbrief_UebernimmtVersandUndRueckmeldung()
{ {
@@ -187,6 +205,79 @@ public sealed class DocumentationDialogViewModelTests
Assert.NotEqual("", vm.AttachmentError); Assert.NotEqual("", vm.AttachmentError);
} }
[Fact]
public void OhneStudentOptions_CanPickStudentIstFalse()
{
var vm = new DocumentationDialogViewModel(Guid.NewGuid(), null, new FakeAttachmentStorage());
Assert.False(vm.CanPickStudent);
}
[Fact]
public void MitStudentOptions_OhneAuswahl_SaveSetztFehlerUndSpeichertNicht()
{
var anna = new StudentOption(Guid.NewGuid(), "Anna Beispiel");
var vm = new DocumentationDialogViewModel(Guid.Empty, null, new FakeAttachmentStorage(), [anna])
{
Title = "Vorfall", TypeName = "Vorkommnis",
};
vm.SaveCommand.Execute(null);
Assert.Null(vm.Result);
Assert.NotEqual("", vm.StudentError);
}
[Fact]
public void MitStudentOptions_AuswahlGetroffen_SaveUebernimmtSchuelerUndGruppe()
{
var groupId = Guid.NewGuid();
var anna = new StudentOption(Guid.NewGuid(), "Anna Beispiel");
var ben = new StudentOption(Guid.NewGuid(), "Ben Muster");
var vm = new DocumentationDialogViewModel(Guid.Empty, null, new FakeAttachmentStorage(),
[anna, ben], groupId)
{
Title = "Vorfall", TypeName = "Vorkommnis", SelectedStudent = ben,
};
vm.SaveCommand.Execute(null);
Assert.NotNull(vm.Result);
Assert.Equal(ben.Id, vm.Result!.StudentId);
Assert.Equal(groupId, vm.Result.GroupId);
}
[Fact]
public void GesamteLerngruppe_SaveSpeichertOhneSchuelerbezug()
{
var groupId = Guid.NewGuid();
var wholeGroup = new StudentOption(Guid.Empty, "Gesamte Lerngruppe");
var vm = new DocumentationDialogViewModel(Guid.Empty, null, new FakeAttachmentStorage(),
[wholeGroup], groupId)
{
SelectedStudent = wholeGroup, Title = "Klausur planen", TypeName = "Planung / Erinnerung",
};
vm.SaveCommand.Execute(null);
Assert.NotNull(vm.Result);
Assert.Equal(Guid.Empty, vm.Result!.StudentId);
Assert.Equal(groupId, vm.Result.GroupId);
Assert.Equal(DocumentationType.Planning, vm.Result.Type);
}
[Fact]
public void MitStudentOptions_Bearbeiten_SchuelerIstVorausgewaehlt()
{
var anna = new StudentOption(Guid.NewGuid(), "Anna Beispiel");
var ben = new StudentOption(Guid.NewGuid(), "Ben Muster");
var existing = new Documentation { StudentId = ben.Id, Title = "Alt" };
var vm = new DocumentationDialogViewModel(ben.Id, existing, new FakeAttachmentStorage(), [anna, ben]);
Assert.Equal(ben, vm.SelectedStudent);
}
[Fact] [Fact]
public void DiscardUnsavedAttachments_EntferntNurNieGespeicherteAnhaenge() public void DiscardUnsavedAttachments_EntferntNurNieGespeicherteAnhaenge()
{ {
@@ -0,0 +1,64 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class ExamDialogViewModelTests
{
private static ExamDialogViewModel BuildViewModel(List<Exam> existingExams, string dateText,
Exam? editingExam = null)
{
var vm = new ExamDialogViewModel(new FakeExams(existingExams), new FakeCompetencyDomains(),
new FakeGradingKeyTemplates(), new GradingService(), Guid.NewGuid(), null, 9,
GradingSystem.Grades1To6, "Chemie", isDifferentiated: false, editingExam, null);
vm.DateText = dateText;
return vm;
}
private static Exam MakeExam(DateOnly date) => new() { GroupId = Guid.NewGuid(), Title = "x", Date = date };
[Fact]
public void WeekLoadHint_WenigeKlausurenInDerWoche_BleibtLeer()
{
var monday = new DateOnly(2026, 3, 16);
var existing = new List<Exam> { MakeExam(monday) };
var vm = BuildViewModel(existing, monday.AddDays(1).ToString("dd.MM.yyyy"));
Assert.Equal("", vm.WeekLoadHint);
}
[Fact]
public void WeekLoadHint_DritteKlausurInDerWoche_ZeigtHinweis()
{
var monday = new DateOnly(2026, 3, 16);
var existing = new List<Exam> { MakeExam(monday), MakeExam(monday.AddDays(1)) };
var vm = BuildViewModel(existing, monday.AddDays(2).ToString("dd.MM.yyyy"));
Assert.Contains("3 Klausuren", vm.WeekLoadHint);
}
[Fact]
public void WeekLoadHint_BearbeiteteKlausurZaehltNichtDoppelt()
{
var monday = new DateOnly(2026, 3, 16);
var editing = MakeExam(monday);
var existing = new List<Exam> { editing, MakeExam(monday.AddDays(1)) };
// Nur die eigene Klausur (editing) + eine weitere -> 2 insgesamt, kein Hinweis.
var vm = BuildViewModel(existing, monday.ToString("dd.MM.yyyy"), editing);
Assert.Equal("", vm.WeekLoadHint);
}
[Fact]
public void WeekLoadHint_UngueltigesDatum_BleibtLeer()
{
var vm = BuildViewModel([], "keindatum");
Assert.Equal("", vm.WeekLoadHint);
}
}
@@ -0,0 +1,65 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
/// Deckt 1.6.3 (Bulk-Aktion "Rest als abwesend markieren") ab — löst das Nutzer-Feedback, dass
/// Schüler, die nie nachschreiben, sonst dauerhaft eine leere, unberührte Zeile hinterlassen.
public sealed class ExamGradingDialogViewModelTests
{
[Fact]
public void MarkRemainingAbsent_MarktNurUnberührteZeilenAbwesend()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var studentWithPoints = new Student { FirstName = "Anna", LastName = "Bauer" };
var untouchedStudent1 = new Student { FirstName = "Ben", LastName = "Cordes" };
var untouchedStudent2 = new Student { FirstName = "Cara", LastName = "Diehl" };
var students = new FakeStudents([studentWithPoints, untouchedStudent1, untouchedStudent2]);
var memberships = new FakeMemberships(
[
new GroupMembership { GroupId = group.Id, StudentId = studentWithPoints.Id },
new GroupMembership { GroupId = group.Id, StudentId = untouchedStudent1.Id },
new GroupMembership { GroupId = group.Id, StudentId = untouchedStudent2.Id },
]);
var exam = new Exam
{
GroupId = group.Id,
Title = "Klausur 1",
Tasks = [new ExamTask { Nr = 1, MaxPoints = 10 }],
GradingKey = GradingService.DefaultKey1To6(),
};
var results = new FakeResults();
var vm = new ExamGradingDialogViewModel(results, students, memberships,
new GradingService(), exam, group.Id);
var rowWithPoints = vm.Rows.Single(r => r.StudentId == studentWithPoints.Id);
rowWithPoints.Cells[0].TrySetValue(7);
vm.MarkRemainingAbsentCommand.Execute(null);
Assert.False(rowWithPoints.Absent);
Assert.True(vm.Rows.Single(r => r.StudentId == untouchedStudent1.Id).Absent);
Assert.True(vm.Rows.Single(r => r.StudentId == untouchedStudent2.Id).Absent);
}
[Fact]
public void MarkRemainingAbsent_LässtBereitsAbwesendMarkierteUnverändert()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var student = new Student { FirstName = "Ben", LastName = "Cordes" };
var students = new FakeStudents([student]);
var memberships = new FakeMemberships(
[new GroupMembership { GroupId = group.Id, StudentId = student.Id }]);
var exam = new Exam { GroupId = group.Id, Title = "Klausur 1", Tasks = [new ExamTask { Nr = 1, MaxPoints = 10 }], GradingKey = GradingService.DefaultKey1To6() };
var vm = new ExamGradingDialogViewModel(new FakeResults(), students, memberships,
new GradingService(), exam, group.Id);
vm.Rows[0].Absent = true;
vm.MarkRemainingAbsentCommand.Execute(null);
Assert.True(vm.Rows[0].Absent);
}
}
@@ -0,0 +1,103 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Exams;
using Xunit;
namespace LehrerApp.Desktop.Tests;
/// Deckt 1.6 (Klausuren-Hauptseite) ab: Priorisierung end-to-end über die ViewModel-Schicht,
/// Parallelkurs-Umschalter (1.6.4) und Genehmigung/Ankündigung (1.6.5).
public sealed class ExamsOverviewViewModelTests
{
private static ExamsOverviewViewModel BuildVm(List<LearningGroup> groups, List<Exam> exams,
FakeMemberships? memberships = null, FakeResults? results = null) =>
new(new FakeExams(exams), results ?? new FakeResults(), new FakeGroups(groups),
memberships ?? new FakeMemberships([]), new GradingService(), new SchoolYearService());
[Fact]
public void Load_UnbearbeiteteKorrekturStehtVorGeplanterKlausur()
{
var group = new LearningGroup { Name = "Q1 Chemie", SchoolYear = new SchoolYearService().CurrentSchoolYear() };
var today = DateOnly.FromDateTime(DateTime.Today);
var overdue = new Exam { GroupId = group.Id, Title = "Alte Klausur", Status = ExamStatus.Conducted, Date = today.AddDays(-10) };
var planned = new Exam { GroupId = group.Id, Title = "Neue Klausur", Status = ExamStatus.Planned, Date = today.AddDays(3) };
var vm = BuildVm([group], [overdue, planned]);
vm.LoadCommand.Execute(null);
Assert.Equal("Alte Klausur", vm.Rows[0].Title);
Assert.Equal("Neue Klausur", vm.Rows[1].Title);
}
[Fact]
public void LeereListe_BietetAbsprungZuLerngruppen()
{
var vm = BuildVm([], []);
var navigated = false;
vm.OnNavigateToGroups = () => navigated = true;
vm.LoadCommand.Execute(null);
vm.GoToGroupsCommand.Execute(null);
Assert.True(vm.HasNoExams);
Assert.True(navigated);
}
[Fact]
public void Load_ParallelkurseWerdenAlsGeschwisterErkannt()
{
var groupA = new LearningGroup { Name = "Chemie GK Q1", SchoolYear = new SchoolYearService().CurrentSchoolYear() };
var groupB = new LearningGroup { Name = "Chemie GK Q1b", SchoolYear = groupA.SchoolYear };
var original = new Exam { GroupId = groupA.Id, Title = "Redox", Status = ExamStatus.Planned, Date = DateOnly.FromDateTime(DateTime.Today).AddDays(5) };
var duplicate = new Exam
{
GroupId = groupB.Id, Title = "Redox", Status = ExamStatus.Planned,
Date = DateOnly.FromDateTime(DateTime.Today).AddDays(6), SharedExamGroupId = original.Id,
};
var vm = BuildVm([groupA, groupB], [original, duplicate]);
vm.LoadCommand.Execute(null);
vm.SelectedRow = vm.Rows.Single(r => r.Exam.Id == original.Id);
Assert.True(vm.SelectedRow.HasSibling);
Assert.Single(vm.Siblings);
Assert.Equal(duplicate.Id, vm.Siblings[0].Exam.Id);
}
[Fact]
public void ToggleApproval_SetztUndLöschtDatum()
{
var group = new LearningGroup { Name = "Q1 Chemie", SchoolYear = new SchoolYearService().CurrentSchoolYear() };
var exam = new Exam { GroupId = group.Id, Title = "Klausur", Status = ExamStatus.Planned, Date = DateOnly.FromDateTime(DateTime.Today).AddDays(2) };
var vm = BuildVm([group], [exam]);
vm.LoadCommand.Execute(null);
vm.SelectedRow = vm.Rows[0];
vm.ToggleApprovalCommand.Execute(null);
Assert.NotNull(exam.ApprovalGrantedAt);
Assert.StartsWith("Genehmigt am", vm.ApprovalLabel);
vm.ToggleApprovalCommand.Execute(null);
Assert.Null(exam.ApprovalGrantedAt);
Assert.Equal("Genehmigung noch ausstehend", vm.ApprovalLabel);
}
[Fact]
public void Selection_ZeigtKorrekturfortschrittOderNotenspiegelJeNachStatus()
{
var group = new LearningGroup { Name = "Q1 Chemie", SchoolYear = new SchoolYearService().CurrentSchoolYear() };
var today = DateOnly.FromDateTime(DateTime.Today);
var conducted = new Exam { GroupId = group.Id, Title = "Läuft", Status = ExamStatus.Conducted, Date = today.AddDays(-2) };
var returned = new Exam { GroupId = group.Id, Title = "Fertig", Status = ExamStatus.Returned, Date = today.AddDays(-20) };
var vm = BuildVm([group], [conducted, returned]);
vm.LoadCommand.Execute(null);
vm.SelectedRow = vm.Rows.Single(r => r.Exam.Id == conducted.Id);
Assert.True(vm.ShowCorrectionProgress);
Assert.False(vm.ShowGradeSummary);
vm.SelectedRow = vm.Rows.Single(r => r.Exam.Id == returned.Id);
Assert.False(vm.ShowCorrectionProgress);
Assert.True(vm.ShowGradeSummary);
}
}
@@ -0,0 +1,32 @@
using LehrerApp.Desktop.Services;
using System.Text;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class CsvBuilderTests
{
[Fact]
public void AddRow_MaskiertTrennzeichen_AnfuehrungszeichenUndZeilenumbrueche()
{
var csv = new CsvBuilder()
.AddRow("Normal", "A;B", "Zitat \"wichtig\"", "Zeile 1\nZeile 2")
.ToString();
Assert.Contains("Normal;\"A;B\"", csv);
Assert.Contains("\"Zitat \"\"wichtig\"\"\"", csv);
Assert.Contains("\"Zeile 1\nZeile 2\"", csv);
}
[Fact]
public void CsvExportfile_BereinigtDateinameUndErgaenztEndungUndUtf8Bom()
{
var export = ExportFile.Csv("Export", "Auswertung/Chemie", "Änderung");
var bytes = export.Content.ToArray();
var preamble = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true).GetPreamble();
Assert.Equal("Auswertung_Chemie.csv", export.SuggestedFileName);
Assert.Equal(preamble, bytes[..preamble.Length]);
Assert.Equal("Änderung", Encoding.UTF8.GetString(bytes[preamble.Length..]));
}
}
+186 -2
View File
@@ -1,6 +1,10 @@
using LehrerApp.Core.Interfaces; using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Settings;
using LehrerApp.Sync;
using LehrerApp.Sync.Crypto;
namespace LehrerApp.Desktop.Tests; namespace LehrerApp.Desktop.Tests;
@@ -26,6 +30,83 @@ public static class TestSupport
public static AiPlanningService BuildAiPlanningService() => new( public static AiPlanningService BuildAiPlanningService() => new(
new HttpClient(), new FakeLessons(), new FakeGroups([]), new FakeSubjects([]), new HttpClient(), new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([])); new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
public static WebUntisSettingsService BuildWebUntisSettingsService()
{
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-webuntissettings-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
return new WebUntisSettingsService(tempPath);
}
/// Analog zu den übrigen dateibasierten Feed-Einstellungen: eigenes Temp-Verzeichnis.
public static AnnualPlanSettingsService BuildAnnualPlanSettingsService()
{
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-annualplansettings-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
return new AnnualPlanSettingsService(tempPath);
}
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
public static SyncSettingsService BuildSyncSettingsService()
{
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-syncsettings-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
return new SyncSettingsService(tempPath);
}
/// Kein echter HTTP-Aufruf, solange SyncSettingsService.IsLoggedIn false ist (siehe
/// BuildAiPlanningService).
public static SyncAuthService BuildSyncAuthService() => new(new HttpClient());
/// Eigenes Temp-Verzeichnis je Aufruf (echte, dateibasierte LiteDB wie bei EventQueue üblich).
public static EventQueue BuildEventQueue()
{
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-eventqueue-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
return new EventQueue(Path.Combine(tempPath, "queue.db"));
}
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
public static PeriodScheduleService BuildPeriodScheduleService()
{
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-periodschedule-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
return new PeriodScheduleService(tempPath);
}
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
public static AppLogger BuildAppLogger()
{
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-applogger-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
return new AppLogger(tempPath);
}
/// Für Tests, die nicht speziell den "Schlüssel neu erzeugt"-Warnzustand prüfen.
public static SyncKeyStatus BuildSyncKeyStatus(bool keyWasRegenerated = false) => new(keyWasRegenerated);
/// Neue, leere Fakes je Aufruf - für Tests, die nicht speziell TrashViewModel-Verhalten prüfen.
public static TrashViewModel BuildTrashViewModel() => new(
new FakeTrash(), new FakeGrades(), new FakeWorkTasks(), new FakeTimeEntries(),
new FakeSeatingPlans(), new FakeGradingKeyTemplates());
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
public static AppearanceSettingsService BuildAppearanceSettingsService()
{
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-appearance-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
return new AppearanceSettingsService(tempPath);
}
/// Eigenes Temp-Verzeichnis je Aufruf, damit Tests sich nicht gegenseitig über dieselbe
/// sync.key stören.
public static SyncKeyRecoveryService BuildSyncKeyRecoveryService()
{
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-synckeyrecovery-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
return new SyncKeyRecoveryService(SyncCrypto.GenerateKey(), Path.Combine(tempPath, "sync.key"));
}
} }
public class FakeStudents(List<Student> all) : IStudentRepository public class FakeStudents(List<Student> all) : IStudentRepository
@@ -72,14 +153,30 @@ public class FakeSeatingPlans(List<SeatingPlan>? initial = null) : ISeatingPlanR
_all.Add(plan); _all.Add(plan);
} }
public void Delete(Guid id) => _all.RemoveAll(p => p.Id == id); public void Delete(Guid id) => _all.RemoveAll(p => p.Id == id);
// Trash-Mechanismus (14.3) lebt real in LiteDbContext.MoveToTrash/RestoreFromTrash - Fakes
// bilden ihn bewusst nicht nach, ViewModel-Tests brauchen das nicht.
public void Restore(Guid trashId) { }
}
public class FakeTrash(List<TrashedItem>? initial = null) : ITrashRepository
{
private readonly List<TrashedItem> _all = initial ?? [];
public void Add(TrashedItem item) => _all.Add(item);
public List<TrashedItem> GetAll() => _all.OrderByDescending(t => t.DeletedAt).ToList();
public void PurgeOlderThan(DateTime cutoffUtc) => _all.RemoveAll(t => t.DeletedAt < cutoffUtc);
} }
public class FakeSessions(List<ParticipationSession> all) : IParticipationSessionRepository public class FakeSessions(List<ParticipationSession> all) : IParticipationSessionRepository
{ {
public List<ParticipationSession> GetByGroup(Guid groupId) => all.Where(s => s.GroupId == groupId).ToList(); public List<ParticipationSession> GetByGroup(Guid groupId) => all.Where(s => s.GroupId == groupId).ToList();
public ParticipationSession? GetById(Guid id) => all.FirstOrDefault(s => s.Id == id); public ParticipationSession? GetById(Guid id) => all.FirstOrDefault(s => s.Id == id);
public void Save(ParticipationSession session) { } public List<ParticipationSession> GetAll() => all.ToList();
public void Delete(Guid id) { } public void Save(ParticipationSession session)
{
all.RemoveAll(s => s.Id == session.Id);
all.Add(session);
}
public void Delete(Guid id) => all.RemoveAll(s => s.Id == id);
} }
public class FakeEntries : IParticipationRepository public class FakeEntries : IParticipationRepository
@@ -135,6 +232,7 @@ public class FakeGrades : IGradeRepository
_all.Add(grade); _all.Add(grade);
} }
public void Delete(Guid id) => _all.RemoveAll(g => g.Id == id); public void Delete(Guid id) => _all.RemoveAll(g => g.Id == id);
public void Restore(Guid trashId) { } // siehe FakeSeatingPlans.Restore
} }
public class FakeExams(List<Exam> all) : IExamRepository public class FakeExams(List<Exam> all) : IExamRepository
@@ -172,6 +270,7 @@ public class FakeGradingKeyTemplates : IGradingKeyTemplateRepository
public GradingKeyTemplate? GetById(Guid id) => _all.FirstOrDefault(t => t.Id == id); public GradingKeyTemplate? GetById(Guid id) => _all.FirstOrDefault(t => t.Id == id);
public void Save(GradingKeyTemplate template) { _all.RemoveAll(t => t.Id == template.Id); _all.Add(template); } public void Save(GradingKeyTemplate template) { _all.RemoveAll(t => t.Id == template.Id); _all.Add(template); }
public void Delete(Guid id) => _all.RemoveAll(t => t.Id == id); public void Delete(Guid id) => _all.RemoveAll(t => t.Id == id);
public void Restore(Guid trashId) { } // siehe FakeSeatingPlans.Restore
} }
public class FakeSchemes : IGradingSchemeRepository public class FakeSchemes : IGradingSchemeRepository
@@ -340,18 +439,102 @@ public class FakeSubstitutionEntries : ISubstitutionEntryRepository
public void Add(SubstitutionEntry e) => _all.Add(e); public void Add(SubstitutionEntry e) => _all.Add(e);
public List<SubstitutionEntry> GetAll() => _all.ToList(); public List<SubstitutionEntry> GetAll() => _all.ToList();
public List<SubstitutionEntry> GetByDate(DateOnly date) => _all.Where(e => e.Date == date).ToList(); public List<SubstitutionEntry> GetByDate(DateOnly date) => _all.Where(e => e.Date == date).ToList();
public SubstitutionEntry? GetByExternalId(string externalId) => _all.FirstOrDefault(e => e.ExternalId == externalId);
public void Save(SubstitutionEntry entry) { _all.RemoveAll(e => e.Id == entry.Id); _all.Add(entry); } public void Save(SubstitutionEntry entry) { _all.RemoveAll(e => e.Id == entry.Id); _all.Add(entry); }
public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id); public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id);
} }
public class FakeUntisSnapshots : IUntisSnapshotRepository
{
private readonly List<UntisSnapshotEntry> _all = [];
public void Add(UntisSnapshotEntry e) => _all.Add(e);
public List<UntisSnapshotEntry> GetAll() => _all.ToList();
public void Save(UntisSnapshotEntry entry) { _all.RemoveAll(e => e.Id == entry.Id); _all.Add(entry); }
public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id);
}
public class FakeUntisSlotMappings : IUntisSlotMappingRepository
{
private readonly List<UntisSlotMapping> _all = [];
public void Add(UntisSlotMapping m) => _all.Add(m);
public List<UntisSlotMapping> GetAll() => _all.ToList();
public void Save(UntisSlotMapping mapping) { _all.RemoveAll(m => m.Id == mapping.Id); _all.Add(mapping); }
public void Delete(Guid id) => _all.RemoveAll(m => m.Id == id);
}
public class FakeAnnualPlanEvents : IAnnualPlanEventRepository
{
private readonly List<AnnualPlanEvent> _all = [];
public void Add(AnnualPlanEvent e) => _all.Add(e);
public List<AnnualPlanEvent> GetAll() => _all.ToList();
public List<AnnualPlanEvent> GetByRange(DateOnly from, DateOnly to) =>
_all.Where(e => e.StartDate <= to && e.EndDate >= from).ToList();
public AnnualPlanEvent? GetByExternalId(string externalId) =>
_all.FirstOrDefault(e => e.ExternalId == externalId);
public void Save(AnnualPlanEvent entry) { _all.RemoveAll(e => e.Id == entry.Id); _all.Add(entry); }
public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id);
}
public class FakeUntisAbsenceCache : IUntisAbsenceCacheRepository
{
private readonly List<UntisAbsenceCacheEntry> _all = [];
public List<UntisAbsenceCacheEntry> GetByClassAndRange(string className, int startDate, int endDate) =>
_all.Where(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate).ToList();
public void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisAbsenceCacheEntry> entries)
{
_all.RemoveAll(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate);
_all.AddRange(entries);
}
public void InsertRange(IEnumerable<UntisAbsenceCacheEntry> entries) => _all.AddRange(entries);
}
public class FakeUntisClassRegisterCache : IUntisClassRegisterCacheRepository
{
private readonly List<UntisClassRegisterCacheEntry> _all = [];
public List<UntisClassRegisterCacheEntry> GetByClassAndRange(string className, int startDate, int endDate) =>
_all.Where(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate).ToList();
public void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisClassRegisterCacheEntry> entries)
{
_all.RemoveAll(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate);
_all.AddRange(entries);
}
public void InsertRange(IEnumerable<UntisClassRegisterCacheEntry> entries) => _all.AddRange(entries);
}
public class FakeUntisStudentRosterCache : IUntisStudentRosterCacheRepository
{
private readonly List<UntisStudentRosterCacheEntry> _all = [];
public List<UntisStudentRosterCacheEntry> GetByClass(string className) =>
_all.Where(e => e.ClassName == className).ToList();
public void ReplaceAll(string className, IEnumerable<UntisStudentRosterCacheEntry> entries)
{
_all.RemoveAll(e => e.ClassName == className);
_all.AddRange(entries);
}
}
public class FakeUntisCacheFetchStates : IUntisCacheFetchStateRepository
{
private readonly List<UntisCacheFetchState> _all = [];
public UntisCacheFetchState? Get(string className, UntisCacheKind kind) =>
_all.FirstOrDefault(s => s.ClassName == className && s.Kind == kind);
public void Save(UntisCacheFetchState state)
{
_all.RemoveAll(s => s.ClassName == state.ClassName && s.Kind == state.Kind);
_all.Add(state);
}
}
public class FakeWorkTasks : IWorkTaskRepository public class FakeWorkTasks : IWorkTaskRepository
{ {
private readonly List<WorkTask> _all = []; private readonly List<WorkTask> _all = [];
public void Add(WorkTask t) => _all.Add(t); public void Add(WorkTask t) => _all.Add(t);
public List<WorkTask> GetByStatus(WorkTaskStatus status) => _all.Where(t => t.Status == status).ToList(); public List<WorkTask> GetByStatus(WorkTaskStatus status) => _all.Where(t => t.Status == status).ToList();
public List<WorkTask> GetAll() => _all.ToList(); public List<WorkTask> GetAll() => _all.ToList();
public List<WorkTask> GetByGroup(Guid groupId) => _all.Where(t => t.GroupId == groupId).ToList();
public void Save(WorkTask task) { _all.RemoveAll(t => t.Id == task.Id); _all.Add(task); } public void Save(WorkTask task) { _all.RemoveAll(t => t.Id == task.Id); _all.Add(task); }
public void Delete(Guid id) => _all.RemoveAll(t => t.Id == id); public void Delete(Guid id) => _all.RemoveAll(t => t.Id == id);
public void Restore(Guid trashId) { } // siehe FakeSeatingPlans.Restore
} }
public class FakeTimeEntries : ITimeEntryRepository public class FakeTimeEntries : ITimeEntryRepository
@@ -364,6 +547,7 @@ public class FakeTimeEntries : ITimeEntryRepository
public List<TimeEntry> GetByTask(Guid taskId) => _all.Where(e => e.TaskId == taskId).ToList(); public List<TimeEntry> GetByTask(Guid taskId) => _all.Where(e => e.TaskId == taskId).ToList();
public void Save(TimeEntry entry) { _all.RemoveAll(e => e.Id == entry.Id); _all.Add(entry); } public void Save(TimeEntry entry) { _all.RemoveAll(e => e.Id == entry.Id); _all.Add(entry); }
public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id); public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id);
public void Restore(Guid trashId) { } // siehe FakeSeatingPlans.Restore
} }
public class FakeReportGrades : IReportGradeRepository public class FakeReportGrades : IReportGradeRepository
@@ -0,0 +1,37 @@
using System.Globalization;
using LehrerApp.Desktop.Converters;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class FractionWidthConverterTests
{
[Fact]
public void Convert_MultipliziertTrackbreiteMitAnteil()
{
var result = FractionWidthConverter.Instance.Convert(
[200d, 0.25d], typeof(double), null, CultureInfo.InvariantCulture);
Assert.Equal(50d, result);
}
[Fact]
public void Convert_LiefertNullBreiteBeiUngueltigenWerten()
{
// Bounds.Width kann vor dem ersten Layout-Pass NaN sein (Element noch nicht gemessen) —
// darf nicht zu einer negativen oder undefinierten Breite führen.
Assert.Equal(0d, FractionWidthConverter.Instance.Convert(
[double.NaN, 0.5d], typeof(double), null, CultureInfo.InvariantCulture));
Assert.Equal(0d, FractionWidthConverter.Instance.Convert(
["not a double", 0.5d], typeof(double), null, CultureInfo.InvariantCulture));
}
[Fact]
public void Convert_ClamptNegativeErgebnisseAufNull()
{
var result = FractionWidthConverter.Instance.Convert(
[100d, -0.2d], typeof(double), null, CultureInfo.InvariantCulture);
Assert.Equal(0d, result);
}
}
@@ -85,7 +85,7 @@ public sealed class GenerateLessonSeriesDialogViewModelTests
} }
[Fact] [Fact]
public void Save_Doppelstunde_ErzeugtZweiStundenAmSelbenTagMitUnterschiedlicherStundennummer() public void Save_Doppelstunde_ErzeugtNurEineStundeVerankertAnDerErstenPeriode()
{ {
var groupId = Guid.NewGuid(); var groupId = Guid.NewGuid();
var unitId = Guid.NewGuid(); var unitId = Guid.NewGuid();
@@ -98,9 +98,50 @@ public sealed class GenerateLessonSeriesDialogViewModelTests
vm.SaveCommand.Execute(null); vm.SaveCommand.Execute(null);
Assert.Equal(1, vm.Result!.Created);
var created = lessons.GetByGroupAndRange(groupId, from, from).Select(l => l.LessonNumber).ToList();
Assert.Equal([3], created);
}
[Fact]
public void Save_Dreifachstunde_ErzeugtNurEineStundeVerankertAnDerErstenPeriode()
{
var groupId = Guid.NewGuid();
var unitId = Guid.NewGuid();
var from = new DateOnly(2025, 9, 1);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = from.DayOfWeek, PeriodNumber = 3 });
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = from.DayOfWeek, PeriodNumber = 4 });
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = from.DayOfWeek, PeriodNumber = 5 });
var lessons = new FakeLessons();
var vm = BuildVm(slots, lessons, new FakeSchoolHolidays(), unitId, groupId, from, from);
vm.SaveCommand.Execute(null);
Assert.Equal(1, vm.Result!.Created);
var created = lessons.GetByGroupAndRange(groupId, from, from).Select(l => l.LessonNumber).ToList();
Assert.Equal([3], created);
}
[Fact]
public void Save_ZweiGetrennteEinzelstundenAmSelbenTagBleibenGetrennt()
{
// Perioden 1 und 3 sind NICHT aufeinanderfolgend (Periode 2 fehlt) — keine Doppelstunde,
// beide bekommen eine eigene Lesson.
var groupId = Guid.NewGuid();
var unitId = Guid.NewGuid();
var from = new DateOnly(2025, 9, 1);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = from.DayOfWeek, PeriodNumber = 1 });
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = from.DayOfWeek, PeriodNumber = 3 });
var lessons = new FakeLessons();
var vm = BuildVm(slots, lessons, new FakeSchoolHolidays(), unitId, groupId, from, from);
vm.SaveCommand.Execute(null);
Assert.Equal(2, vm.Result!.Created); Assert.Equal(2, vm.Result!.Created);
var created = lessons.GetByGroupAndRange(groupId, from, from).Select(l => l.LessonNumber).OrderBy(n => n).ToList(); var created = lessons.GetByGroupAndRange(groupId, from, from).Select(l => l.LessonNumber).OrderBy(n => n).ToList();
Assert.Equal([3, 4], created); Assert.Equal([1, 3], created);
} }
[Fact] [Fact]
@@ -0,0 +1,103 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class GlobalSearchViewModelTests
{
[Fact]
public void LeereSuche_ZeigtSchnellaktionen()
{
var vm = BuildVm();
Assert.Collection(vm.Results,
item => Assert.Equal(GlobalSearchAction.NewTask, item.Action),
item => Assert.Equal(GlobalSearchAction.NewReminder, item.Action),
item => Assert.Equal(GlobalSearchAction.NewStudent, item.Action),
item => Assert.Equal(GlobalSearchAction.NewGroupDocumentation, item.Action));
Assert.Same(vm.Results[0], vm.SelectedResult);
}
[Fact]
public void Suche_FindetSchuelerGruppeKlausurUndAufgabe()
{
var group = new LearningGroup { Name = "9 Chemie", SchoolYear = "2026/27", GradeLevel = 9 };
var student = new Student { FirstName = "Mia", LastName = "Chemie" };
var exam = new Exam { GroupId = group.Id, Title = "Chemie-Test", Date = new DateOnly(2026, 9, 1) };
var tasks = new FakeWorkTasks();
tasks.Add(new WorkTask { GroupId = group.Id, Title = "Chemie-Klausur korrigieren" });
var vm = BuildVm([student], [group], [exam], tasks);
vm.Query = "Chemie";
Assert.Contains(vm.Results, x => x.Kind == GlobalSearchResultKind.Student && x.EntityId == student.Id);
Assert.Contains(vm.Results, x => x.Kind == GlobalSearchResultKind.Group && x.EntityId == group.Id);
Assert.Contains(vm.Results, x => x.Kind == GlobalSearchResultKind.Exam && x.EntityId == exam.Id);
Assert.Contains(vm.Results, x => x.Kind == GlobalSearchResultKind.Task && x.Title.Contains("korrigieren"));
}
[Fact]
public async Task TrefferAusfuehren_NavigiertUndSchliesstPalette()
{
var student = new Student { FirstName = "Mia", LastName = "Muster" };
var vm = BuildVm([student]);
vm.Query = "Muster";
var result = Assert.Single(vm.Results, x => x.Kind == GlobalSearchResultKind.Student);
GlobalSearchResult? navigated = null;
var closed = false;
vm.OnNavigate = x => navigated = x;
vm.OnClose = () => closed = true;
await vm.ExecuteCommand.ExecuteAsync(result);
Assert.Same(result, navigated);
Assert.True(closed);
}
[Fact]
public async Task ErinnerungSchnellErfassen_StartetDialogAlsErinnerung()
{
var vm = BuildVm();
bool? reminder = null;
vm.OnQuickAddTask = value => { reminder = value; return Task.CompletedTask; };
var action = vm.Results.Single(x => x.Action == GlobalSearchAction.NewReminder);
await vm.ExecuteCommand.ExecuteAsync(action);
Assert.True(reminder);
}
[Fact]
public void Suche_FindetAktiveGruppeUeberFachUndZeigtFachImUntertitel()
{
var subject = new Subject { Name = "Wirtschaft-Arbeit-Technik", ShortName = "WAT" };
var group = new LearningGroup { Name = "10c", SubjectId = subject.Id, SchoolYear = "2026/27", GradeLevel = 10 };
var vm = BuildVm(groups: [group], subjects: [subject]);
vm.Query = "wat";
var result = Assert.Single(vm.Results, x => x.Kind == GlobalSearchResultKind.Group);
Assert.Contains("WAT", result.Subtitle);
}
[Fact]
public void Suche_UeberspringtArchivierteSchuelerUndGruppenSamtDerenKlausuren()
{
var archivedGroup = new LearningGroup { Name = "Testkurs Vorjahr", IsActive = false };
var archivedStudent = new Student { FirstName = "Test", LastName = "Archiv", IsActive = false };
var exam = new Exam { GroupId = archivedGroup.Id, Title = "Testklausur" };
var vm = BuildVm([archivedStudent], [archivedGroup], [exam]);
vm.Query = "Test";
Assert.DoesNotContain(vm.Results, x => x.Kind is GlobalSearchResultKind.Student
or GlobalSearchResultKind.Group or GlobalSearchResultKind.Exam);
}
private static GlobalSearchViewModel BuildVm(List<Student>? students = null,
List<LearningGroup>? groups = null, List<Exam>? exams = null, FakeWorkTasks? tasks = null,
List<Subject>? subjects = null) =>
new(new FakeStudents(students ?? []), new FakeGroups(groups ?? []),
new FakeSubjects(subjects ?? []), new FakeExams(exams ?? []), tasks ?? new FakeWorkTasks());
}
@@ -18,14 +18,18 @@ public sealed class GroupDetailViewModelTests
var grades = new FakeGrades(); var grades = new FakeGrades();
var vm = new GroupDetailViewModel(groups, students, memberships, subjects, exams, grades, tasks, var vm = new GroupDetailViewModel(groups, students, memberships, subjects, exams, grades, tasks,
new GroupOverviewViewModel(new FakeLessons(), exams, new FakeResults(), new FakeSessions([]), new FakeEntries(), students,
new FakeDocumentation(), tasks, groups, new AttendanceBalanceService(), new GradingService(), new SchoolYearService()),
new ParticipationTabViewModel(new FakeSessions([]), new FakeEntries(), new FakeAspects(), new ParticipationTabViewModel(new FakeSessions([]), new FakeEntries(), new FakeAspects(),
students, memberships, groups, new FakeCompetencyDomains()), students, memberships, groups, new FakeCompetencyDomains()),
new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new GradingService()), new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new GradingService()),
new PlanningTabViewModel(new FakeUnits(), new FakeLessons(), groups, subjects, new PlanningTabViewModel(new FakeUnits(), new FakeLessons(), groups, subjects,
new FakeCompetencyDomains(), TestSupport.BuildAiSettingsService()), new FakeCompetencyDomains(), TestSupport.BuildAiSettingsService(), new FakeSessions([])),
new CompetencyOverviewTabViewModel(new FakeUnits(), exams, new FakeResults(), new CompetencyOverviewTabViewModel(new FakeUnits(), exams, new FakeResults(),
new FakeCompetencyDomains(), students, new CompetencyAnalysisService()), new FakeCompetencyDomains(), students, new CompetencyAnalysisService()),
new SeatingPlanTabViewModel(new FakeSeatingPlans(), students, memberships)); new SeatingPlanTabViewModel(new FakeSeatingPlans(), students, memberships,
new FakeSessions([]), new FakeEntries(), new FakeAspects()),
new GroupDocumentationTabViewModel(new FakeDocumentation(), students, groups));
vm.LoadGroup(group.Id); vm.LoadGroup(group.Id);
vm.SelectedExam = vm.Exams.First(e => e.Id == exam.Id); vm.SelectedExam = vm.Exams.First(e => e.Id == exam.Id);
@@ -0,0 +1,26 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Students;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class GroupDocumentationQuickViewModelTests
{
[Fact]
public void Save_ErstelltPlanungFuerGesamteLerngruppe()
{
var group = new GroupDocumentationOption(Guid.NewGuid(), "10c · WAT");
var vm = new GroupDocumentationQuickViewModel([group])
{
SelectedGroup = group, Title = "Klausur ankündigen", Content = "Termin abstimmen",
};
vm.SaveCommand.Execute(null);
Assert.NotNull(vm.Result);
Assert.Equal(Guid.Empty, vm.Result!.StudentId);
Assert.Equal(group.Id, vm.Result.GroupId);
Assert.Equal(DocumentationType.Planning, vm.Result.Type);
Assert.True(vm.Result.ExcludeFromWebUntisSync);
}
}
@@ -0,0 +1,166 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Students;
using Xunit;
namespace LehrerApp.Desktop.Tests;
/// Tests für den bisher als Platzhalter existierenden Gruppen-Tab "Dokumentation" (Nutzer-Feedback):
/// listet Dokumentationseinträge aller Gruppen-Schüler, zeigt Einträge aus anderen Lerngruppen
/// standardmäßig mit an (optisch abgesetzt statt versteckt), mit optionalem Filter darauf.
public sealed class GroupDocumentationTabViewModelTests
{
private static GroupDocumentationTabViewModel BuildVm(
List<Student> students, List<LearningGroup> groups, FakeDocumentation? docs = null)
{
var vm = new GroupDocumentationTabViewModel(docs ?? new FakeDocumentation(),
new FakeStudents(students), new FakeGroups(groups));
return vm;
}
[Fact]
public void Initialize_ZeigtEintraegeAllerGruppenschuelerSortiertNachDatumAbsteigend()
{
var group = new LearningGroup { Name = "9c" };
var anna = new Student { FirstName = "Anna", LastName = "Beispiel" };
var ben = new Student { FirstName = "Ben", LastName = "Muster" };
var docs = new FakeDocumentation();
docs.Add(new Documentation { StudentId = anna.Id, GroupId = group.Id, Title = "Alt", Date = new DateOnly(2025, 9, 1) });
docs.Add(new Documentation { StudentId = ben.Id, GroupId = group.Id, Title = "Neu", Date = new DateOnly(2025, 9, 10) });
var vm = BuildVm([anna, ben], [group], docs);
vm.Initialize(group.Id);
Assert.Equal(2, vm.Entries.Count);
Assert.Equal("Neu", vm.Entries[0].Model.Title);
Assert.Equal("Alt", vm.Entries[1].Model.Title);
}
[Fact]
public void Initialize_ZeigtGruppenweitenEintragAuchOhneSchueler()
{
var group = new LearningGroup { Name = "10c" };
var docs = new FakeDocumentation();
docs.Add(new Documentation
{
StudentId = Guid.Empty, GroupId = group.Id, Type = DocumentationType.Planning,
Title = "Klausur planen", Date = new DateOnly(2026, 9, 1),
});
var vm = BuildVm([], [group], docs);
vm.Initialize(group.Id);
var entry = Assert.Single(vm.Entries);
Assert.Equal("Gesamte Lerngruppe", entry.StudentName);
Assert.Equal("Planung / Erinnerung", entry.TypeLabel);
}
[Fact]
public void Initialize_EintragAusAndererGruppe_WirdMitangezeigtAberAlsFremdMarkiert()
{
var group = new LearningGroup { Name = "9c" };
var otherGroup = new LearningGroup { Name = "Mathematik 9b" };
var anna = new Student { FirstName = "Anna", LastName = "Beispiel" };
var docs = new FakeDocumentation();
docs.Add(new Documentation { StudentId = anna.Id, GroupId = otherGroup.Id, Title = "Aus anderem Kurs", Date = new DateOnly(2025, 9, 1) });
var vm = BuildVm([anna], [group, otherGroup], docs);
vm.Initialize(group.Id);
var entry = Assert.Single(vm.Entries);
Assert.False(entry.IsOwnGroup);
Assert.Equal("Mathematik 9b", entry.OtherGroupLabel);
Assert.True(entry.ContentOpacity < 1.0);
}
[Fact]
public void Initialize_EintragOhneGruppe_GiltAlsEigen()
{
var group = new LearningGroup { Name = "9c" };
var anna = new Student { FirstName = "Anna", LastName = "Beispiel" };
var docs = new FakeDocumentation();
docs.Add(new Documentation { StudentId = anna.Id, GroupId = null, Title = "Allgemein", Date = new DateOnly(2025, 9, 1) });
var vm = BuildVm([anna], [group], docs);
vm.Initialize(group.Id);
var entry = Assert.Single(vm.Entries);
Assert.True(entry.IsOwnGroup);
}
[Fact]
public void OnlyThisGroup_Aktiviert_BlendetEintraegeAusAnderenGruppenAus()
{
var group = new LearningGroup { Name = "9c" };
var otherGroup = new LearningGroup { Name = "Mathematik 9b" };
var anna = new Student { FirstName = "Anna", LastName = "Beispiel" };
var docs = new FakeDocumentation();
docs.Add(new Documentation { StudentId = anna.Id, GroupId = group.Id, Title = "Eigen", Date = new DateOnly(2025, 9, 1) });
docs.Add(new Documentation { StudentId = anna.Id, GroupId = otherGroup.Id, Title = "Fremd", Date = new DateOnly(2025, 9, 2) });
var vm = BuildVm([anna], [group, otherGroup], docs);
vm.Initialize(group.Id);
vm.OnlyThisGroup = true;
var entry = Assert.Single(vm.Entries);
Assert.Equal("Eigen", entry.Model.Title);
}
[Fact]
public void SelectedStudentFilter_AufEinzelnenSchueler_FiltertListe()
{
var group = new LearningGroup { Name = "9c" };
var anna = new Student { FirstName = "Anna", LastName = "Beispiel" };
var ben = new Student { FirstName = "Ben", LastName = "Muster" };
var docs = new FakeDocumentation();
docs.Add(new Documentation { StudentId = anna.Id, GroupId = group.Id, Title = "Anna-Eintrag", Date = new DateOnly(2025, 9, 1) });
docs.Add(new Documentation { StudentId = ben.Id, GroupId = group.Id, Title = "Ben-Eintrag", Date = new DateOnly(2025, 9, 1) });
var vm = BuildVm([anna, ben], [group], docs);
vm.Initialize(group.Id);
vm.SelectedStudentFilter = vm.StudentFilterOptions.Single(s => s.Id == anna.Id);
var entry = Assert.Single(vm.Entries);
Assert.Equal("Anna-Eintrag", entry.Model.Title);
}
[Fact]
public async Task AddDocumentation_SpeichertErgebnisUndLaedtNeu()
{
var group = new LearningGroup { Name = "9c" };
var anna = new Student { FirstName = "Anna", LastName = "Beispiel" };
var docs = new FakeDocumentation();
var vm = BuildVm([anna], [group], docs);
vm.Initialize(group.Id);
vm.OnEditDocumentation = (groupId, options, editing) =>
{
Assert.Equal(group.Id, groupId);
Assert.Contains(options, o => o.Id == anna.Id);
Assert.Null(editing);
return Task.FromResult<Documentation?>(new Documentation
{ StudentId = anna.Id, GroupId = groupId, Title = "Neu", Date = new DateOnly(2025, 9, 1) });
};
await vm.AddDocumentationCommand.ExecuteAsync(null);
var entry = Assert.Single(vm.Entries);
Assert.Equal("Neu", entry.Model.Title);
}
[Fact]
public async Task DeleteDocumentation_NachBestaetigung_EntferntEintragAusListe()
{
var group = new LearningGroup { Name = "9c" };
var anna = new Student { FirstName = "Anna", LastName = "Beispiel" };
var docs = new FakeDocumentation();
docs.Add(new Documentation { StudentId = anna.Id, GroupId = group.Id, Title = "Weg", Date = new DateOnly(2025, 9, 1) });
var vm = BuildVm([anna], [group], docs);
vm.Initialize(group.Id);
vm.OnConfirmDeleteDocumentation = _ => Task.FromResult(true);
await vm.DeleteDocumentationCommand.ExecuteAsync(vm.Entries[0]);
Assert.Empty(vm.Entries);
}
}
@@ -0,0 +1,73 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
/// <summary>Tests für die direkte, einlagige Lerngruppen-Navigation.</summary>
public sealed class GroupListViewModelTests
{
private static GroupListViewModel BuildVm(params LearningGroup[] groups) =>
new(new FakeGroups([.. groups]), new FakeSubjects([]), new SchoolYearService());
[Fact]
public void Gruppenkarte_OeffnetDirektDieUebersicht()
{
var group = CurrentGroup("9c");
var vm = BuildVm(group);
Guid? openedId = null;
int? openedTab = null;
vm.OnNavigateToDetail = (id, tab) => { openedId = id; openedTab = tab; };
Assert.Single(vm.Groups).OpenCommand.Execute(null);
Assert.Equal(group.Id, openedId);
Assert.Equal(0, openedTab);
}
[Fact]
public async Task Verwaltungsmenue_BearbeitetDieGewaehlteKarteOhneZwischenauswahl()
{
var first = CurrentGroup("9a");
var second = CurrentGroup("9b");
var vm = BuildVm(first, second);
Guid? editedId = null;
vm.OnEditGroup = id => { editedId = id; return Task.CompletedTask; };
var secondItem = vm.Groups.Single(x => x.Id == second.Id);
await secondItem.EditCommand.ExecuteAsync(null);
Assert.Equal(second.Id, editedId);
}
[Fact]
public void Suche_FiltertWeiterhinNachGruppenname()
{
var vm = BuildVm(CurrentGroup("9 Chemie"), CurrentGroup("10 Mathematik"));
vm.SearchText = "Chemie";
Assert.Equal("9 Chemie", Assert.Single(vm.Groups).Name);
}
[Fact]
public void Archivieren_WirktAufDieKarteUndEntferntSieAusDerAktivenListe()
{
var group = CurrentGroup("9c");
var vm = BuildVm(group);
var item = Assert.Single(vm.Groups);
item.ToggleArchiveCommand.Execute(null);
Assert.False(group.IsActive);
Assert.Empty(vm.Groups);
}
private static LearningGroup CurrentGroup(string name) => new()
{
Name = name,
SchoolYear = new SchoolYearService().CurrentSchoolYear(),
IsActive = true,
};
}
@@ -0,0 +1,451 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
/// Tests für die Kurs-Übersicht: sie liest bewusst keine eigenen Daten, sondern zieht nur
/// zusammen, was Planung/Klausuren/Mitarbeit/Dokumentation ohnehin schon verwalten.
public sealed class GroupOverviewViewModelTests
{
private static GroupOverviewViewModel NewVm(FakeLessons? lessons = null, FakeExams? exams = null,
FakeSessions? sessions = null, FakeEntries? entries = null, FakeStudents? students = null,
FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null,
FakeResults? results = null, FakeGroups? groups = null) =>
new(lessons ?? new FakeLessons(), exams ?? new FakeExams([]), results ?? new FakeResults(),
sessions ?? new FakeSessions([]),
entries ?? new FakeEntries(), students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(),
tasks ?? new FakeWorkTasks(), groups ?? new FakeGroups([]), new AttendanceBalanceService(),
new GradingService(), new SchoolYearService());
private static (GroupOverviewViewModel Vm, FakeLessons Lessons, FakeExams Exams,
FakeSessions Sessions, FakeEntries Entries, FakeStudents Students, Guid GroupId) BuildScenario(
List<Lesson>? lessons = null, List<Exam>? exams = null)
{
var groupId = Guid.NewGuid();
var lessonsRepo = new FakeLessons();
foreach (var l in lessons ?? []) lessonsRepo.Add(l);
var examsRepo = new FakeExams(exams ?? []);
var sessions = new FakeSessions([]);
var entries = new FakeEntries();
var students = new FakeStudents([]);
var vm = NewVm(lessonsRepo, examsRepo, sessions, entries, students);
vm.Initialize(groupId, "Testkurs");
return (vm, lessonsRepo, examsRepo, sessions, entries, students, groupId);
}
[Fact]
public void NaechsteStunde_FindetDieNaechsteGeplanteStundeInnerhalbDesZeitfensters()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var groupId = Guid.NewGuid();
var lessons = new FakeLessons();
lessons.Add(new Lesson { GroupId = groupId, Date = today.AddDays(10), Topic = "Später", Status = LessonStatus.Planned });
lessons.Add(new Lesson { GroupId = groupId, Date = today.AddDays(3), Topic = "Brechung", Status = LessonStatus.Planned });
lessons.Add(new Lesson { GroupId = groupId, Date = today.AddDays(1), Topic = "Schon vorbei", Status = LessonStatus.Conducted });
var vm = NewVm(lessons: lessons);
vm.Initialize(groupId, "Testkurs");
Assert.True(vm.HasNextLesson);
Assert.Contains("Brechung", vm.NextLessonLabel);
}
[Fact]
public void NaechsteStunde_OhneGeplanteStundeIstNichtVorhanden()
{
var (vm, _, _, _, _, _, _) = BuildScenario();
Assert.False(vm.HasNextLesson);
}
[Fact]
public void NaechsteKlausur_FindetFruehesteKuenftigeKlausur()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var groupId = Guid.NewGuid();
var exams = new FakeExams([
new Exam { GroupId = groupId, Date = today.AddDays(20), Title = "Später" },
new Exam { GroupId = groupId, Date = today.AddDays(5), Title = "Klassenarbeit 2" },
new Exam { GroupId = groupId, Date = today.AddDays(-3), Title = "Vergangen" },
]);
var vm = NewVm(exams: exams);
vm.Initialize(groupId, "Testkurs");
Assert.True(vm.HasNextExam);
Assert.Contains("Klassenarbeit 2", vm.NextExamLabel);
}
[Fact]
public void Vorjahresvergleich_OhneVerknuepfteVorgaengergruppe_BleibtVerborgen()
{
var groupId = Guid.NewGuid();
var groups = new FakeGroups([new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6 }]);
var vm = NewVm(groups: groups);
vm.Initialize(groupId, "Testkurs");
Assert.False(vm.HasYearComparison);
}
[Fact]
public void Vorjahresvergleich_VorgaengerOhneKlausurergebnisse_BleibtVerborgen()
{
var previousId = Guid.NewGuid();
var groupId = Guid.NewGuid();
var groups = new FakeGroups([
new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6, PreviousGroupId = previousId },
new LearningGroup { Id = previousId, GradingSystem = GradingSystem.Grades1To6 },
]);
var vm = NewVm(groups: groups);
vm.Initialize(groupId, "Testkurs");
Assert.False(vm.HasYearComparison);
}
[Fact]
public void Vorjahresvergleich_ZeigtDurchschnitteBeiderJahreAn()
{
var previousId = Guid.NewGuid();
var groupId = Guid.NewGuid();
var groups = new FakeGroups([
new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6, PreviousGroupId = previousId },
new LearningGroup { Id = previousId, GradingSystem = GradingSystem.Grades1To6 },
]);
var previousExam = new Exam { Id = Guid.NewGuid(), GroupId = previousId };
var currentExam = new Exam { Id = Guid.NewGuid(), GroupId = groupId };
var exams = new FakeExams([previousExam, currentExam]);
var results = new FakeResults();
results.Add(new ExamResult { ExamId = previousExam.Id, StudentId = Guid.NewGuid(), Grade = "2" });
results.Add(new ExamResult { ExamId = previousExam.Id, StudentId = Guid.NewGuid(), Grade = "4" });
results.Add(new ExamResult { ExamId = currentExam.Id, StudentId = Guid.NewGuid(), Grade = "1" });
// Abwesende und leere Ergebnisse fließen bewusst nicht in den Schnitt ein.
results.Add(new ExamResult { ExamId = currentExam.Id, StudentId = Guid.NewGuid(), Absent = true, Grade = "6" });
var vm = NewVm(exams: exams, results: results, groups: groups);
vm.Initialize(groupId, "Testkurs");
Assert.True(vm.HasYearComparison);
Assert.Contains("Dieses Jahr: Ø 1,0 (1 Klausuren)", vm.YearComparisonLabel);
Assert.Contains("Vorjahr: Ø 3,0 (2 Klausuren)", vm.YearComparisonLabel);
}
[Fact]
public void Vorjahresvergleich_OhneEigeneKlausurenZeigtNurVorjahr()
{
var previousId = Guid.NewGuid();
var groupId = Guid.NewGuid();
var groups = new FakeGroups([
new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6, PreviousGroupId = previousId },
new LearningGroup { Id = previousId, GradingSystem = GradingSystem.Grades1To6 },
]);
var previousExam = new Exam { Id = Guid.NewGuid(), GroupId = previousId };
var exams = new FakeExams([previousExam]);
var results = new FakeResults();
results.Add(new ExamResult { ExamId = previousExam.Id, StudentId = Guid.NewGuid(), Grade = "3" });
var vm = NewVm(exams: exams, results: results, groups: groups);
vm.Initialize(groupId, "Testkurs");
Assert.True(vm.HasYearComparison);
Assert.Contains("noch keine Klausur", vm.YearComparisonLabel);
}
[Fact]
public void Mitarbeit_OhneSitzungenZeigtHinweisOhneWarnung()
{
var (vm, _, _, _, _, _, _) = BuildScenario();
Assert.Contains("Noch keine Mitarbeitssitzung", vm.ParticipationHintLabel);
Assert.False(vm.ParticipationHintIsStale);
}
[Fact]
public void Mitarbeit_LetzteSitzungVorMehrAls14TagenGiltAlsVeraltet()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var groupId = Guid.NewGuid();
var sessions = new FakeSessions([new ParticipationSession { GroupId = groupId, Date = today.AddDays(-20) }]);
var vm = NewVm(sessions: sessions);
vm.Initialize(groupId, "Testkurs");
Assert.True(vm.ParticipationHintIsStale);
}
[Fact]
public void Mitarbeit_LetzteSitzungKuerzlichGiltNichtAlsVeraltet()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var groupId = Guid.NewGuid();
var sessions = new FakeSessions([new ParticipationSession { GroupId = groupId, Date = today.AddDays(-2) }]);
var vm = NewVm(sessions: sessions);
vm.Initialize(groupId, "Testkurs");
Assert.False(vm.ParticipationHintIsStale);
}
[Fact]
public void OffeneHausaufgabenKontrolle_ZeigtUnkontrollierteHausaufgabeDerLetztenStunde()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var groupId = Guid.NewGuid();
var lessons = new FakeLessons();
lessons.Add(new Lesson
{
GroupId = groupId, Date = today.AddDays(-2), Homework = "S. 42 Nr. 3",
HomeworkChecked = false, HomeworkCheckDismissed = false,
});
var vm = NewVm(lessons: lessons);
vm.Initialize(groupId, "Testkurs");
Assert.True(vm.HasOpenHomeworkCheck);
Assert.Contains("S. 42 Nr. 3", vm.OpenHomeworkCheckLabel);
}
[Fact]
public void OffeneHausaufgabenKontrolle_VerschwindetSobaldKontrolliert()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var groupId = Guid.NewGuid();
var lessons = new FakeLessons();
lessons.Add(new Lesson
{
GroupId = groupId, Date = today.AddDays(-2), Homework = "S. 42 Nr. 3",
HomeworkChecked = true,
});
var vm = NewVm(lessons: lessons);
vm.Initialize(groupId, "Testkurs");
Assert.False(vm.HasOpenHomeworkCheck);
}
[Fact]
public void OffeneEntschuldigungen_ListetOffenePendenteEntschuldigungenUndErlaubtAufloesen()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var groupId = Guid.NewGuid();
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
var session = new ParticipationSession { GroupId = groupId, Date = today.AddDays(-2) };
var sessions = new FakeSessions([session]);
var entries = new FakeEntries();
var entry = new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = AttendanceStatus.ExcusePending };
entries.Add(entry);
var students = new FakeStudents([student]);
var vm = NewVm(sessions: sessions, entries: entries, students: students);
vm.Initialize(groupId, "Testkurs");
var item = Assert.Single(vm.OpenExcuses);
Assert.Equal(student.FullName, item.StudentName);
item.MarkExcusedCommand.Execute(null);
Assert.Empty(vm.OpenExcuses);
Assert.Equal(AttendanceStatus.Excused, entries.GetBySessionAndStudent(session.Id, student.Id)!.Attendance);
}
[Fact]
public void OffeneDokumentationen_ZaehltNurEntwuerfeDieserGruppeOderOhneGruppe()
{
var groupId = Guid.NewGuid();
var otherGroupId = Guid.NewGuid();
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
var students = new FakeStudents([student]);
var documentation = new FakeDocumentation();
documentation.Add(new Documentation { StudentId = student.Id, GroupId = groupId, IsDraft = true, Title = "Beobachtung" });
documentation.Add(new Documentation { StudentId = student.Id, GroupId = null, IsDraft = true, Title = "Ohne Gruppe" });
documentation.Add(new Documentation { StudentId = student.Id, GroupId = otherGroupId, IsDraft = true, Title = "Anderer Kurs" });
documentation.Add(new Documentation { StudentId = student.Id, GroupId = groupId, IsDraft = false, Title = "Fertig" });
var vm = NewVm(students: students, documentation: documentation);
vm.Initialize(groupId, "Testkurs");
Assert.Equal(2, vm.DraftDocumentationCount);
}
[Fact]
public void AuffaelligeFehlzeiten_WirdErstAbMindeststichprobeGemeldet()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var groupId = Guid.NewGuid();
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
var students = new FakeStudents([student]);
var sessions = new FakeSessions([]);
var entries = new FakeEntries();
// Nur 3 erfasste Termine, davon 3 unentschuldigt (100 %) — trotz hoher Quote noch keine
// Meldung, weil die Stichprobe zu klein ist (Schuljahresbeginn-Problem).
for (var i = 0; i < 3; i++)
{
var session = new ParticipationSession { GroupId = groupId, Date = today.AddDays(-i - 1) };
sessions.Save(session);
entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = AttendanceStatus.Unexcused });
}
var vm = NewVm(sessions: sessions, entries: entries, students: students);
vm.Initialize(groupId, "Testkurs");
Assert.False(vm.HasAttendanceNotices);
}
[Fact]
public void AuffaelligeFehlzeiten_MeldetHoheQuoteAbMindeststichprobe()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var groupId = Guid.NewGuid();
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
var students = new FakeStudents([student]);
var sessions = new FakeSessions([]);
var entries = new FakeEntries();
// 10 erfasste Termine, 3 davon unentschuldigt (30 % > 20 % Schwelle) — jetzt genug
// Stichprobe für eine Meldung.
for (var i = 0; i < 10; i++)
{
var session = new ParticipationSession { GroupId = groupId, Date = today.AddDays(-i - 1) };
sessions.Save(session);
var status = i < 3 ? AttendanceStatus.Unexcused : AttendanceStatus.Present;
entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = status });
}
var vm = NewVm(sessions: sessions, entries: entries, students: students);
vm.Initialize(groupId, "Testkurs");
var item = Assert.Single(vm.AttendanceNotices);
Assert.Equal(student.FullName, item.StudentName);
Assert.Equal(30.0, item.AbsenceRatePercent);
}
[Fact]
public void NavigationCommands_RufenDelegatMitPassendemTabIndexAuf()
{
var (vm, _, _, _, _, _, _) = BuildScenario();
var indices = new List<int>();
vm.OnNavigateToTab = idx => indices.Add(idx);
vm.NavigateToPlanningCommand.Execute(null);
vm.NavigateToExamsCommand.Execute(null);
vm.NavigateToParticipationCommand.Execute(null);
vm.NavigateToDocumentationCommand.Execute(null);
Assert.Equal([6, 4, 3, 8], indices);
}
[Fact]
public void FehlendeHausaufgaben_ListetNurSchuelerAusDerLetztenSitzung()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var groupId = Guid.NewGuid();
var studentMissing = new Student { FirstName = "Anna", LastName = "Beispiel" };
var studentDone = new Student { FirstName = "Ben", LastName = "Muster" };
var students = new FakeStudents([studentMissing, studentDone]);
var sessions = new FakeSessions([]);
var entries = new FakeEntries();
var olderSession = new ParticipationSession { GroupId = groupId, Date = today.AddDays(-10) };
sessions.Save(olderSession);
entries.Add(new ParticipationEntry
{
SessionId = olderSession.Id, StudentId = studentDone.Id,
Homework = HomeworkStatus.MissingOpen, HomeworkMissing = true,
});
var lastSession = new ParticipationSession { GroupId = groupId, Date = today.AddDays(-1) };
sessions.Save(lastSession);
entries.Add(new ParticipationEntry
{
SessionId = lastSession.Id, StudentId = studentMissing.Id,
Homework = HomeworkStatus.MissingOverdue, HomeworkMissing = true,
});
entries.Add(new ParticipationEntry
{
SessionId = lastSession.Id, StudentId = studentDone.Id,
Homework = HomeworkStatus.Completed, HomeworkMissing = false,
});
var vm = NewVm(sessions: sessions, entries: entries, students: students);
vm.Initialize(groupId, "Testkurs");
var item = Assert.Single(vm.MissingHomeworkStudents);
Assert.Equal(studentMissing.FullName, item.StudentName);
Assert.Contains(lastSession.Date.ToString("dd.MM.yyyy"), vm.MissingHomeworkSessionLabel);
}
[Fact]
public void FehlendeHausaufgaben_OhneOffenePostenIstLeer()
{
var (vm, _, _, _, _, _, _) = BuildScenario();
Assert.False(vm.HasMissingHomework);
}
[Fact]
public void AnstehendeAufgaben_ZeigtNurOffeneAufgabenDieserGruppeSortiertNachFaelligkeit()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var groupId = Guid.NewGuid();
var otherGroupId = Guid.NewGuid();
var tasks = new FakeWorkTasks();
tasks.Add(new WorkTask { GroupId = groupId, Title = "Später fällig", DueDate = today.AddDays(10), Status = WorkTaskStatus.Open });
tasks.Add(new WorkTask { GroupId = groupId, Title = "Bald fällig", DueDate = today.AddDays(1), Status = WorkTaskStatus.Open });
tasks.Add(new WorkTask { GroupId = groupId, Title = "Erledigt", DueDate = today, Status = WorkTaskStatus.Done });
tasks.Add(new WorkTask { GroupId = otherGroupId, Title = "Anderer Kurs", DueDate = today, Status = WorkTaskStatus.Open });
var vm = NewVm(tasks: tasks);
vm.Initialize(groupId, "Testkurs");
Assert.True(vm.HasGroupTasks);
Assert.Equal(["Bald fällig", "Später fällig"], vm.GroupTasks.Select(t => t.Title));
}
[Fact]
public void AnstehendeAufgaben_OhneAufgabenIstLeer()
{
var (vm, _, _, _, _, _, _) = BuildScenario();
Assert.False(vm.HasGroupTasks);
}
[Fact]
public void NavigateToWorkload_RuftDelegatAuf()
{
var (vm, _, _, _, _, _, _) = BuildScenario();
var called = false;
vm.OnNavigateToWorkload = () => called = true;
vm.NavigateToWorkloadCommand.Execute(null);
Assert.True(called);
}
[Fact]
public async Task AddGroupTask_SpeichertErgebnisMitGruppeUndAktualisiertListe()
{
var tasks = new FakeWorkTasks();
var (vm, _, _, _, _, _, groupId) = BuildScenario();
var vmWithTasks = NewVm(tasks: tasks);
vmWithTasks.Initialize(groupId, "Testkurs");
vmWithTasks.OnAddGroupTask = requestedGroupId =>
Task.FromResult<WorkTask?>(new WorkTask { Title = "Material einsammeln", GroupId = requestedGroupId });
await vmWithTasks.AddGroupTaskCommand.ExecuteAsync(null);
var saved = Assert.Single(tasks.GetByGroup(groupId));
Assert.Equal("Material einsammeln", saved.Title);
Assert.True(vmWithTasks.HasGroupTasks);
Assert.Equal("Material einsammeln", Assert.Single(vmWithTasks.GroupTasks).Title);
}
[Fact]
public async Task AddGroupTask_OhneDelegatMachtNichts()
{
var (vm, _, _, _, _, _, _) = BuildScenario();
await vm.AddGroupTaskCommand.ExecuteAsync(null);
Assert.False(vm.HasGroupTasks);
}
}

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