Files
LehrerApp/LehrerApp.Desktop/Services/UntisReportCacheService.cs
T
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

144 lines
8.2 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}