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>
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
/// <summary>Ergebnis von <see cref="UntisReportCacheService.Plan"/>: was für eine Anfrage
|
||||
/// [<c>RequestStart</c>..<c>RequestEnd</c>, 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
|
||||
/// <see cref="ClassTeacher.ClassAbsenceDaySummaryRow.GroupByStudentAndDay"/>).</summary>
|
||||
public readonly record struct UntisCacheRefreshPlan(
|
||||
bool RefreshHotWindow, DateOnly HotWindowStart, DateOnly HotWindowEnd,
|
||||
bool FetchColdRange, DateOnly ColdRangeStart, DateOnly ColdRangeEnd,
|
||||
int? ResultingColdCoverageStartDate);
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="HotWindowDays"/> 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
|
||||
/// <see cref="IUntisAbsenceCacheRepository"/>-Implementierung) - jedes Gerät füllt seinen Cache
|
||||
/// über diesen Service selbst.
|
||||
/// </summary>
|
||||
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<IReadOnlyList<UntisClassAbsenceEntryDto>> 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<IReadOnlyList<UntisForeignClassRegisterEventDto>> 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
|
||||
/// [<paramref name="requestStart"/>..<paramref name="requestEnd"/>] live nachgeladen werden,
|
||||
/// gegeben den zuletzt gespeicherten Zustand. Das heiße Fenster ist immer fest
|
||||
/// [<paramref name="today"/> − <see cref="HotWindowDays"/>, <paramref name="today"/>], 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 <paramref name="requestStart"/> 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);
|
||||
}
|
||||
Reference in New Issue
Block a user