From 27359794f7e75dbc8f0cd7543443e46adf4f0bc3 Mon Sep 17 00:00:00 2001 From: Baddi86 Date: Thu, 13 Aug 2026 19:57:48 +0200 Subject: [PATCH] Review der Codebase --- LehrerApp.Data.Tests/RepositoryTests.cs | 24 +++++ LehrerApp.Data/LiteDbContext.cs | 15 +++ .../Repositories/AllRepositories.cs | 86 ++++++++++++----- LehrerApp.Desktop/App.axaml.cs | 39 +++++++- LehrerApp.Desktop/AppBootstrapper.cs | 2 +- .../Services/GlobalExceptionHandler.cs | 20 ++-- .../ViewModels/MainWindowViewModel.cs | 19 ++++ LehrerApp.Desktop/Views/MainWindow.axaml | 94 +++++++++++-------- TODO.md | 50 +++++++++- 9 files changed, 269 insertions(+), 80 deletions(-) diff --git a/LehrerApp.Data.Tests/RepositoryTests.cs b/LehrerApp.Data.Tests/RepositoryTests.cs index d5c1af2..ca7b3eb 100644 --- a/LehrerApp.Data.Tests/RepositoryTests.cs +++ b/LehrerApp.Data.Tests/RepositoryTests.cs @@ -102,6 +102,24 @@ public sealed class RepositoryTests var aspectId = Guid.NewGuid(); db.ParticipationAspects.Insert(new ParticipationAspect { Id = aspectId, GroupId = groupId, Key = "quality" }); + var reportGradeId = Guid.NewGuid(); + db.ReportGrades.Insert(new ReportGrade { Id = reportGradeId, GroupId = groupId, StudentId = studentId }); + + var gradingSchemeId = Guid.NewGuid(); + db.GradingSchemes.Insert(new GradingScheme { Id = gradingSchemeId, GroupId = groupId }); + + var sectionId = Guid.NewGuid(); + db.ParticipationSections.Insert(new ParticipationSection { Id = sectionId, GroupId = groupId }); + + var documentationId = Guid.NewGuid(); + db.Documentation.Insert(new Documentation { Id = documentationId, GroupId = groupId, StudentId = studentId }); + + var taskId = Guid.NewGuid(); + db.Tasks.Insert(new WorkTask { Id = taskId, GroupId = groupId }); + + var timeEntryId = Guid.NewGuid(); + db.TimeEntries.Insert(new TimeEntry { Id = timeEntryId, GroupId = groupId, TaskId = taskId }); + groupRepo.Delete(groupId); Assert.Null(db.Groups.FindById(groupId)); @@ -115,6 +133,12 @@ public sealed class RepositoryTests Assert.Null(db.ParticipationSessions.FindById(sessionId)); Assert.Null(db.ParticipationEntries.FindById(entryId)); Assert.Null(db.ParticipationAspects.FindById(aspectId)); + Assert.Null(db.ReportGrades.FindById(reportGradeId)); + Assert.Null(db.GradingSchemes.FindById(gradingSchemeId)); + Assert.Null(db.ParticipationSections.FindById(sectionId)); + Assert.Null(db.Documentation.FindById(documentationId)?.GroupId); + Assert.Null(db.Tasks.FindById(taskId)?.GroupId); + Assert.Null(db.TimeEntries.FindById(timeEntryId)?.GroupId); } [Fact] diff --git a/LehrerApp.Data/LiteDbContext.cs b/LehrerApp.Data/LiteDbContext.cs index e6041fc..1cfcb1d 100644 --- a/LehrerApp.Data/LiteDbContext.cs +++ b/LehrerApp.Data/LiteDbContext.cs @@ -60,6 +60,21 @@ public class LiteDbContext : IDisposable public int SchemaVersion => ReadSchemaVersion(); + internal void ExecuteInTransaction(Action action) + { + _db.BeginTrans(); + try + { + action(); + _db.Commit(); + } + catch + { + _db.Rollback(); + throw; + } + } + private void RunVersionedMigrations() { var version = ReadSchemaVersion(); diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs index 9f72e71..ac6dd28 100644 --- a/LehrerApp.Data/Repositories/AllRepositories.cs +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -40,40 +40,74 @@ public class GroupRepository(LiteDbContext db) : IGroupRepository } public void Delete(Guid id) { - 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()) + db.ExecuteInTransaction(() => { - 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 membership in db.Memberships.Find(e => e.GroupId == id).ToList()) + db.Memberships.Delete(membership.Id); - foreach (var grade in db.Grades.Find(g => g.GroupId == id).ToList()) - db.Grades.Delete(grade.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 unit in db.Units.Find(u => u.GroupId == id).ToList()) - { - foreach (var lesson in db.Lessons.Find(l => l.UnitId == unit.Id).ToList()) + 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); - 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 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 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); - db.Groups.Delete(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); + }); } } diff --git a/LehrerApp.Desktop/App.axaml.cs b/LehrerApp.Desktop/App.axaml.cs index 549079e..46dabef 100644 --- a/LehrerApp.Desktop/App.axaml.cs +++ b/LehrerApp.Desktop/App.axaml.cs @@ -15,6 +15,8 @@ namespace LehrerApp.Desktop; public class App : Application { public static IServiceProvider Services { get; private set; } = null!; + private static ServiceProvider? _serviceProvider; + private static bool _exitHandlerAttached; public override void Initialize() => AvaloniaXamlLoader.Load(this); @@ -32,31 +34,58 @@ public class App : Application promptVm.OnUnlocked = password => { AppBootstrapper.DbPassword = password; - StartMainApp(desktop); + StartMainApp(desktop, showImmediately: true); promptWindow.Close(); }; desktop.MainWindow = promptWindow; } else { - StartMainApp(desktop); + StartMainApp(desktop, showImmediately: false); } } base.OnFrameworkInitializationCompleted(); } - private static void StartMainApp(IClassicDesktopStyleApplicationLifetime desktop) + private static void StartMainApp( + IClassicDesktopStyleApplicationLifetime desktop, bool showImmediately) { - Services = AppBootstrapper.BuildServices(); + _serviceProvider = AppBootstrapper.BuildServices(); + Services = _serviceProvider; Services.GetRequiredService().Info("Anwendung gestartet."); GlobalExceptionHandler.AttachNotifications(Services.GetRequiredService()); + if (!_exitHandlerAttached) + { + desktop.Exit += (_, _) => DisposeServices(); + _exitHandlerAttached = true; + } + var mainVm = Services.GetRequiredService(); WireCallbacks(mainVm); var main = new MainWindow { DataContext = mainVm }; desktop.MainWindow = main; - main.Show(); + if (showImmediately) main.Show(); + } + + private static void DisposeServices() + { + if (_serviceProvider is null) return; + + try + { + _serviceProvider.GetService()?.Checkpoint(); + } + catch (Exception ex) + { + AppBootstrapper.Logger.Error("Datenbank-Checkpoint beim Beenden fehlgeschlagen.", ex); + } + finally + { + _serviceProvider.Dispose(); + _serviceProvider = null; + } } private static void WireCallbacks(MainWindowViewModel main) diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index 976bd7a..034ed2c 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -76,7 +76,7 @@ public static class AppBootstrapper Environment.Exit(0); } - public static IServiceProvider BuildServices() + public static ServiceProvider BuildServices() { var services = new ServiceCollection(); diff --git a/LehrerApp.Desktop/Services/GlobalExceptionHandler.cs b/LehrerApp.Desktop/Services/GlobalExceptionHandler.cs index 02a788d..d72e41e 100644 --- a/LehrerApp.Desktop/Services/GlobalExceptionHandler.cs +++ b/LehrerApp.Desktop/Services/GlobalExceptionHandler.cs @@ -7,10 +7,10 @@ namespace LehrerApp.Desktop.Services; /// Zentrale Exception-Behandlung (13.2.1): protokolliert jeden unerwarteten Fehler und zeigt /// eine verständliche Meldung statt eines rohen Absturzes. /// -/// Reichweite ehrlich betrachtet: .UIThread.UnhandledException kann -/// Ausnahmen aus Befehlen/Ereignis-Handlern auf dem UI-Thread abfangen und die App am Leben -/// erhalten (Handled = true) — das deckt den weit überwiegenden Teil realer Abstürze in einer -/// Desktop-App ab. AppDomain.UnhandledException und TaskScheduler.UnobservedTaskException sind +/// .UIThread.UnhandledException fängt Fehler aus Befehlen und +/// Ereignis-Handlern ab. Nur erwartbar wiederherstellbare I/O-, Netzwerk-, Timeout- und +/// Abbruchfehler werden behandelt; unbekannte Zustandsfehler dürfen die App beenden. +/// AppDomain.UnhandledException und TaskScheduler.UnobservedTaskException sind /// Sicherheitsnetze für Fehler außerhalb des UI-Threads; die App kann eine "IsTerminating"- /// Ausnahme dort nicht mehr verhindern, aber wenigstens vollständig protokollieren, bevor sie endet. /// @@ -36,10 +36,18 @@ public static class GlobalExceptionHandler private static void OnDispatcherUnhandledException(object? sender, DispatcherUnhandledExceptionEventArgs e) { _logger?.Error("Unbehandelter Fehler auf dem UI-Thread.", e.Exception); - _notifications?.ShowError("Es ist ein unerwarteter Fehler aufgetreten. Details wurden protokolliert."); - e.Handled = true; // App am Leben halten statt abzustürzen. + e.Handled = IsRecoverable(e.Exception); + if (e.Handled) + _notifications?.ShowError("Der Vorgang ist fehlgeschlagen. Details wurden protokolliert."); } + private static bool IsRecoverable(Exception exception) => exception is + IOException or + UnauthorizedAccessException or + HttpRequestException or + TimeoutException or + OperationCanceledException; + private static void OnAppDomainUnhandledException(object sender, UnhandledExceptionEventArgs e) { _logger?.Error( diff --git a/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs b/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs index 6d90991..0d5388b 100644 --- a/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs @@ -22,6 +22,14 @@ public partial class MainWindowViewModel : ObservableObject public ObservableCollection Toasts { get; } public AppLockViewModel AppLock { get; } + public bool IsDashboardActive => ActiveNavItem == NavItem.Dashboard; + public bool IsGroupsActive => ActiveNavItem == NavItem.Groups; + public bool IsStudentsActive => ActiveNavItem == NavItem.Students; + public bool IsExamsActive => ActiveNavItem == NavItem.Exams; + public bool IsPlannerActive => ActiveNavItem == NavItem.Planner; + public bool IsWorkloadActive => ActiveNavItem == NavItem.Workload; + public bool IsSettingsActive => ActiveNavItem == NavItem.Settings; + public MainWindowViewModel(IServiceProvider services, DashboardViewModel dashboard, SchoolYearService sy, SyncStatusViewModel syncStatus, NotificationService notifications, AppLockViewModel appLock) @@ -35,6 +43,17 @@ public partial class MainWindowViewModel : ObservableObject AppLock.ApplyConfig(); } + partial void OnActiveNavItemChanged(NavItem value) + { + OnPropertyChanged(nameof(IsDashboardActive)); + OnPropertyChanged(nameof(IsGroupsActive)); + OnPropertyChanged(nameof(IsStudentsActive)); + OnPropertyChanged(nameof(IsExamsActive)); + OnPropertyChanged(nameof(IsPlannerActive)); + OnPropertyChanged(nameof(IsWorkloadActive)); + OnPropertyChanged(nameof(IsSettingsActive)); + } + [RelayCommand] private void NavigateTo(NavItem item) { diff --git a/LehrerApp.Desktop/Views/MainWindow.axaml b/LehrerApp.Desktop/Views/MainWindow.axaml index dcd50eb..6cc0970 100644 --- a/LehrerApp.Desktop/Views/MainWindow.axaml +++ b/LehrerApp.Desktop/Views/MainWindow.axaml @@ -60,6 +60,15 @@ überschreiben kann – lokale Werte hätten immer Vorrang vor Style-Settern. --> + + + + + - @@ -106,14 +126,14 @@ - - @@ -121,43 +141,43 @@ - - - - @@ -165,23 +185,23 @@ - - diff --git a/TODO.md b/TODO.md index 4f6c28f..6d9f84e 100644 --- a/TODO.md +++ b/TODO.md @@ -415,7 +415,10 @@ Hinweis in Kapitel 1 — betrifft auch Kurse, nicht nur Klassen. ### 7.2 Gruppen - [x] **7.2.1** Gruppe bearbeiten und löschen — bereits vorhanden (`EditGroupCommand`/`DeleteGroupCommand`/ `ToggleArchiveCommand` in `GroupListViewModel`), nicht Teil der aktuellen Klausuren-Arbeit, - beim Review aber bestätigt. + beim Review aber bestätigt. Die Löschung läuft transaktional und entfernt alle fachlich + abhängigen Datensätze (inkl. Zeugnisnoten, gruppenspezifischem Notenschema und + Mitarbeitsabschnitten). Dokumentation, Aufgaben und Zeiteinträge bleiben als historische + Nachweise erhalten; ihr `GroupId` wird auf `null` gesetzt. - [ ] **7.2.2** Gruppe ins neue Schuljahr übernehmen: Kopie mit gleicher Schülerschaft, neues `SchoolYear`, neue `GroupMembership`-Einträge. - [ ] **7.2.3** Schüler aus einer Gruppe entfernen (`GroupMembership.LeftAt` setzen statt löschen). @@ -487,6 +490,8 @@ ist aber nur aktiv, wenn eine Server-URL konfiguriert ist. - [ ] **10.1.3** Konfliktanzeige in der UI — was `ConflictResolver` entscheidet, muss sichtbar sein. - [ ] **10.1.4** Manuelles Auslösen einer vollständigen Synchronisation. - [ ] **10.1.5** Statusanzeige erweitern: letzter Sync, Anzahl wartender Events, Fehlerzustand. +- [ ] **10.1.6** Lokale Schreibvorgänge atomar an die Outbox (`EventQueue`) anbinden — aktuell ist + das Sync-Grundgerüst registriert, die Repositories erzeugen aber noch keine Sync-Ereignisse. ### 10.2 Server - [ ] **10.2.1** Benutzerverwaltung/Registrierung prüfen und absichern @@ -579,10 +584,11 @@ Fächer- und Kompetenzverwaltung existiert bereits in ### 13.2 Fehlerbehandlung & Logging - [x] **13.2.1** Zentrale Exception-Behandlung mit verständlicher Fehlermeldung statt Absturz — [GlobalExceptionHandler.cs](LehrerApp.Desktop/Services/GlobalExceptionHandler.cs). - `Dispatcher.UIThread.UnhandledException` fängt Fehler aus Befehlen/Ereignis-Handlern auf - dem UI-Thread ab, protokolliert sie und setzt `Handled = true` — die App stürzt dabei - nachweislich nicht ab (per Headless-Test verifiziert: Fehler in einem Dispatcher-Callback - wird geloggt + als Toast angezeigt, App läuft weiter). `AppDomain.UnhandledException` und + `Dispatcher.UIThread.UnhandledException` protokolliert Fehler aus Befehlen/Ereignis-Handlern. + Nur als wiederherstellbar eingestufte I/O-, Netzwerk-, Timeout- und Abbruchfehler werden mit + `Handled = true` behandelt und als Toast angezeigt. Unbekannte Programmier-/Zustandsfehler + dürfen die Anwendung kontrolliert beenden, statt mit möglicherweise beschädigtem Zustand + weiterzulaufen. `AppDomain.UnhandledException` und `TaskScheduler.UnobservedTaskException` sind Sicherheitsnetze für Fehler außerhalb des UI-Threads — dort kann ein bereits "IsTerminating"-Fehler nicht mehr verhindert werden, wird aber vollständig protokolliert. @@ -664,6 +670,10 @@ Fächer- und Kompetenzverwaltung existiert bereits in zeigt `ProcessPath` auf den `dotnet`-Host statt auf die App, ein Neustart über die Einstellungen ist dort also nur in einer veröffentlichten Build (`dotnet publish`) sinnvoll zu testen. +- [x] **13.3.6** Sauberer Desktop-Lifecycle — beim Beenden wird ein LiteDB-Checkpoint ausgeführt + und anschließend der DI-Container samt Singleton-Datenbankverbindung entsorgt. Das initiale + Hauptfenster wird vom Avalonia-Desktop-Lifetime angezeigt; ein explizites `Show()` erfolgt + nur beim späteren Wechsel vom Passwortfenster zur Hauptansicht. ### 13.4 Codepflege - [x] **13.4.1** `CLAUDE.md` mit Projektkonventionen anlegen (`/init`) — [CLAUDE.md](CLAUDE.md). @@ -706,6 +716,16 @@ Fächer- und Kompetenzverwaltung existiert bereits in - [ ] **14.5** Leere Zustände mit Handlungsaufforderung statt leerer Tabellen. - [ ] **14.6** Fenstergröße und Spaltenbreiten über Sitzungen hinweg merken. - [ ] **14.7** Bedienung auf Touch-Geräten prüfen (Tablet im Unterricht). +- [ ] **14.8** Responsive Layout und Windows-DPI prüfen (kleine Notebook-Auflösungen sowie + 125/150/200 % Skalierung; starre Master-Detail-Spalten bei Bedarf stapeln). Der kompakte + Drawer berücksichtigt bereits die schmalere verfügbare Breite mit reduziertem Außen-/ + Innenabstand und einer eigenen Iconfläche, damit Windows-Emoji nicht abgeschnitten werden. +- [ ] **14.9** Barrierefreiheit prüfen: Automation-Namen für Icon-Buttons, sichtbare Fokusrahmen, + Kontraste und Status nicht ausschließlich über Farbe/Emoji vermitteln. +- [ ] **14.10** Plattformübergreifend konsistentes SVG-/`PathIcon`-Set statt systemabhängiger + Emoji-Darstellung einführen. +- [x] **14.11** Aktiven Navigationspunkt in der Seitenleiste sichtbar hervorheben; Zustand wird + über `MainWindowViewModel.ActiveNavItem` gesteuert. --- @@ -719,6 +739,26 @@ Fächer- und Kompetenzverwaltung existiert bereits in --- +## 16. Datenmodell- und Architekturqualität + +- [ ] **16.1** Referenzielle Integrität für alle Modellbeziehungen dokumentieren und je Beziehung + explizit `Cascade`, `Restrict`, `SetNull` oder Archivierung festlegen; Löschpfade mit + Transaktions- und Vollständigkeitstests absichern. +- [ ] **16.2** Domain-Validierung aus den Dialog-ViewModels in gemeinsam nutzbare Regeln/Services + überführen, damit Import, Sync und API dieselben Regeln durchsetzen. +- [ ] **16.3** Prüfen, ob Punkte, Gewichtungen und Prozentgrenzen von `double` auf `decimal` oder + skalierte Ganzzahlen migriert werden sollen; Rundungs- und Migrationsstrategie festlegen. +- [ ] **16.4** Redundant gespeicherte Bewertungswerte (`ExamResult.TotalPoints`, berechnete Note) + entweder ableiten oder zusammen mit einer unveränderlichen Version des verwendeten + Notenschlüssels als historischen Snapshot speichern. +- [ ] **16.5** Kompetenzzuordnungen auf stabile `CompetencyItem.Id` umstellen; Code/Beschreibung bei + Bedarf zusätzlich als historischen Snapshot speichern, damit Umbenennungen keine alten + Klausur- oder Sitzungsreferenzen brechen. +- [ ] **16.6** Navigation und manuelle Callback-Verdrahtung in `App.axaml.cs` langfristig durch + einen testbaren Navigationsdienst oder Messenger ersetzen; statischen Servicezugriff abbauen. + +--- + ## Empfohlene Reihenfolge Die Abschnitte sind thematisch, nicht chronologisch nummeriert. Sinnvolle Bearbeitungsreihenfolge: