diff --git a/LehrerApp.Core/Interfaces/IRepositories.cs b/LehrerApp.Core/Interfaces/IRepositories.cs index bacc1ef..ad878e0 100644 --- a/LehrerApp.Core/Interfaces/IRepositories.cs +++ b/LehrerApp.Core/Interfaces/IRepositories.cs @@ -136,6 +136,32 @@ public interface IUntisSnapshotRepository 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 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 entries); + /// Fügt nur ein, ohne zu löschen — fürs Erweitern der als endgültig angenommenen "kalten" Historie. + void InsertRange(IEnumerable entries); +} +/// Lokaler Cache der Klassenbuch-Berichtszeilen anderer Lehrkräfte fürs Klassenlehrer-Feature — +/// gleiches Muster wie . +public interface IUntisClassRegisterCacheRepository +{ + List GetByClassAndRange(string className, int startDate, int endDate); + void ReplaceRange(string className, int startDate, int endDate, IEnumerable entries); + void InsertRange(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 +{ + UntisCacheFetchState? Get(string className, UntisCacheKind kind); + void Save(UntisCacheFetchState state); +} /// Vom Nutzer bestätigte Zuordnungen WebUntis-Wochenmuster → LearningGroup. public interface IUntisSlotMappingRepository { diff --git a/LehrerApp.Core/Models/UntisCache.cs b/LehrerApp.Core/Models/UntisCache.cs new file mode 100644 index 0000000..3bb9c4f --- /dev/null +++ b/LehrerApp.Core/Models/UntisCache.cs @@ -0,0 +1,65 @@ +namespace LehrerApp.Core.Models; + +/// +/// 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. Date bewusst als int (yyyyMMdd) wie die DTOs statt DateOnly/ +/// DateTime — vermeidet die LiteDB-DateTime-Kind-Tücke (siehe CLAUDE.md) und ist +/// direkt für Bereichs-Queries nutzbar. +/// +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; } +} + +/// Lokal zwischengespeicherte Zeile aus dem "-alle-"-Klassenbuchbericht, gefiltert auf +/// Einträge anderer Lehrkräfte (Klassenlehrer-Feature) — 1:1-Abbildung von +/// `UntisForeignClassRegisterEventDto` (LehrerApp.Desktop). +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; } +} + +public enum UntisCacheKind { Absences, ClassRegister } + +/// +/// Ein Datensatz pro (, ): 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. +/// +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; } +} diff --git a/LehrerApp.Data.Tests/RepositoryTests.cs b/LehrerApp.Data.Tests/RepositoryTests.cs index 9ee47d3..0788f68 100644 --- a/LehrerApp.Data.Tests/RepositoryTests.cs +++ b/LehrerApp.Data.Tests/RepositoryTests.cs @@ -1053,6 +1053,84 @@ public sealed class RepositoryTests 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 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] diff --git a/LehrerApp.Data/LiteDbContext.cs b/LehrerApp.Data/LiteDbContext.cs index f11e7ad..6547fe2 100644 --- a/LehrerApp.Data/LiteDbContext.cs +++ b/LehrerApp.Data/LiteDbContext.cs @@ -68,6 +68,9 @@ public class LiteDbContext : IDisposable public ILiteCollection SubstitutionEntries => _db.GetCollection("substitution_entries"); public ILiteCollection UntisSnapshotEntries => _db.GetCollection("untis_snapshot_entries"); public ILiteCollection UntisSlotMappings => _db.GetCollection("untis_slot_mappings"); + 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 AnnualPlanEvents => _db.GetCollection("annual_plan_events"); public ILiteCollection TrashedItems => _db.GetCollection("trash"); @@ -535,6 +538,12 @@ public class LiteDbContext : IDisposable 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); + UntisCacheFetchStates.EnsureIndex("ux_class_kind", + BsonExpression.Create("STRING($.ClassName) + ':' + STRING($.Kind)"), unique: true); } public void Dispose() => _db.Dispose(); diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs index 9c4f1fe..b082b92 100644 --- a/LehrerApp.Data/Repositories/AllRepositories.cs +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -794,6 +794,50 @@ public class AnnualPlanEventRepository(LiteDbContext db) : IAnnualPlanEventRepos 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 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 entries) + { + db.UntisAbsenceCache.DeleteMany(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate); + db.UntisAbsenceCache.InsertBulk(entries); + } + + public void InsertRange(IEnumerable entries) => db.UntisAbsenceCache.InsertBulk(entries); +} + +public class UntisClassRegisterCacheRepository(LiteDbContext db) : IUntisClassRegisterCacheRepository +{ + public List 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 entries) + { + db.UntisClassRegisterCache.DeleteMany(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate); + db.UntisClassRegisterCache.InsertBulk(entries); + } + + public void InsertRange(IEnumerable entries) => db.UntisClassRegisterCache.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 List GetBySubjectAndGrade(Guid subjectId, int gradeLevel) => diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs index 0a88552..d54f65c 100644 --- a/LehrerApp.Desktop.Tests/Fakes.cs +++ b/LehrerApp.Desktop.Tests/Fakes.cs @@ -474,6 +474,44 @@ public class FakeAnnualPlanEvents : IAnnualPlanEventRepository public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id); } +public class FakeUntisAbsenceCache : IUntisAbsenceCacheRepository +{ + private readonly List _all = []; + public List 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 entries) + { + _all.RemoveAll(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate); + _all.AddRange(entries); + } + public void InsertRange(IEnumerable entries) => _all.AddRange(entries); +} + +public class FakeUntisClassRegisterCache : IUntisClassRegisterCacheRepository +{ + private readonly List _all = []; + public List 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 entries) + { + _all.RemoveAll(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate); + _all.AddRange(entries); + } + public void InsertRange(IEnumerable entries) => _all.AddRange(entries); +} + +public class FakeUntisCacheFetchStates : IUntisCacheFetchStateRepository +{ + private readonly List _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 { private readonly List _all = []; diff --git a/LehrerApp.Desktop.Tests/UntisReportCacheServiceTests.cs b/LehrerApp.Desktop.Tests/UntisReportCacheServiceTests.cs new file mode 100644 index 0000000..0b7fe14 --- /dev/null +++ b/LehrerApp.Desktop.Tests/UntisReportCacheServiceTests.cs @@ -0,0 +1,178 @@ +using System.Net; +using System.Text; +using LehrerApp.Core.Models; +using LehrerApp.Desktop.Services; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class UntisReportCacheServiceTests +{ + private static readonly DateOnly Today = new(2026, 8, 25); + private static readonly DateOnly HotStart = Today.AddDays(-14); + + // ── Plan(): reine Entscheidungslogik, keine Repositories/HTTP nötig ────────── + + [Fact] + public void Plan_ErsterAufruf_LaedtHeissesFensterUndDeckungReichtBisFensteranfang() + { + var plan = UntisReportCacheService.Plan(null, Today, Today, Today, forceRefresh: false, DateTime.UtcNow); + + Assert.True(plan.RefreshHotWindow); + Assert.Equal(HotStart, plan.HotWindowStart); + Assert.Equal(Today, plan.HotWindowEnd); + Assert.False(plan.FetchColdRange); + Assert.Equal(Int(HotStart), plan.ResultingColdCoverageStartDate); + } + + [Fact] + public void Plan_InnerhalbEinerStunde_KeinErneutesHeissesFenster() + { + var state = new UntisCacheFetchState + { + ClassName = "10c", Kind = UntisCacheKind.Absences, + HotWindowFetchedAt = DateTime.UtcNow.AddMinutes(-10), ColdCoverageStartDate = Int(HotStart), + }; + + var plan = UntisReportCacheService.Plan(state, Today, Today, Today, forceRefresh: false, DateTime.UtcNow); + + Assert.False(plan.RefreshHotWindow); + Assert.False(plan.FetchColdRange); + } + + [Fact] + public void Plan_ForceRefresh_LoestTrotzKuerzlichemAbrufAus() + { + var state = new UntisCacheFetchState + { + ClassName = "10c", Kind = UntisCacheKind.Absences, + HotWindowFetchedAt = DateTime.UtcNow.AddMinutes(-10), ColdCoverageStartDate = Int(HotStart), + }; + + var plan = UntisReportCacheService.Plan(state, Today, Today, Today, forceRefresh: true, DateTime.UtcNow); + + Assert.True(plan.RefreshHotWindow); + } + + [Fact] + public void Plan_MehrAlsEineStundeVergangen_FrischtHeissesFensterErneutAuf() + { + var state = new UntisCacheFetchState + { + ClassName = "10c", Kind = UntisCacheKind.Absences, + HotWindowFetchedAt = DateTime.UtcNow.AddHours(-2), ColdCoverageStartDate = Int(HotStart), + }; + + var plan = UntisReportCacheService.Plan(state, Today, Today, Today, forceRefresh: false, DateTime.UtcNow); + + Assert.True(plan.RefreshHotWindow); + } + + [Fact] + public void Plan_AnfrageVorDemHeissenFenster_KeinHeissesFensterAberKalterErstabruf() + { + var requestStart = HotStart.AddDays(-30); + var requestEnd = HotStart.AddDays(-5); + + var plan = UntisReportCacheService.Plan(null, requestStart, requestEnd, Today, forceRefresh: false, DateTime.UtcNow); + + Assert.False(plan.RefreshHotWindow); + Assert.True(plan.FetchColdRange); + Assert.Equal(requestStart, plan.ColdRangeStart); + Assert.Equal(HotStart.AddDays(-1), plan.ColdRangeEnd); + Assert.Equal(Int(requestStart), plan.ResultingColdCoverageStartDate); + } + + [Fact] + public void Plan_BereitsKaltAbgedeckterBereich_KeinAbrufNoetig() + { + var coveredSince = HotStart.AddDays(-60); + var state = new UntisCacheFetchState + { + ClassName = "10c", Kind = UntisCacheKind.Absences, + HotWindowFetchedAt = DateTime.UtcNow, ColdCoverageStartDate = Int(coveredSince), + }; + + var plan = UntisReportCacheService.Plan(state, coveredSince.AddDays(10), HotStart.AddDays(-20), + Today, forceRefresh: false, DateTime.UtcNow); + + Assert.False(plan.RefreshHotWindow); + Assert.False(plan.FetchColdRange); + } + + [Fact] + public void Plan_ErweitertKalteAbdeckungNurBisZumBenoetigtenStart() + { + var coveredSince = HotStart.AddDays(-30); + var earlierStart = HotStart.AddDays(-90); + var state = new UntisCacheFetchState + { + ClassName = "10c", Kind = UntisCacheKind.Absences, + HotWindowFetchedAt = DateTime.UtcNow, ColdCoverageStartDate = Int(coveredSince), + }; + + var plan = UntisReportCacheService.Plan(state, earlierStart, HotStart.AddDays(-40), + Today, forceRefresh: false, DateTime.UtcNow); + + Assert.False(plan.RefreshHotWindow); + Assert.True(plan.FetchColdRange); + Assert.Equal(earlierStart, plan.ColdRangeStart); + Assert.Equal(coveredSince.AddDays(-1), plan.ColdRangeEnd); + Assert.Equal(Int(earlierStart), plan.ResultingColdCoverageStartDate); + } + + private static int Int(DateOnly date) => date.Year * 10000 + date.Month * 100 + date.Day; + + // ── Ende-zu-Ende: erster Aufruf ruft WebUntis ab, zweiter Aufruf binnen einer Stunde nicht ── + + [Fact] + public async Task GetClassRegisterEventsAsync_ZweiterAufrufBinnenEinerStunde_RuftWebUntisNichtErneutAb() + { + var todayLabel = DateTime.Today.ToString("dd.MM.yy"); + var csv = Encoding.UTF8.GetBytes( + "Klasse\tDatum\tFach\tName\tBenutzer\tEintragskategorie\tKategoriegruppe\tText\r\n" + + $"10c\t{todayLabel}\tNAT\tMuster Max\tmueller\tMitarb. über Erwart.\tPositiv\tArbeitet gut mit.\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=ClassregEventPerStudent&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 absenceCache = new FakeUntisAbsenceCache(); + var classRegisterCache = new FakeUntisClassRegisterCache(); + var fetchStates = new FakeUntisCacheFetchStates(); + var cache = new UntisReportCacheService(untis, absenceCache, classRegisterCache, fetchStates); + var today = DateOnly.FromDateTime(DateTime.Today); + + var first = await cache.GetClassRegisterEventsAsync("10c", today, today); + Assert.Single(first); + Assert.Equal(4, handler.Requests.Count); + + var second = await cache.GetClassRegisterEventsAsync("10c", today, today); + 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"), + }; + + private sealed class QueueHandler(params HttpResponseMessage[] responses) : HttpMessageHandler + { + private readonly Queue _responses = new(responses); + public List Requests { get; } = []; + + protected override async Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) + { + Requests.Add(request.RequestUri?.ToString() ?? ""); + await Task.Yield(); + return _responses.Dequeue(); + } + } +} diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index 91b181f..3ace722 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -169,6 +169,9 @@ public static class AppBootstrapper services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); // ── Services ────────────────────────────────────────────────────────── services.AddSingleton(); @@ -225,6 +228,7 @@ public static class AppBootstrapper services.AddSingleton(_ => new SyncAuthService(new HttpClient())); services.AddSingleton(sp => new SchoolWeatherService(new HttpClient(), syncSettings)); services.AddSingleton(sp => new WebUntisIntegrationService(new HttpClient(), untisSettings)); + services.AddSingleton(); // War dieses Gerät schon eingeloggt, aber sync.key fehlt(e), wurde gerade eben (unten) // stillschweigend ein neuer, unabhängiger Schlüssel erzeugt - bisher unter dem ALTEN // Schlüssel synchronisierte Server-Daten sind für dieses Gerät dann nicht mehr lesbar. diff --git a/LehrerApp.Desktop/Services/UntisReportCacheService.cs b/LehrerApp.Desktop/Services/UntisReportCacheService.cs new file mode 100644 index 0000000..8393550 --- /dev/null +++ b/LehrerApp.Desktop/Services/UntisReportCacheService.cs @@ -0,0 +1,143 @@ +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; + +namespace LehrerApp.Desktop.Services; + +/// Ergebnis von : was für eine Anfrage +/// [RequestStart..RequestEnd, nicht Teil dieses Records, siehe Aufrufer] tatsächlich +/// live nachgeladen werden muss, und welcher Deckungsstand danach gilt. Bewusst als reine, ohne +/// Repository/HTTP-Zugriff testbare Funktion ausgelagert (gleiches Muster wie +/// ). +public readonly record struct UntisCacheRefreshPlan( + bool RefreshHotWindow, DateOnly HotWindowStart, DateOnly HotWindowEnd, + bool FetchColdRange, DateOnly ColdRangeStart, DateOnly ColdRangeEnd, + int? ResultingColdCoverageStartDate); + +/// +/// Lokaler Cache für die beiden Klassenlehrer-Berichte (Fehlzeiten, Klassenbucheinträge anderer +/// Lehrkräfte, siehe TODO.md) - Nutzer-Feedback: Warnungen sollen sofort da sein, ohne bei jedem +/// Öffnen der Ansicht neu abzurufen, und der Bericht darf nicht mehrfach pro Stunde abgerufen +/// werden (Sorge, bei WebUntis aufzufallen, wenn dieselbe Ansicht mehrfach geöffnet wird). Ein +/// festes "heißes" Fenster der letzten Tage wird höchstens stündlich +/// aufgefrischt (dort kann sich der Status noch ändern, z. B. "ausstehend" → "entschuldigt"); +/// alles Ältere gilt als endgültig und wird, einmal abgerufen, dauerhaft aus dem Cache bedient. +/// Die Cache-Tabellen selbst sind bewusst nicht synchronisiert (siehe +/// -Implementierung) - jedes Gerät füllt seinen Cache +/// über diesen Service selbst. +/// +public sealed class UntisReportCacheService( + WebUntisIntegrationService untis, + IUntisAbsenceCacheRepository absenceCache, + IUntisClassRegisterCacheRepository classRegisterCache, + IUntisCacheFetchStateRepository fetchState) +{ + private static readonly TimeSpan HotWindowRefreshInterval = TimeSpan.FromHours(1); + private const int HotWindowDays = 14; + + public async Task> GetAbsencesAsync(string className, + DateOnly start, DateOnly end, bool forceRefresh = false, CancellationToken token = default) + { + var state = fetchState.Get(className, UntisCacheKind.Absences); + var plan = Plan(state, start, end, Today(), forceRefresh, DateTime.UtcNow); + + if (plan.RefreshHotWindow) + { + var fresh = await untis.GetClassAbsencesAsync(className, plan.HotWindowStart, plan.HotWindowEnd, token); + absenceCache.ReplaceRange(className, Int(plan.HotWindowStart), Int(plan.HotWindowEnd), fresh.Select(ToEntry)); + } + if (plan.FetchColdRange) + { + var older = await untis.GetClassAbsencesAsync(className, plan.ColdRangeStart, plan.ColdRangeEnd, token); + absenceCache.InsertRange(older.Select(ToEntry)); + } + if (plan.RefreshHotWindow || plan.FetchColdRange) + fetchState.Save(NextState(state, className, UntisCacheKind.Absences, plan)); + + return absenceCache.GetByClassAndRange(className, Int(start), Int(end)).Select(ToDto).ToList(); + } + + public async Task> GetClassRegisterEventsAsync( + string className, DateOnly start, DateOnly end, bool forceRefresh = false, CancellationToken token = default) + { + var state = fetchState.Get(className, UntisCacheKind.ClassRegister); + var plan = Plan(state, start, end, Today(), forceRefresh, DateTime.UtcNow); + + if (plan.RefreshHotWindow) + { + var fresh = await untis.GetForeignClassRegisterEventsAsync(className, plan.HotWindowStart, plan.HotWindowEnd, token); + classRegisterCache.ReplaceRange(className, Int(plan.HotWindowStart), Int(plan.HotWindowEnd), fresh.Select(ToEntry)); + } + if (plan.FetchColdRange) + { + var older = await untis.GetForeignClassRegisterEventsAsync(className, plan.ColdRangeStart, plan.ColdRangeEnd, token); + classRegisterCache.InsertRange(older.Select(ToEntry)); + } + if (plan.RefreshHotWindow || plan.FetchColdRange) + fetchState.Save(NextState(state, className, UntisCacheKind.ClassRegister, plan)); + + return classRegisterCache.GetByClassAndRange(className, Int(start), Int(end)).Select(ToDto).ToList(); + } + + /// Reine Entscheidungslogik ohne Repository-/HTTP-Zugriff: was muss für eine Anfrage + /// [..] live nachgeladen werden, + /// gegeben den zuletzt gespeicherten Zustand. Das heiße Fenster ist immer fest + /// [, ], unabhängig + /// von der angefragten Spanne. Nach einem heißen Refresh reicht die bekannte Abdeckung mindestens + /// bis zum Fensteranfang zurück; ein zusätzlicher kalter Abruf erweitert sie nur so weit wie + /// für nötig, nie weiter. + public static UntisCacheRefreshPlan Plan(UntisCacheFetchState? state, DateOnly requestStart, DateOnly requestEnd, + DateOnly today, bool forceRefresh, DateTime utcNow) + { + var hotStart = today.AddDays(-HotWindowDays); + var refreshHot = requestEnd >= hotStart && (forceRefresh || state?.HotWindowFetchedAt is null + || utcNow - state.HotWindowFetchedAt >= HotWindowRefreshInterval); + + int? coverageStart = state?.ColdCoverageStartDate; + if (refreshHot) + coverageStart = coverageStart is { } existing ? Math.Min(existing, Int(hotStart)) : Int(hotStart); + + var coldEnd = coverageStart is { } covered ? FromInt(covered).AddDays(-1) : hotStart.AddDays(-1); + var fetchCold = (coverageStart is null || requestStart < FromInt(coverageStart.Value)) && requestStart <= coldEnd; + if (fetchCold) coverageStart = Int(requestStart); + + return new UntisCacheRefreshPlan(refreshHot, hotStart, today, fetchCold, requestStart, coldEnd, coverageStart); + } + + private static UntisCacheFetchState NextState(UntisCacheFetchState? previous, string className, + UntisCacheKind kind, UntisCacheRefreshPlan plan) => new() + { + Id = previous?.Id ?? Guid.NewGuid(), + ClassName = className, + Kind = kind, + HotWindowFetchedAt = plan.RefreshHotWindow ? DateTime.UtcNow : previous?.HotWindowFetchedAt, + ColdCoverageStartDate = plan.ResultingColdCoverageStartDate, + }; + + private static DateOnly Today() => DateOnly.FromDateTime(DateTime.Today); + private static int Int(DateOnly date) => date.Year * 10000 + date.Month * 100 + date.Day; + private static DateOnly FromInt(int value) => new(value / 10000, value / 100 % 100, value % 100); + + private static UntisAbsenceCacheEntry ToEntry(UntisClassAbsenceEntryDto x) => new() + { + ClassName = x.ClassName, StudentName = x.StudentName, ExternKey = x.ExternKey, Date = x.Date, + AbsentPeriods = x.AbsentPeriods, AbsentMinutes = x.AbsentMinutes, TeacherUsernames = x.TeacherUsernames, + Subject = x.Subject, AbsenceReason = x.AbsenceReason, Note = x.Note, EntryId = x.EntryId, + HandledOn = x.HandledOn, Counts = x.Counts, ExcuseNote = x.ExcuseNote, PeriodNumber = x.PeriodNumber, + Status = x.Status, CountsAsFullDay = x.CountsAsFullDay, + }; + + private static UntisClassAbsenceEntryDto ToDto(UntisAbsenceCacheEntry e) => new( + e.StudentName, e.ExternKey, e.ClassName, e.Date, e.AbsentPeriods, e.AbsentMinutes, e.TeacherUsernames, + e.Subject, e.AbsenceReason, e.Note, e.EntryId, e.HandledOn, e.Counts, e.ExcuseNote, e.PeriodNumber, + e.Status, e.CountsAsFullDay); + + private static UntisClassRegisterCacheEntry ToEntry(UntisForeignClassRegisterEventDto x) => new() + { + ClassName = x.ClassName, Date = x.Date, Subject = x.Subject, StudentName = x.StudentName, + TeacherUsername = x.TeacherUsername, CategoryName = x.CategoryName, CategoryGroup = x.CategoryGroup, + Text = x.Text, + }; + + private static UntisForeignClassRegisterEventDto ToDto(UntisClassRegisterCacheEntry e) => new( + e.ClassName, e.Date, e.Subject, e.StudentName, e.TeacherUsername, e.CategoryName, e.CategoryGroup, e.Text); +} diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs index aff4767..5420f82 100644 --- a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs @@ -58,17 +58,17 @@ public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName /// /// Details-Ansicht des Klassenlehrer-Bereichs: Klassenbucheinträge, die andere Lehrkräfte zu /// Schülern der Klasse angelegt haben (WebUntis "-alle-"-Bericht, gefiltert auf fremde statt der -/// eigenen Benutzerkennung wie beim bestehenden Dokumentations-Abgleich, siehe -/// ), sowie die -/// Fehlzeiten der Klasse über alle Fächer (). -/// Beides rein zur Ansicht, kein Übernehmen in lokale Daten. Bewusst unabhängig von jeder -/// — Klassenlehrer ist man für die ganze Klasse, nicht für -/// einen einzelnen Unterricht; die Klasse kommt aus -/// (letztlich aus den WebUntis-Einstellungen), nicht aus einer Gruppen-ID. +/// eigenen Benutzerkennung wie beim bestehenden Dokumentations-Abgleich), sowie die Fehlzeiten der +/// Klasse über alle Fächer — beide über (lokal gecacht statt +/// bei jedem Öffnen neu abgerufen, siehe TODO.md). Beides rein zur Ansicht, kein Übernehmen in +/// lokale Daten. Bewusst unabhängig von jeder — +/// Klassenlehrer ist man für die ganze Klasse, nicht für einen einzelnen Unterricht; die Klasse +/// kommt aus (letztlich aus den WebUntis-Einstellungen), +/// nicht aus einer Gruppen-ID. /// public partial class ClassTeacherDetailsViewModel : ObservableObject { - private readonly WebUntisIntegrationService _untis; + private readonly UntisReportCacheService _cache; private string _className = ""; public ObservableCollection Entries { get; } = []; @@ -81,9 +81,9 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject /// Von der Übersicht gesetzt (Klick auf eine Roster-Zeile) - leer zeigt alle Schüler*innen. [ObservableProperty] private string _studentFilter = ""; - public ClassTeacherDetailsViewModel(WebUntisIntegrationService untis) + public ClassTeacherDetailsViewModel(UntisReportCacheService cache) { - _untis = untis; + _cache = cache; } public void Initialize(string className) @@ -95,7 +95,14 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject } [RelayCommand] - private async Task Load() + private Task Load() => LoadInternal(forceRefresh: false); + + /// Umgeht bewusst die Stunden-Sperre von — für den Fall, + /// dass man sicher weiß, dass sich seit dem letzten automatischen Abruf etwas geändert hat. + [RelayCommand] + private Task Refresh() => LoadInternal(forceRefresh: true); + + private async Task LoadInternal(bool forceRefresh) { var start = DateOnly.FromDateTime(StartDate.LocalDateTime); var end = DateOnly.FromDateTime(EndDate.LocalDateTime); @@ -105,8 +112,8 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject Busy = true; Entries.Clear(); AbsenceEntries.Clear(); try { - var classRegisterTask = _untis.GetForeignClassRegisterEventsAsync(_className, start, end); - var absencesTask = _untis.GetClassAbsencesAsync(_className, start, end); + var classRegisterTask = _cache.GetClassRegisterEventsAsync(_className, start, end, forceRefresh); + var absencesTask = _cache.GetAbsencesAsync(_className, start, end, forceRefresh); await Task.WhenAll(classRegisterTask, absencesTask); var ordered = classRegisterTask.Result diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs index d9e0b20..cc69795 100644 --- a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs @@ -62,6 +62,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject { private readonly WebUntisSettingsService _settings; private readonly WebUntisIntegrationService _untis; + private readonly UntisReportCacheService _cache; public ClassTeacherDetailsViewModel DetailsTab { get; } public ObservableCollection Roster { get; } = []; @@ -78,9 +79,9 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject public Func? OnNavigateToSettings { get; set; } public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, WebUntisIntegrationService untis, - ClassTeacherDetailsViewModel detailsTab) + UntisReportCacheService cache, ClassTeacherDetailsViewModel detailsTab) { - _settings = settings; _untis = untis; DetailsTab = detailsTab; + _settings = settings; _untis = untis; _cache = cache; DetailsTab = detailsTab; } partial void OnHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(HomeroomClassConfigured)); @@ -104,8 +105,8 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject { var today = DateOnly.FromDateTime(DateTime.Today); var studentsTask = _untis.GetStudentsAsync(className); - var absencesTask = _untis.GetClassAbsencesAsync(className, today, today); - var classRegisterTask = _untis.GetForeignClassRegisterEventsAsync(className, today.AddDays(-7), today); + 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); diff --git a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherDetailsView.axaml b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherDetailsView.axaml index b992d2d..8a6ad0e 100644 --- a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherDetailsView.axaml +++ b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherDetailsView.axaml @@ -12,7 +12,10 @@ -