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:
@@ -169,6 +169,9 @@ public static class AppBootstrapper
|
||||
services.AddSingleton<IUntisSnapshotRepository, UntisSnapshotRepository>();
|
||||
services.AddSingleton<IUntisSlotMappingRepository, UntisSlotMappingRepository>();
|
||||
services.AddSingleton<IAnnualPlanEventRepository, AnnualPlanEventRepository>();
|
||||
services.AddSingleton<IUntisAbsenceCacheRepository, UntisAbsenceCacheRepository>();
|
||||
services.AddSingleton<IUntisClassRegisterCacheRepository, UntisClassRegisterCacheRepository>();
|
||||
services.AddSingleton<IUntisCacheFetchStateRepository, UntisCacheFetchStateRepository>();
|
||||
|
||||
// ── Services ──────────────────────────────────────────────────────────
|
||||
services.AddSingleton<GradingService>();
|
||||
@@ -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<UntisReportCacheService>();
|
||||
// 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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -58,17 +58,17 @@ public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="WebUntisIntegrationService.GetForeignClassRegisterEventsAsync"/>), sowie die
|
||||
/// Fehlzeiten der Klasse über alle Fächer (<see cref="WebUntisIntegrationService.GetClassAbsencesAsync"/>).
|
||||
/// Beides rein zur Ansicht, kein Übernehmen in lokale Daten. Bewusst unabhängig von jeder
|
||||
/// <see cref="Core.Models.LearningGroup"/> — Klassenlehrer ist man für die ganze Klasse, nicht für
|
||||
/// einen einzelnen Unterricht; die Klasse kommt aus <see cref="ClassTeacherOverviewViewModel"/>
|
||||
/// (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 <see cref="UntisReportCacheService"/> (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 <see cref="Core.Models.LearningGroup"/> —
|
||||
/// Klassenlehrer ist man für die ganze Klasse, nicht für einen einzelnen Unterricht; die Klasse
|
||||
/// kommt aus <see cref="ClassTeacherOverviewViewModel"/> (letztlich aus den WebUntis-Einstellungen),
|
||||
/// nicht aus einer Gruppen-ID.
|
||||
/// </summary>
|
||||
public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||||
{
|
||||
private readonly WebUntisIntegrationService _untis;
|
||||
private readonly UntisReportCacheService _cache;
|
||||
private string _className = "";
|
||||
|
||||
public ObservableCollection<ClassTeacherClassRegisterRow> 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 <see cref="UntisReportCacheService"/> — 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
|
||||
|
||||
@@ -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<ClassTeacherRosterRow> Roster { get; } = [];
|
||||
@@ -78,9 +79,9 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
public Func<Task>? 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);
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
<DatePicker SelectedDate="{Binding StartDate}"/>
|
||||
<TextBlock Text="bis" VerticalAlignment="Center"/>
|
||||
<DatePicker SelectedDate="{Binding EndDate}"/>
|
||||
<Button Content="Laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
|
||||
<Button Content="Laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"
|
||||
ToolTip.Tip="Lädt aus dem lokalen Cache, sofern der aktuelle Zeitraum in der letzten Stunde schon einmal abgerufen wurde."/>
|
||||
<Button Content="Jetzt wirklich neu abrufen" Command="{Binding RefreshCommand}" IsEnabled="{Binding !Busy}"
|
||||
ToolTip.Tip="Ruft WebUntis direkt ab, auch wenn der Cache noch aktuell wäre."/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="8"
|
||||
|
||||
Reference in New Issue
Block a user