diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs index 206eea5..2f2fef2 100644 --- a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs @@ -445,6 +445,13 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject [ObservableProperty] private int _ownDocumentationFollowUpCount; [ObservableProperty] private int _ownDocumentationCriticalCount; + // Zuletzt per Load() geholte WebUntis-Rohdaten, für RefreshFromLocalDataOnly() - damit ein + // eingehendes Sync-Ereignis die Ansicht neu aufbauen kann, ohne selbst WebUntis anzufragen. + private IReadOnlyList? _lastStudents; + private IReadOnlyList? _lastAbsences; + private IReadOnlyList? _lastClassRegisterEvents; + private IReadOnlyList? _lastHolidays; + public bool HomeroomClassConfigured => !string.IsNullOrWhiteSpace(HomeroomClassName); public bool HasPrimaryRoster => PrimaryRoster.Count > 0; public bool HasSecondaryRoster => SecondaryRoster.Count > 0; @@ -526,13 +533,41 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject ApplyRosterFilter(); } + private void ResetRosterState() + { + ActiveTabIndex = 0; + Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear(); + OpenExcuses.Clear(); OpenExcuseOverflowCount = 0; + } + + // Wird beim Navigieren auf diese Seite aufgerufen statt LoadCommand: baut nur den lokalen + // Grundzustand auf (keine WebUntis-Anfrage), damit Öffnen der Ansicht nicht auf einen + // WebUntis-Login+Report-Abruf wartet. Die eigentlichen Fehlzeiten/Klassenbuch-Daten holt sich + // die Nutzerin bewusst über den "Aktualisieren"-Button (LoadCommand). + public void PrepareForDisplay() + { + if (Busy) return; + HomeroomClassName = _settings.HomeroomClassName; + ResetRosterState(); + if (!HomeroomClassConfigured) + { + Status = "Noch keine Klasse ausgewählt."; + } + else + { + DetailsTab.Initialize(HomeroomClassName!); + CasesTab.Initialize(HomeroomClassName!); + if (StudentCount == 0) + Status = "Noch nicht geladen – auf „Aktualisieren“ klicken, um aktuelle WebUntis-Daten zu holen."; + } + NotifyRosterState(); + } + [RelayCommand] private async Task Load() { HomeroomClassName = _settings.HomeroomClassName; - ActiveTabIndex = 0; - Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear(); - OpenExcuses.Clear(); OpenExcuseOverflowCount = 0; + ResetRosterState(); if (!HomeroomClassConfigured) { Status = "Noch keine Klasse ausgewählt."; @@ -547,14 +582,11 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject NotifyRosterState(); try { - var today = DateOnly.FromDateTime(DateTime.Today); - var sevenDayStart = today.AddDays(-6); - var trendDays = LastSchoolDays(today, 7); // Fehlzeiten seit Schuljahresbeginn statt nur der letzten 7 Tage: liefert die // Kennzahl unten (YearSummaryLabel) und den Nenner für die Fehlquote in einem Abruf. // UntisReportCacheService cached diesen "kalten" Bereich dauerhaft (siehe dort) — // teuer ist nur der erste Abruf pro Schuljahr, nicht jedes Öffnen der Ansicht. - var yearStart = _schoolYear.SchoolYearStart(_schoolYear.CurrentSchoolYear(today)); + var (today, yearStart, sevenDayStart, trendDays) = RosterDateWindow(); var studentsTask = _cache.GetStudentRosterAsync(className); var absencesTask = _cache.GetAbsencesAsync(className, yearStart, today); // Seit Schuljahresbeginn statt nur die letzte Woche (Nutzer-Feedback: Klassenbucheinträge @@ -568,54 +600,93 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject var holidaysTask = GetHolidaysAsync(); await Task.WhenAll(studentsTask, absencesTask, classRegisterTask, holidaysTask); - var absenceDaysYear = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result); - var todayAbsences = absenceDaysYear.Where(a => a.Date == today).ToList(); - var termStart = EstimateTermStart(absenceDaysYear, yearStart); - var rawWeekdaysElapsed = CountSchoolWeekdays(termStart, today, []); - var schoolDaysElapsed = CountSchoolWeekdays(termStart, today, holidaysTask.Result); - var holidayWeekdaysExcluded = rawWeekdaysElapsed - schoolDaysElapsed; - var recentRegisterEntries = classRegisterTask.Result - .Where(e => TryDate(e.Date, out var d) && d >= sevenDayStart).ToList(); - // Geschlossene Vorgänge je Klassen-Roster-Namen (für die bereichsbezogene Score-Dämpfung/ - // Rückfall-Erkennung in Build) — gleicher Namensabgleich wie bei den übrigen Build*-Methoden - // dieser Klasse (z.B. BuildAttendanceParticipationNotices). - var localStudents = _students.GetAll(); - var closedVorgaengeByNameKey = studentsTask.Result - .Select(r => (Roster: r, Student: MatchStudent(r.DisplayName, localStudents))) - .Where(x => x.Student is not null) - .ToDictionary( - x => UntisNameMatching.NameKey(x.Roster.DisplayName), - x => (IReadOnlyList)_vorgaenge.GetByStudent(x.Student!.Id) - .Where(v => v.Status == VorgangStatus.Closed).ToList()); - foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences, - recentRegisterEntries, today, absenceDaysYear, schoolDaysElapsed, - holidayWeekdaysExcluded, termStart, classRegisterTask.Result, - _patternScoreSettings.Load(), closedVorgaengeByNameKey)) Roster.Add(row); - - StudentCount = Roster.Count; - TodayAlertCount = Roster.Count(r => r.HasAbsenceToday); - TodayUnexcusedCount = Roster.Count(r => r.IsUnexcused); - LateCount = Roster.Count(r => r.IsLate); - PresentCount = Roster.Count(r => !r.HasAbsenceToday); - ExcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && !r.IsUnexcused); - UnexcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && r.IsUnexcused); - RecentClassRegisterCount = recentRegisterEntries.Count; - BuildTrend(absenceDaysYear, trendDays); - BuildPatternNotices(absenceDaysYear, sevenDayStart); - BuildWeekdayPatternNotices(absenceDaysYear); - BuildAttendanceParticipationNotices(); - BuildVorgangRegressionNotices(); - BuildOwnDocumentationCounts(); - BuildOpenExcuses(absenceDaysYear, today); + _lastStudents = studentsTask.Result; + _lastAbsences = absencesTask.Result; + _lastClassRegisterEvents = classRegisterTask.Result; + _lastHolidays = holidaysTask.Result; + BuildRosterFromCachedData(studentsTask.Result, absencesTask.Result, classRegisterTask.Result, + holidaysTask.Result, today, yearStart, sevenDayStart, trendDays); LastUpdatedLabel = $"Zuletzt aktualisiert: Heute, {DateTime.Now:HH:mm}"; - Status = $"{StudentCount} Schüler*innen · {TodayAlertCount} heute auffällig"; - ApplyRosterFilter(); - NotifySummary(); } catch (WebUntisIntegrationException ex) { Status = ex.Message; NotifyRosterState(); } finally { Busy = false; NotifyRosterState(); } } + // Baut die Ansicht ausschließlich aus den zuletzt per Load() geholten WebUntis-Daten plus + // aktuellen lokalen Daten (Vorgänge/Dokumentation) neu auf - löst NIE einen WebUntis-Request + // aus, auch nicht über UntisReportCacheService' stündliches Hot-Window-Refresh. Wird bei + // eingehenden Sync-Ereignissen aufgerufen (siehe MainWindowViewModel.RefreshCurrentPage); + // echte WebUntis-Daten holt bewusst ausschließlich der "Aktualisieren"-Button (LoadCommand), + // wenn die Nutzerin sich dafür entscheidet. + public void RefreshFromLocalDataOnly() + { + if (Busy || _lastStudents is null || _lastAbsences is null || + _lastClassRegisterEvents is null || _lastHolidays is null) return; + var (today, yearStart, sevenDayStart, trendDays) = RosterDateWindow(); + ResetRosterState(); + BuildRosterFromCachedData(_lastStudents, _lastAbsences, _lastClassRegisterEvents, _lastHolidays, + today, yearStart, sevenDayStart, trendDays); + NotifyRosterState(); + } + + private (DateOnly Today, DateOnly YearStart, DateOnly SevenDayStart, IReadOnlyList TrendDays) RosterDateWindow() + { + var today = DateOnly.FromDateTime(DateTime.Today); + var yearStart = _schoolYear.SchoolYearStart(_schoolYear.CurrentSchoolYear(today)); + return (today, yearStart, today.AddDays(-6), LastSchoolDays(today, 7)); + } + + private void BuildRosterFromCachedData( + IReadOnlyList students, + IReadOnlyList absences, + IReadOnlyList classRegisterEvents, + IReadOnlyList holidays, + DateOnly today, DateOnly yearStart, DateOnly sevenDayStart, IReadOnlyList trendDays) + { + var absenceDaysYear = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences); + var todayAbsences = absenceDaysYear.Where(a => a.Date == today).ToList(); + var termStart = EstimateTermStart(absenceDaysYear, yearStart); + var rawWeekdaysElapsed = CountSchoolWeekdays(termStart, today, []); + var schoolDaysElapsed = CountSchoolWeekdays(termStart, today, holidays); + var holidayWeekdaysExcluded = rawWeekdaysElapsed - schoolDaysElapsed; + var recentRegisterEntries = classRegisterEvents + .Where(e => TryDate(e.Date, out var d) && d >= sevenDayStart).ToList(); + // Geschlossene Vorgänge je Klassen-Roster-Namen (für die bereichsbezogene Score-Dämpfung/ + // Rückfall-Erkennung in Build) — gleicher Namensabgleich wie bei den übrigen Build*-Methoden + // dieser Klasse (z.B. BuildAttendanceParticipationNotices). + var localStudents = _students.GetAll(); + var closedVorgaengeByNameKey = students + .Select(r => (Roster: r, Student: MatchStudent(r.DisplayName, localStudents))) + .Where(x => x.Student is not null) + .ToDictionary( + x => UntisNameMatching.NameKey(x.Roster.DisplayName), + x => (IReadOnlyList)_vorgaenge.GetByStudent(x.Student!.Id) + .Where(v => v.Status == VorgangStatus.Closed).ToList()); + foreach (var row in ClassTeacherRosterRow.Build(students, todayAbsences, + recentRegisterEntries, today, absenceDaysYear, schoolDaysElapsed, + holidayWeekdaysExcluded, termStart, classRegisterEvents, + _patternScoreSettings.Load(), closedVorgaengeByNameKey)) Roster.Add(row); + + StudentCount = Roster.Count; + TodayAlertCount = Roster.Count(r => r.HasAbsenceToday); + TodayUnexcusedCount = Roster.Count(r => r.IsUnexcused); + LateCount = Roster.Count(r => r.IsLate); + PresentCount = Roster.Count(r => !r.HasAbsenceToday); + ExcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && !r.IsUnexcused); + UnexcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && r.IsUnexcused); + RecentClassRegisterCount = recentRegisterEntries.Count; + BuildTrend(absenceDaysYear, trendDays); + BuildPatternNotices(absenceDaysYear, sevenDayStart); + BuildWeekdayPatternNotices(absenceDaysYear); + BuildAttendanceParticipationNotices(); + BuildVorgangRegressionNotices(); + BuildOwnDocumentationCounts(); + BuildOpenExcuses(absenceDaysYear, today); + Status = $"{StudentCount} Schüler*innen · {TodayAlertCount} heute auffällig"; + ApplyRosterFilter(); + NotifySummary(); + } + [RelayCommand] private void ShowAlerts() => SelectedRosterFilter = 0; [RelayCommand] private void ShowClassRegister() => SelectedRosterFilter = 1; [RelayCommand] private void ShowAll() => SelectedRosterFilter = 2; diff --git a/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs b/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs index c53c09f..1790690 100644 --- a/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs @@ -90,7 +90,10 @@ public partial class MainWindowViewModel : ObservableObject case TimetableViewModel vm: vm.Load(); break; case WorkloadViewModel vm: vm.Tasks.Load(); vm.TimeTracking.Load(); vm.Evaluation.Load(); break; - case ClassTeacherOverviewViewModel vm: vm.LoadCommand.Execute(null); break; + // RefreshFromLocalDataOnly() statt LoadCommand: baut die Ansicht aus den zuletzt + // geholten WebUntis-Daten + aktuellen lokalen Daten neu auf, löst aber selbst nie einen + // WebUntis-Request aus - den holt bewusst ausschließlich der "Aktualisieren"-Button. + case ClassTeacherOverviewViewModel vm: vm.RefreshFromLocalDataOnly(); break; case ExamsOverviewViewModel vm: vm.LoadCommand.Execute(null); break; case GroupDetailViewModel { Group: { } group } vm: vm.LoadGroup(group.Id); break; // Inline-Bearbeitung (IsEditing) nicht überschreiben - anders als die Gruppenansicht @@ -176,7 +179,11 @@ public partial class MainWindowViewModel : ObservableObject var vm = _services.GetRequiredService(); vm.OnNavigateToSettings = () => { NavigateToSettings(SettingsTab.WebUntis); return Task.CompletedTask; }; vm.OnNavigateToWorkload = () => { NavigateToWorkload(); return Task.CompletedTask; }; - vm.LoadCommand.Execute(null); + // Kein automatischer WebUntis-Abruf beim Navigieren: PrepareForDisplay() baut nur den + // lokalen Grundzustand auf, die eigentlichen Report-Daten holt die Nutzerin bewusst über + // den "Aktualisieren"-Button (LoadCommand) - vermeidet WebUntis-Login+Report-Latenz beim + // bloßen Öffnen der Seite. + vm.PrepareForDisplay(); return vm; } diff --git a/LehrerApp.WebUntis/WebUntisClient.cs b/LehrerApp.WebUntis/WebUntisClient.cs index ccf2a1e..70e71cf 100644 --- a/LehrerApp.WebUntis/WebUntisClient.cs +++ b/LehrerApp.WebUntis/WebUntisClient.cs @@ -10,12 +10,26 @@ public sealed class WebUntisClient : IAsyncDisposable private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); private static readonly UTF8Encoding StrictUtf8 = new(false, true); private static readonly TimeSpan LogoutTimeout = TimeSpan.FromSeconds(2); + // Feste (nicht zufällige) kurze Pause zwischen Login und erstem Report-Request, damit beides + // nicht im Millisekundenabstand hintereinander passiert. + private static readonly TimeSpan PostLoginDelay = TimeSpan.FromSeconds(1); + + // WebUntis blockt Anfragen ohne die Header, die die eigene WebApp immer mitschickt, inzwischen + // serverseitig mit HTTP 403. Header-Satz per "Copy as cURL" aus einem echten Browser-Request + // 1:1 übernommen (kein X-Requested-With, WebUntis' Angular-Frontend sendet das nicht). + private const string BrowserUserAgent = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.6.2 Safari/605.1.15"; private readonly HttpClient _http; private readonly WebUntisOptions _options; private readonly SemaphoreSlim _sessionGate = new(1, 1); private readonly Timer _sessionExpiryTimer; private string? _sessionId; private int? _myTeacherUntisId; + // Cookies, die WebUntis beim Login per Set-Cookie mitschickt (z.B. Tenant-Id, traceId) und die + // die WebApp bei jedem weiteren Request 1:1 zurücksendet. JSESSIONID wird separat verwaltet + // (siehe _sessionId), deshalb hier ausgeklammert. + private string? _serverCookies; + private string? _tenantId; private DateTimeOffset _sessionExpiresAt; private int _activeRequests; private bool _disposed; @@ -330,13 +344,14 @@ public sealed class WebUntisClient : IAsyncDisposable user = configuration.Username, password = configuration.Password, client = configuration.Client, - }, null, cancellationToken); + }, null, cancellationToken, CaptureServerCookies); _sessionId = OptionalString(result, "sessionId") ?? throw new WebUntisException( $"WebUntis-Login fehlgeschlagen: {configuration.Host} hat für die Schulkennung " + $"„{configuration.School}“ keine Sitzung geliefert. Bitte insbesondere Server und " + "Schulkennung mit der WebUntis-Anmeldeseite vergleichen."); _myTeacherUntisId = OptionalInt(result, "personId"); + await Task.Delay(PostLoginDelay, cancellationToken); } _activeRequests++; @@ -424,7 +439,7 @@ public sealed class WebUntisClient : IAsyncDisposable TimeSpan.FromMinutes(Math.Clamp(_options.SessionIdleTimeoutMinutes, 1, 30)); private async Task RpcAsync(string method, object parameters, string? sessionId, - CancellationToken cancellationToken) + CancellationToken cancellationToken, Action? onResponse = null) { var configuration = GetConfiguration(); var uri = $"https://{configuration.Host}/WebUntis/jsonrpc.do?school={Uri.EscapeDataString(configuration.School)}"; @@ -433,9 +448,11 @@ public sealed class WebUntisClient : IAsyncDisposable Content = JsonContent.Create(new { id = "lehrerapp-webuntis", method, @params = parameters, jsonrpc = "2.0" }, options: JsonOptions), }; - if (sessionId is not null) request.Headers.Add("Cookie", $"JSESSIONID={sessionId}"); + ApplyBrowserHeaders(request, configuration.Host); + if (sessionId is not null) request.Headers.Add("Cookie", SessionCookie(sessionId)); using var response = await SendAsync(request, TimeSpan.FromSeconds(20), cancellationToken); + onResponse?.Invoke(response); var payload = await ReadJsonAsync(response, cancellationToken); if (!response.IsSuccessStatusCode) throw new WebUntisException($"WebUntis RPC ({method}) fehlgeschlagen: {ErrorMessage(payload, response)}"); @@ -521,10 +538,48 @@ public sealed class WebUntisClient : IAsyncDisposable { var request = new HttpRequestMessage(HttpMethod.Get, uri); if (acceptJson) request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - request.Headers.Add("Cookie", $"JSESSIONID={sessionId}; schoolname=\"_{SchoolCookie()}\""); + ApplyBrowserHeaders(request, GetConfiguration().Host); + request.Headers.Add("Cookie", SessionCookie(sessionId)); + if (_tenantId is not null) request.Headers.Add("Tenant-Id", _tenantId); return request; } + private string SessionCookie(string sessionId) + { + var cookie = $"JSESSIONID={sessionId}; schoolname=\"_{SchoolCookie()}\""; + return _serverCookies is null ? cookie : $"{cookie}; {_serverCookies}"; + } + + // Sammelt Set-Cookie-Werte aus der Login-Antwort ein (z.B. Tenant-Id, traceId), die WebUntis' + // WebApp danach bei jedem Request unverändert zurückschickt. JSESSIONID wird ausgeklammert, da + // sie bereits separat über _sessionId verwaltet wird. + private void CaptureServerCookies(HttpResponseMessage response) + { + if (!response.Headers.TryGetValues("Set-Cookie", out var setCookieHeaders)) return; + var pairs = new List(); + foreach (var header in setCookieHeaders) + { + var nameValue = header.Split(';', 2)[0].Trim(); + var name = nameValue.Split('=', 2)[0]; + if (name.Equals("JSESSIONID", StringComparison.OrdinalIgnoreCase)) continue; + pairs.Add(nameValue); + if (name.Equals("Tenant-Id", StringComparison.OrdinalIgnoreCase) && + nameValue.Split('=', 2) is [_, var value]) + _tenantId = value.Trim('"'); + } + _serverCookies = pairs.Count > 0 ? string.Join("; ", pairs) : null; + } + + private static void ApplyBrowserHeaders(HttpRequestMessage request, string host) + { + request.Headers.UserAgent.ParseAdd(BrowserUserAgent); + request.Headers.Referrer = new Uri($"https://{host}/WebUntis/embedded.do?showSidebar=true"); + request.Headers.Add("Accept-Language", "de-DE,de;q=0.9"); + request.Headers.Add("Sec-Fetch-Site", "same-origin"); + request.Headers.Add("Sec-Fetch-Mode", "cors"); + request.Headers.Add("Sec-Fetch-Dest", "empty"); + } + private async Task SendAsync(HttpRequestMessage request, TimeSpan timeout, CancellationToken cancellationToken) {