From 0c8bb58e1b635885364d45638e279955d4d31f30 Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Wed, 26 Aug 2026 22:18:53 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Klassenlehrer-=C3=9Cbersicht=20rief=20Sc?= =?UTF-8?q?h=C3=BClerreport=20bislang=20live=20statt=20gecacht=20ab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- LehrerApp.Core/Interfaces/IRepositories.cs | 8 +++++ LehrerApp.Core/Models/UntisCache.cs | 16 +++++++++- LehrerApp.Data.Tests/RepositoryTests.cs | 17 ++++++++++ LehrerApp.Data/LiteDbContext.cs | 2 ++ .../Repositories/AllRepositories.cs | 15 +++++++++ .../ClassTeacherViewModelsTests.cs | 9 ++---- LehrerApp.Desktop.Tests/Fakes.cs | 12 +++++++ .../UntisReportCacheServiceTests.cs | 32 ++++++++++++++++++- LehrerApp.Desktop/AppBootstrapper.cs | 1 + .../Services/UntisReportCacheService.cs | 28 ++++++++++++++++ .../ClassTeacherOverviewViewModel.cs | 12 +++---- TODO.md | 11 +++++++ 12 files changed, 149 insertions(+), 14 deletions(-) diff --git a/LehrerApp.Core/Interfaces/IRepositories.cs b/LehrerApp.Core/Interfaces/IRepositories.cs index ad878e0..a8909d1 100644 --- a/LehrerApp.Core/Interfaces/IRepositories.cs +++ b/LehrerApp.Core/Interfaces/IRepositories.cs @@ -155,6 +155,14 @@ public interface IUntisClassRegisterCacheRepository void ReplaceRange(string className, int startDate, int endDate, IEnumerable entries); void InsertRange(IEnumerable 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 GetByClass(string className); + void ReplaceAll(string className, IEnumerable 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 diff --git a/LehrerApp.Core/Models/UntisCache.cs b/LehrerApp.Core/Models/UntisCache.cs index 3bb9c4f..341cbd2 100644 --- a/LehrerApp.Core/Models/UntisCache.cs +++ b/LehrerApp.Core/Models/UntisCache.cs @@ -46,7 +46,21 @@ public class UntisClassRegisterCacheEntry public string? Text { get; set; } } -public enum UntisCacheKind { Absences, ClassRegister } +/// 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 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). +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 } /// /// Ein Datensatz pro (, ): wann das "heiße" Fenster diff --git a/LehrerApp.Data.Tests/RepositoryTests.cs b/LehrerApp.Data.Tests/RepositoryTests.cs index 0788f68..d6486a2 100644 --- a/LehrerApp.Data.Tests/RepositoryTests.cs +++ b/LehrerApp.Data.Tests/RepositoryTests.cs @@ -1108,6 +1108,23 @@ public sealed class RepositoryTests 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() { diff --git a/LehrerApp.Data/LiteDbContext.cs b/LehrerApp.Data/LiteDbContext.cs index 6547fe2..fa92412 100644 --- a/LehrerApp.Data/LiteDbContext.cs +++ b/LehrerApp.Data/LiteDbContext.cs @@ -71,6 +71,7 @@ public class LiteDbContext : IDisposable public ILiteCollection UntisAbsenceCache => _db.GetCollection("untis_absence_cache"); public ILiteCollection UntisClassRegisterCache => _db.GetCollection("untis_classregister_cache"); public ILiteCollection UntisCacheFetchStates => _db.GetCollection("untis_cache_fetch_state"); + public ILiteCollection UntisStudentRosterCache => _db.GetCollection("untis_student_roster_cache"); public ILiteCollection AnnualPlanEvents => _db.GetCollection("annual_plan_events"); public ILiteCollection TrashedItems => _db.GetCollection("trash"); @@ -542,6 +543,7 @@ public class LiteDbContext : IDisposable 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); } diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs index b082b92..b94ca06 100644 --- a/LehrerApp.Data/Repositories/AllRepositories.cs +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -830,6 +830,21 @@ public class UntisClassRegisterCacheRepository(LiteDbContext db) : IUntisClassRe public void InsertRange(IEnumerable 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 GetByClass(string className) => + db.UntisStudentRosterCache.Find(e => e.ClassName == className).ToList(); + + public void ReplaceAll(string className, IEnumerable 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) => diff --git a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs index 799b6cc..7756094 100644 --- a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs +++ b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs @@ -1,3 +1,4 @@ +using LehrerApp.Core.Models; using LehrerApp.Desktop.Services; using LehrerApp.Desktop.ViewModels.ClassTeacher; using Xunit; @@ -125,10 +126,6 @@ public sealed class ClassTeacherViewModelsTests Assert.True(ben.HasRecentClassRegisterEntry); } - private static UntisStudentDto Student(int? externKey, string displayName) => new( - UntisId: externKey ?? 0, ExternKey: externKey, ClassName: "6a", Name: displayName, LongName: null, - ForeName: null, DisplayName: displayName, Gender: null, BirthDate: null, BirthDateRaw: null, - EntryDate: null, EntryDateRaw: null, ExitDate: null, ExitDateRaw: null, Text: null, - MedicalReportDuty: null, Schulpflicht: null, Majority: null, - Address: new UntisStudentAddressDto(null, null, null, null, null, null), AttributeIL: null); + private static UntisStudentRosterCacheEntry Student(int? externKey, string displayName) => + new() { ClassName = "6a", ExternKey = externKey, DisplayName = displayName }; } diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs index d54f65c..ed3f77e 100644 --- a/LehrerApp.Desktop.Tests/Fakes.cs +++ b/LehrerApp.Desktop.Tests/Fakes.cs @@ -500,6 +500,18 @@ public class FakeUntisClassRegisterCache : IUntisClassRegisterCacheRepository public void InsertRange(IEnumerable entries) => _all.AddRange(entries); } +public class FakeUntisStudentRosterCache : IUntisStudentRosterCacheRepository +{ + private readonly List _all = []; + public List GetByClass(string className) => + _all.Where(e => e.ClassName == className).ToList(); + public void ReplaceAll(string className, IEnumerable entries) + { + _all.RemoveAll(e => e.ClassName == className); + _all.AddRange(entries); + } +} + public class FakeUntisCacheFetchStates : IUntisCacheFetchStateRepository { private readonly List _all = []; diff --git a/LehrerApp.Desktop.Tests/UntisReportCacheServiceTests.cs b/LehrerApp.Desktop.Tests/UntisReportCacheServiceTests.cs index 0b7fe14..4a5ddae 100644 --- a/LehrerApp.Desktop.Tests/UntisReportCacheServiceTests.cs +++ b/LehrerApp.Desktop.Tests/UntisReportCacheServiceTests.cs @@ -144,8 +144,9 @@ public sealed class UntisReportCacheServiceTests var absenceCache = new FakeUntisAbsenceCache(); var classRegisterCache = new FakeUntisClassRegisterCache(); + var rosterCache = new FakeUntisStudentRosterCache(); var fetchStates = new FakeUntisCacheFetchStates(); - var cache = new UntisReportCacheService(untis, absenceCache, classRegisterCache, fetchStates); + var cache = new UntisReportCacheService(untis, absenceCache, classRegisterCache, rosterCache, fetchStates); var today = DateOnly.FromDateTime(DateTime.Today); var first = await cache.GetClassRegisterEventsAsync("10c", today, today); @@ -157,6 +158,35 @@ public sealed class UntisReportCacheServiceTests Assert.Equal(4, handler.Requests.Count); // keine weiteren Anfragen - aus dem Cache bedient } + [Fact] + public async Task GetStudentRosterAsync_ZweiterAufrufBinnenEinerStunde_RuftWebUntisNichtErneutAb() + { + var csv = Encoding.UTF8.GetBytes( + "id\texternKey\tklasse.name\tname\tlongName\tforeName\r\n" + + "1\t9001\t10c\tMUST\tMustermann\tMax\r\n"); + var handler = new QueueHandler( + Json("{\"result\":{\"sessionId\":\"s\"}}"), + Json("{\"result\":[{\"id\":1,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"), + Json("{\"data\":{\"finished\":true,\"error\":false," + + "\"reportParams\":\"get=rpt.tmp&name=Student&format=csv\"}}"), + new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(csv) }); + var settings = TestSupport.BuildWebUntisSettingsService(); + await using var untis = new WebUntisIntegrationService(new HttpClient(handler), settings); + await untis.ConnectAsync(new WebUntisCredentials("bk-ostvest", "arche.webuntis.com", "lehrkraft", "geheim")); + + var cache = new UntisReportCacheService(untis, new FakeUntisAbsenceCache(), new FakeUntisClassRegisterCache(), + new FakeUntisStudentRosterCache(), new FakeUntisCacheFetchStates()); + + var first = await cache.GetStudentRosterAsync("10c"); + Assert.Single(first); + Assert.Equal("Max Mustermann", first[0].DisplayName); + Assert.Equal(4, handler.Requests.Count); + + var second = await cache.GetStudentRosterAsync("10c"); + Assert.Single(second); + Assert.Equal(4, handler.Requests.Count); // keine weiteren Anfragen - aus dem Cache bedient + } + private static HttpResponseMessage Json(string json) => new(HttpStatusCode.OK) { Content = new StringContent(json, Encoding.UTF8, "application/json"), diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index 3ace722..add621c 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -172,6 +172,7 @@ public static class AppBootstrapper services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // ── Services ────────────────────────────────────────────────────────── services.AddSingleton(); diff --git a/LehrerApp.Desktop/Services/UntisReportCacheService.cs b/LehrerApp.Desktop/Services/UntisReportCacheService.cs index 8393550..87d735b 100644 --- a/LehrerApp.Desktop/Services/UntisReportCacheService.cs +++ b/LehrerApp.Desktop/Services/UntisReportCacheService.cs @@ -29,11 +29,39 @@ public sealed class UntisReportCacheService( WebUntisIntegrationService untis, IUntisAbsenceCacheRepository absenceCache, IUntisClassRegisterCacheRepository classRegisterCache, + IUntisStudentRosterCacheRepository rosterCache, IUntisCacheFetchStateRepository fetchState) { private static readonly TimeSpan HotWindowRefreshInterval = TimeSpan.FromHours(1); private const int HotWindowDays = 14; + /// Roster hat anders als Fehlzeiten/Klassenbuch keine Historie (nur "aktueller Stand einer + /// Klasse") - deshalb ohne heißes/kaltes Fenster, nur "gilt der letzte Abruf noch als frisch". + /// Behebt, dass die Klassenlehrer-Übersicht bei jedem Öffnen spürbar verzögert wirkte: der + /// Roster-Abruf lief bislang komplett am Cache vorbei live gegen WebUntis (Nutzer-Feedback). + public async Task> GetStudentRosterAsync(string className, + bool forceRefresh = false, CancellationToken token = default) + { + var state = fetchState.Get(className, UntisCacheKind.Roster); + if (NeedsRefresh(state, forceRefresh, DateTime.UtcNow)) + { + var fresh = await untis.GetStudentsAsync(className, token); + rosterCache.ReplaceAll(className, fresh.Students.Select(s => new UntisStudentRosterCacheEntry + { + ClassName = className, ExternKey = s.ExternKey, DisplayName = s.DisplayName, + })); + fetchState.Save(new UntisCacheFetchState + { + Id = state?.Id ?? Guid.NewGuid(), ClassName = className, Kind = UntisCacheKind.Roster, + HotWindowFetchedAt = DateTime.UtcNow, + }); + } + return rosterCache.GetByClass(className); + } + + private static bool NeedsRefresh(UntisCacheFetchState? state, bool forceRefresh, DateTime utcNow) => + forceRefresh || state?.HotWindowFetchedAt is null || utcNow - state.HotWindowFetchedAt >= HotWindowRefreshInterval; + public async Task> GetAbsencesAsync(string className, DateOnly start, DateOnly end, bool forceRefresh = false, CancellationToken token = default) { diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs index 584f98a..6dd6e3f 100644 --- a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs @@ -1,6 +1,7 @@ using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Models; using LehrerApp.Desktop.Services; namespace LehrerApp.Desktop.ViewModels.ClassTeacher; @@ -22,7 +23,7 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b /// (siehe UntisForeignClassRegisterEventDto) — WebUntis liefert Namen dort in anderer /// Reihenfolge als im Schülerreport. public static IReadOnlyList Build( - IReadOnlyList students, + IReadOnlyList students, IReadOnlyList todayAbsences, IReadOnlyList recentClassRegisterEntries) { @@ -64,7 +65,6 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b public partial class ClassTeacherOverviewViewModel : ObservableObject { private readonly WebUntisSettingsService _settings; - private readonly WebUntisIntegrationService _untis; private readonly UntisReportCacheService _cache; public ClassTeacherDetailsViewModel DetailsTab { get; } @@ -81,10 +81,10 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject /// keine Klasse gewählt ist. public Func? OnNavigateToSettings { get; set; } - public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, WebUntisIntegrationService untis, + public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, UntisReportCacheService cache, ClassTeacherDetailsViewModel detailsTab) { - _settings = settings; _untis = untis; _cache = cache; DetailsTab = detailsTab; + _settings = settings; _cache = cache; DetailsTab = detailsTab; } partial void OnHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(HomeroomClassConfigured)); @@ -107,13 +107,13 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject try { var today = DateOnly.FromDateTime(DateTime.Today); - var studentsTask = _untis.GetStudentsAsync(className); + var studentsTask = _cache.GetStudentRosterAsync(className); var absencesTask = _cache.GetAbsencesAsync(className, today, today); var classRegisterTask = _cache.GetClassRegisterEventsAsync(className, today.AddDays(-7), today); await Task.WhenAll(studentsTask, absencesTask, classRegisterTask); var todayAbsences = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result); - foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result.Students, todayAbsences, + foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences, classRegisterTask.Result)) Roster.Add(row); Status = $"{Roster.Count} Schüler*innen · Stand heute."; diff --git a/TODO.md b/TODO.md index cca2817..1a61543 100644 --- a/TODO.md +++ b/TODO.md @@ -1385,6 +1385,17 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk (dortige `OnActiveTabIndexChanged`-Refreshs beim Zurückkehren zu einem Tab greifen bei einem reinen Nutzerklick möglicherweise ebenfalls nicht) — als Hintergrundaufgabe zur Prüfung ausgelagert, nicht Teil dieser Änderung. + - Die Klassenlehrer-Übersicht wirkte beim Öffnen spürbar zeitverzögert, obwohl Fehlzeiten und + Klassenbuch schon gecacht waren (Nutzer-Feedback: "fühlt sich an wie ein Live-Pull mit + CSV-Parsing"). Ursache: genau das passierte tatsächlich — der Schülerreport + (`GetStudentsAsync`, die Namen fürs Roster) lief komplett am Cache vorbei live gegen WebUntis, + bei jedem einzelnen Öffnen. Neues `UntisStudentRosterCacheEntry`/ + `UntisStudentRosterCacheRepository` (gleiches "kein db.OnChange"-Prinzip wie die anderen beiden + Caches) plus `UntisReportCacheService.GetStudentRosterAsync` — anders als Fehlzeiten/Klassenbuch + ohne heißes/kaltes Fenster, da eine Klassenliste keine Historie hat, sondern nur "gilt der + letzte Abruf noch als frisch" (dieselbe Stundenschwelle). `ClassTeacherRosterRow.Build` nimmt + jetzt `UntisStudentRosterCacheEntry` statt des vollen `UntisStudentDto` entgegen (nur + `ExternKey`/`DisplayName` wurden je gebraucht). ### 4.4 Wochen-/Tagesansicht - [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3