From 17475a781f8d8dd9e5e2edd23bfa807a51029b89 Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Mon, 24 Aug 2026 22:33:30 +0200 Subject: [PATCH] Untis-API Optimierung. --- LehrerApp.Desktop/App.axaml.cs | 16 +++--- .../Services/WebUntisIntegrationService.cs | 23 ++++---- .../WebUntisAbsenceComparisonViewModel.cs | 15 +++--- .../Views/Groups/GroupDetailView.axaml.cs | 14 ++++- .../WebUntisClientTests.cs | 53 +++++++++++++++++-- .../WebUntisStudentReportParserTests.cs | 14 +++++ LehrerApp.WebUntis/WebUntisClient.cs | 18 +++---- LehrerApp.WebUntis/WebUntisModels.cs | 7 --- .../WebUntisStudentReportParser.cs | 2 +- 9 files changed, 109 insertions(+), 53 deletions(-) diff --git a/LehrerApp.Desktop/App.axaml.cs b/LehrerApp.Desktop/App.axaml.cs index 96679e2..77790c5 100644 --- a/LehrerApp.Desktop/App.axaml.cs +++ b/LehrerApp.Desktop/App.axaml.cs @@ -100,11 +100,12 @@ public class App : Application private static void DisposeServices() { - if (_serviceProvider is null) return; + var serviceProvider = Interlocked.Exchange(ref _serviceProvider, null); + if (serviceProvider is null) return; try { - _serviceProvider.GetService()?.Checkpoint(); + serviceProvider.GetService()?.Checkpoint(); } catch (Exception ex) { @@ -112,18 +113,19 @@ public class App : Application } finally { - // Der direkte WebUntis-Client meldet seine Sitzung asynchron ab. Ein synchrones - // ServiceProvider.Dispose() lehnt reine IAsyncDisposable-Dienste ab und ließ die App - // beim Schließen mit InvalidOperationException abstürzen. + // Der Exit-Handler läuft synchron auf dem Avalonia-UI-Thread. Die asynchrone + // Entsorgung darf dort nicht mit GetResult() gestartet werden: Fortsetzungen aus + // WebUntis/HttpClient könnten sonst auf den blockierten UI-Kontext zurückwarten. try { - _serviceProvider.DisposeAsync().AsTask().GetAwaiter().GetResult(); + Task.Run(async () => + await serviceProvider.DisposeAsync().ConfigureAwait(false)) + .GetAwaiter().GetResult(); } catch (Exception ex) { AppBootstrapper.Logger.Error("Dienste konnten beim Beenden nicht vollständig freigegeben werden.", ex); } - _serviceProvider = null; } } diff --git a/LehrerApp.Desktop/Services/WebUntisIntegrationService.cs b/LehrerApp.Desktop/Services/WebUntisIntegrationService.cs index 04c4500..76d3218 100644 --- a/LehrerApp.Desktop/Services/WebUntisIntegrationService.cs +++ b/LehrerApp.Desktop/Services/WebUntisIntegrationService.cs @@ -30,8 +30,6 @@ public sealed record UntisStudentReportDto(int Count, string? ClassNameFilter, I public sealed record UntisStudentAbsenceDto(int StudentKey, int Date, int StartTime, int EndTime, int AbsentMinutes, bool Checked, string? AbsenceReason, string? ExcuseStatus, int? SubjectId, IReadOnlyList TeacherIds, string? StudentGroup); -public sealed record UntisStudentAbsenceReportDto(int StudentKey, int StartDate, int EndDate, int EntryCount, - int AbsentMinutes, IReadOnlyList Absences); /// Direkter WebUntis-Client des Desktops. Personenbezogene Antworten und der /// unverschlüsselte CSV-Report passieren zu keinem Zeitpunkt den LehrerApp-Server. @@ -56,19 +54,19 @@ public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettings } WebUntisClient? previous; - await _clientGate.WaitAsync(token); + await _clientGate.WaitAsync(token).ConfigureAwait(false); try { previous = _client; _client = candidate; } finally { _clientGate.Release(); } - if (previous is not null) await previous.DisposeAsync(); + if (previous is not null) await previous.DisposeAsync().ConfigureAwait(false); } public async Task DisconnectAsync(CancellationToken token = default) { WebUntisClient? previous; - await _clientGate.WaitAsync(token); + await _clientGate.WaitAsync(token).ConfigureAwait(false); try { previous = _client; _client = null; } finally { _clientGate.Release(); } - if (previous is not null) await previous.DisposeAsync(); + if (previous is not null) await previous.DisposeAsync().ConfigureAwait(false); } public Task> GetSchoolYearsAsync(CancellationToken token = default) => ExecuteAsync( @@ -109,14 +107,13 @@ public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettings x.Address.PostCode, x.Address.Street), x.AttributeIL)).ToList()); }, token); - public Task GetAbsencesAsync(int studentKey, DateOnly start, DateOnly end, + public Task> GetAbsencesAsync(DateOnly start, DateOnly end, CancellationToken token = default) => ExecuteAsync(async client => { - var report = await client.GetStudentAbsencesAsync(studentKey, Date(start), Date(end), token); - return new UntisStudentAbsenceReportDto(report.StudentKey, report.StartDate, report.EndDate, - report.EntryCount, report.AbsentMinutes, report.Absences.Select(x => new UntisStudentAbsenceDto( - x.StudentKey, x.Date, x.StartTime, x.EndTime, x.AbsentMinutes, x.Checked, x.AbsenceReason, - x.ExcuseStatus, x.SubjectId, x.TeacherIds, x.StudentGroup)).ToList()); + var absences = await client.GetAbsencesAsync(Date(start), Date(end), token); + return (IReadOnlyList)absences.Select(x => new UntisStudentAbsenceDto( + x.StudentKey, x.Date, x.StartTime, x.EndTime, x.AbsentMinutes, x.Checked, x.AbsenceReason, + x.ExcuseStatus, x.SubjectId, x.TeacherIds, x.StudentGroup)).ToList(); }, token); private async Task ExecuteAsync(Func> operation, CancellationToken token) @@ -153,5 +150,5 @@ public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettings private static WebUntisIntegrationException Translate(Exception exception) => new(exception.Message); - public async ValueTask DisposeAsync() => await DisconnectAsync(); + public async ValueTask DisposeAsync() => await DisconnectAsync().ConfigureAwait(false); } diff --git a/LehrerApp.Desktop/ViewModels/Groups/WebUntisAbsenceComparisonViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/WebUntisAbsenceComparisonViewModel.cs index 7fe212c..b96cb9f 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/WebUntisAbsenceComparisonViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/WebUntisAbsenceComparisonViewModel.cs @@ -1,4 +1,3 @@ -using System.Collections.Concurrent; using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -60,16 +59,14 @@ public partial class WebUntisAbsenceComparisonViewModel : ObservableObject var localSessions = _sessions.GetByGroup(_group.Id) .Where(x => x.Date >= start && x.Date <= end).GroupBy(x => x.Date) .ToDictionary(x => x.Key, x => x.First()); - var loaded = new ConcurrentBag<(Student Student, UntisStudentAbsenceDto Absence)>(); var linked = courseStudents.Select(student => (Student: student, Key: StudentKey(student))) - .Where(x => x.Key is not null).ToList(); - await Parallel.ForEachAsync(linked, new ParallelOptions { MaxDegreeOfParallelism = 4 }, async (item, token) => - { - var report = await _untis.GetAbsencesAsync(item.Key!.Value, start, end, token); - foreach (var absence in report.Absences) loaded.Add((item.Student, absence)); - }); + .Where(x => x.Key is not null).ToDictionary(x => x.Key!.Value, x => x.Student); + var absences = await _untis.GetAbsencesAsync(start, end); + var loaded = absences.Where(absence => linked.ContainsKey(absence.StudentKey)) + .Select(absence => (Student: linked[absence.StudentKey], Absence: absence)) + .OrderBy(x => x.Absence.Date).ThenBy(x => x.Student.FullName); - foreach (var item in loaded.OrderBy(x => x.Absence.Date).ThenBy(x => x.Student.FullName)) + foreach (var item in loaded) { if (!TryDate(item.Absence.Date, out var date)) continue; localSessions.TryGetValue(date, out var session); diff --git a/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml.cs b/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml.cs index 50b8eeb..ecf8483 100644 --- a/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml.cs +++ b/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml.cs @@ -13,6 +13,7 @@ using LehrerApp.Desktop.Views.Shared; using LehrerApp.Desktop.Views.Students; using LehrerApp.Desktop.Views.Workload; using Microsoft.Extensions.DependencyInjection; +using System.Globalization; using System.Text; namespace LehrerApp.Desktop.Views.Groups; @@ -174,7 +175,7 @@ public partial class GroupDetailView : UserControl var values = new[] { student.LongName ?? student.Name, student.ForeName, student.Gender, - student.BirthDate?.ToString() ?? student.BirthDateRaw, student.ClassName, + FormatBirthDate(student), student.ClassName, student.ExternKey.ToString(), student.Address.Email, student.Address.Mobile, student.Address.Phone, student.Address.City, student.Address.PostCode, student.Address.Street, }; @@ -183,6 +184,17 @@ public partial class GroupDetailView : UserControl return new ImportFile("webuntis-students.csv", Encoding.UTF8.GetBytes(builder.ToString())); } + private static string? FormatBirthDate(UntisStudentDto student) + { + if (student.BirthDate is not { } normalizedDate) return student.BirthDateRaw; + + var value = normalizedDate.ToString("D8", CultureInfo.InvariantCulture); + return DateOnly.TryParseExact(value, "yyyyMMdd", CultureInfo.InvariantCulture, + DateTimeStyles.None, out var date) + ? date.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture) + : student.BirthDateRaw; + } + private static string SafeTsv(string? value) => (value ?? "").Replace('\t', ' ') .Replace('\r', ' ').Replace('\n', ' '); diff --git a/LehrerApp.WebUntis.Tests/WebUntisClientTests.cs b/LehrerApp.WebUntis.Tests/WebUntisClientTests.cs index 937aff8..39d3321 100644 --- a/LehrerApp.WebUntis.Tests/WebUntisClientTests.cs +++ b/LehrerApp.WebUntis.Tests/WebUntisClientTests.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Diagnostics; using System.Text; using LehrerApp.WebUntis; using Xunit; @@ -84,6 +85,20 @@ public sealed class WebUntisClientTests await client.DisposeAsync(); } + [Fact] + public async Task Dispose_BegrenztEinenNichtAntwortendenLogout() + { + var handler = new HangingLogoutHandler(); + var client = CreateClient(handler); + await client.GetSchoolYearsAsync(CancellationToken.None); + + var elapsed = Stopwatch.StartNew(); + await client.DisposeAsync(); + + Assert.True(elapsed.Elapsed < TimeSpan.FromSeconds(5)); + Assert.True(handler.LogoutWasCancelled); + } + [Fact] public async Task VollstaendigeLoginUrl_TrenntRegionalenServerUndSchulkennung() { @@ -128,7 +143,7 @@ public sealed class WebUntisClientTests } [Fact] - public async Task FehlzeitenUndKlassenbuch_WerdenNachSchuelerAbgerufenUndTypisiert() + public async Task FehlzeitenUndKlassenbuch_WerdenTypisiertAbgerufen() { var handler = new QueueHandler( Json("{\"result\":{\"sessionId\":\"s\"}}"), @@ -147,15 +162,15 @@ public sealed class WebUntisClientTests Json("{\"result\":{}}")); var client = CreateClient(handler); - var report = await client.GetStudentAbsencesAsync(9001, 20260801, 20270731, - CancellationToken.None); + var absences = await client.GetAbsencesAsync(20260801, 20270731, CancellationToken.None); var entries = await client.GetClassRegisterEntriesAsync(17, 20260801, 20270731, CancellationToken.None); var categories = await client.GetClassRegisterCategoriesAsync(CancellationToken.None); var groups = await client.GetClassRegisterCategoryGroupsAsync(CancellationToken.None); - var absence = Assert.Single(report.Absences); - Assert.Equal(45, report.AbsentMinutes); + Assert.Equal(2, absences.Count); + var absence = Assert.Single(absences, x => x.StudentKey == 9001); + Assert.Equal(45, absence.AbsentMinutes); Assert.Equal("entschuldigt", absence.ExcuseStatus); Assert.Equal(7, Assert.Single(absence.TeacherIds)); var entry = Assert.Single(entries); @@ -207,5 +222,33 @@ public sealed class WebUntisClientTests } } + private sealed class HangingLogoutHandler : HttpMessageHandler + { + public bool LogoutWasCancelled { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) + { + var body = request.Content is null + ? "" + : await request.Content.ReadAsStringAsync(cancellationToken); + if (body.Contains("\"method\":\"authenticate\"")) + return Json("{\"result\":{\"sessionId\":\"s\"}}"); + if (body.Contains("\"method\":\"getSchoolyears\"")) + return Json("{\"result\":[]}"); + + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + throw new InvalidOperationException("Der simulierte Logout darf nicht regulär enden."); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + LogoutWasCancelled = true; + throw; + } + } + } + private sealed record CapturedRequest(string Uri, string Body, string Cookie); } diff --git a/LehrerApp.WebUntis.Tests/WebUntisStudentReportParserTests.cs b/LehrerApp.WebUntis.Tests/WebUntisStudentReportParserTests.cs index 85e43de..167fb17 100644 --- a/LehrerApp.WebUntis.Tests/WebUntisStudentReportParserTests.cs +++ b/LehrerApp.WebUntis.Tests/WebUntisStudentReportParserTests.cs @@ -37,6 +37,20 @@ public sealed class WebUntisStudentReportParserTests Assert.Equal("MUS\"T", student.DisplayName); } + [Fact] + public void Parse_AkzeptiertIsoDatumswerteOhneTrennzeichen() + { + const string report = "id\texternKey\tklasse.name\tbirthDate\tentryDate\texitDate\r\n" + + "17\t9001\t10a\t20100203\t20210801\t20270731\r\n"; + + var student = Assert.Single(WebUntisStudentReportParser.Parse(report)); + + Assert.Equal(20100203, student.BirthDate); + Assert.Equal("20100203", student.BirthDateRaw); + Assert.Equal(20210801, student.EntryDate); + Assert.Equal(20270731, student.ExitDate); + } + [Fact] public void Parse_LehntUngueltigePflichtIdAb() { diff --git a/LehrerApp.WebUntis/WebUntisClient.cs b/LehrerApp.WebUntis/WebUntisClient.cs index b54996d..74f7ded 100644 --- a/LehrerApp.WebUntis/WebUntisClient.cs +++ b/LehrerApp.WebUntis/WebUntisClient.cs @@ -9,6 +9,7 @@ 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); private readonly HttpClient _http; private readonly WebUntisOptions _options; private readonly SemaphoreSlim _sessionGate = new(1, 1); @@ -177,7 +178,7 @@ public sealed class WebUntisClient : IAsyncDisposable Entities(entry, "ro"), entry.Clone())).ToList(); }, cancellationToken); - public Task GetStudentAbsencesAsync(int studentKey, int startDate, int endDate, + public Task> GetAbsencesAsync(int startDate, int endDate, CancellationToken cancellationToken) => WithSessionAsync(async sessionId => { var result = await RpcAsync("getTimetableWithAbsences", new @@ -187,8 +188,7 @@ public sealed class WebUntisClient : IAsyncDisposable if (!TryProperty(result, "periodsWithAbsences", out var periods) || periods.ValueKind != JsonValueKind.Array) throw new WebUntisException("WebUntis hat keine gültigen Fehlzeiten-Daten geliefert."); - var absences = periods.EnumerateArray() - .Where(entry => OptionalInt(entry, "studentId") == studentKey) + return (IReadOnlyList)periods.EnumerateArray() .Select(entry => new UntisStudentAbsence( RequiredInt(entry, "studentId"), RequiredInt(entry, "date"), @@ -205,9 +205,6 @@ public sealed class WebUntisClient : IAsyncDisposable .OrderBy(entry => entry.Date) .ThenBy(entry => entry.StartTime) .ToList(); - - return new UntisStudentAbsenceReport(studentKey, startDate, endDate, absences.Count, - absences.Sum(entry => entry.AbsentMinutes), absences); }, cancellationToken); public Task> GetClassRegisterEntriesAsync(int studentId, @@ -346,14 +343,15 @@ public sealed class WebUntisClient : IAsyncDisposable private async Task TryLogoutAsync(string sessionId) { - try { await RpcAsync("logout", new { }, sessionId, CancellationToken.None); } + using var timeout = new CancellationTokenSource(LogoutTimeout); + try { await RpcAsync("logout", new { }, sessionId, timeout.Token).ConfigureAwait(false); } catch { /* Ein fehlgeschlagener Logout darf Abrufe und Shutdown nicht fehlschlagen lassen. */ } } public async ValueTask DisposeAsync() { string? sessionId; - await _sessionGate.WaitAsync(); + await _sessionGate.WaitAsync().ConfigureAwait(false); try { if (_disposed) return; @@ -368,8 +366,8 @@ public sealed class WebUntisClient : IAsyncDisposable // Außerhalb des Gates warten: ein bereits laufender Timer-Callback könnte selbst gerade // auf dieses Gate warten und würde sonst den Shutdown blockieren. - await _sessionExpiryTimer.DisposeAsync(); - if (sessionId is not null) await TryLogoutAsync(sessionId); + await _sessionExpiryTimer.DisposeAsync().ConfigureAwait(false); + if (sessionId is not null) await TryLogoutAsync(sessionId).ConfigureAwait(false); } private TimeSpan SessionIdleTimeout => diff --git a/LehrerApp.WebUntis/WebUntisModels.cs b/LehrerApp.WebUntis/WebUntisModels.cs index a8ee514..2c6d54e 100644 --- a/LehrerApp.WebUntis/WebUntisModels.cs +++ b/LehrerApp.WebUntis/WebUntisModels.cs @@ -146,13 +146,6 @@ public sealed record UntisStudentAbsence( string? StudentGroup, JsonElement Raw); -public sealed record UntisStudentAbsenceReport( - int StudentKey, - int StartDate, - int EndDate, - int EntryCount, - int AbsentMinutes, - IReadOnlyList Absences); public sealed record UntisClassRegisterEntry( int? StudentKey, diff --git a/LehrerApp.WebUntis/WebUntisStudentReportParser.cs b/LehrerApp.WebUntis/WebUntisStudentReportParser.cs index 6accfa8..2896247 100644 --- a/LehrerApp.WebUntis/WebUntisStudentReportParser.cs +++ b/LehrerApp.WebUntis/WebUntisStudentReportParser.cs @@ -125,7 +125,7 @@ public static class WebUntisStudentReportParser private static int? GermanDate(string? value) { - if (!DateOnly.TryParseExact(value?.Trim(), "dd.MM.yyyy", CultureInfo.InvariantCulture, + if (!DateOnly.TryParseExact(value?.Trim(), ["dd.MM.yyyy", "yyyyMMdd"], CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) return null; return date.Year * 10_000 + date.Month * 100 + date.Day;