Untis-API Optimierung.

This commit is contained in:
2026-08-24 22:33:30 +02:00
parent 1db16f8b69
commit 17475a781f
9 changed files with 109 additions and 53 deletions
+9 -7
View File
@@ -100,11 +100,12 @@ public class App : Application
private static void DisposeServices() private static void DisposeServices()
{ {
if (_serviceProvider is null) return; var serviceProvider = Interlocked.Exchange(ref _serviceProvider, null);
if (serviceProvider is null) return;
try try
{ {
_serviceProvider.GetService<LiteDbContext>()?.Checkpoint(); serviceProvider.GetService<LiteDbContext>()?.Checkpoint();
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -112,18 +113,19 @@ public class App : Application
} }
finally finally
{ {
// Der direkte WebUntis-Client meldet seine Sitzung asynchron ab. Ein synchrones // Der Exit-Handler läuft synchron auf dem Avalonia-UI-Thread. Die asynchrone
// ServiceProvider.Dispose() lehnt reine IAsyncDisposable-Dienste ab und ließ die App // Entsorgung darf dort nicht mit GetResult() gestartet werden: Fortsetzungen aus
// beim Schließen mit InvalidOperationException abstürzen. // WebUntis/HttpClient könnten sonst auf den blockierten UI-Kontext zurückwarten.
try try
{ {
_serviceProvider.DisposeAsync().AsTask().GetAwaiter().GetResult(); Task.Run(async () =>
await serviceProvider.DisposeAsync().ConfigureAwait(false))
.GetAwaiter().GetResult();
} }
catch (Exception ex) catch (Exception ex)
{ {
AppBootstrapper.Logger.Error("Dienste konnten beim Beenden nicht vollständig freigegeben werden.", ex); AppBootstrapper.Logger.Error("Dienste konnten beim Beenden nicht vollständig freigegeben werden.", ex);
} }
_serviceProvider = null;
} }
} }
@@ -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, public sealed record UntisStudentAbsenceDto(int StudentKey, int Date, int StartTime, int EndTime, int AbsentMinutes,
bool Checked, string? AbsenceReason, string? ExcuseStatus, int? SubjectId, IReadOnlyList<int> TeacherIds, bool Checked, string? AbsenceReason, string? ExcuseStatus, int? SubjectId, IReadOnlyList<int> TeacherIds,
string? StudentGroup); string? StudentGroup);
public sealed record UntisStudentAbsenceReportDto(int StudentKey, int StartDate, int EndDate, int EntryCount,
int AbsentMinutes, IReadOnlyList<UntisStudentAbsenceDto> Absences);
/// <summary>Direkter WebUntis-Client des Desktops. Personenbezogene Antworten und der /// <summary>Direkter WebUntis-Client des Desktops. Personenbezogene Antworten und der
/// unverschlüsselte CSV-Report passieren zu keinem Zeitpunkt den LehrerApp-Server.</summary> /// unverschlüsselte CSV-Report passieren zu keinem Zeitpunkt den LehrerApp-Server.</summary>
@@ -56,19 +54,19 @@ public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettings
} }
WebUntisClient? previous; WebUntisClient? previous;
await _clientGate.WaitAsync(token); await _clientGate.WaitAsync(token).ConfigureAwait(false);
try { previous = _client; _client = candidate; } try { previous = _client; _client = candidate; }
finally { _clientGate.Release(); } 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) public async Task DisconnectAsync(CancellationToken token = default)
{ {
WebUntisClient? previous; WebUntisClient? previous;
await _clientGate.WaitAsync(token); await _clientGate.WaitAsync(token).ConfigureAwait(false);
try { previous = _client; _client = null; } try { previous = _client; _client = null; }
finally { _clientGate.Release(); } finally { _clientGate.Release(); }
if (previous is not null) await previous.DisposeAsync(); if (previous is not null) await previous.DisposeAsync().ConfigureAwait(false);
} }
public Task<IReadOnlyList<UntisSchoolYearDto>> GetSchoolYearsAsync(CancellationToken token = default) => ExecuteAsync( public Task<IReadOnlyList<UntisSchoolYearDto>> 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()); x.Address.PostCode, x.Address.Street), x.AttributeIL)).ToList());
}, token); }, token);
public Task<UntisStudentAbsenceReportDto> GetAbsencesAsync(int studentKey, DateOnly start, DateOnly end, public Task<IReadOnlyList<UntisStudentAbsenceDto>> GetAbsencesAsync(DateOnly start, DateOnly end,
CancellationToken token = default) => ExecuteAsync(async client => CancellationToken token = default) => ExecuteAsync(async client =>
{ {
var report = await client.GetStudentAbsencesAsync(studentKey, Date(start), Date(end), token); var absences = await client.GetAbsencesAsync(Date(start), Date(end), token);
return new UntisStudentAbsenceReportDto(report.StudentKey, report.StartDate, report.EndDate, return (IReadOnlyList<UntisStudentAbsenceDto>)absences.Select(x => new UntisStudentAbsenceDto(
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.StudentKey, x.Date, x.StartTime, x.EndTime, x.AbsentMinutes, x.Checked, x.AbsenceReason, x.ExcuseStatus, x.SubjectId, x.TeacherIds, x.StudentGroup)).ToList();
x.ExcuseStatus, x.SubjectId, x.TeacherIds, x.StudentGroup)).ToList());
}, token); }, token);
private async Task<T> ExecuteAsync<T>(Func<WebUntisClient, Task<T>> operation, CancellationToken token) private async Task<T> ExecuteAsync<T>(Func<WebUntisClient, Task<T>> operation, CancellationToken token)
@@ -153,5 +150,5 @@ public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettings
private static WebUntisIntegrationException Translate(Exception exception) => private static WebUntisIntegrationException Translate(Exception exception) =>
new(exception.Message); new(exception.Message);
public async ValueTask DisposeAsync() => await DisconnectAsync(); public async ValueTask DisposeAsync() => await DisconnectAsync().ConfigureAwait(false);
} }
@@ -1,4 +1,3 @@
using System.Collections.Concurrent;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
@@ -60,16 +59,14 @@ public partial class WebUntisAbsenceComparisonViewModel : ObservableObject
var localSessions = _sessions.GetByGroup(_group.Id) var localSessions = _sessions.GetByGroup(_group.Id)
.Where(x => x.Date >= start && x.Date <= end).GroupBy(x => x.Date) .Where(x => x.Date >= start && x.Date <= end).GroupBy(x => x.Date)
.ToDictionary(x => x.Key, x => x.First()); .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))) var linked = courseStudents.Select(student => (Student: student, Key: StudentKey(student)))
.Where(x => x.Key is not null).ToList(); .Where(x => x.Key is not null).ToDictionary(x => x.Key!.Value, x => x.Student);
await Parallel.ForEachAsync(linked, new ParallelOptions { MaxDegreeOfParallelism = 4 }, async (item, token) => var absences = await _untis.GetAbsencesAsync(start, end);
{ var loaded = absences.Where(absence => linked.ContainsKey(absence.StudentKey))
var report = await _untis.GetAbsencesAsync(item.Key!.Value, start, end, token); .Select(absence => (Student: linked[absence.StudentKey], Absence: absence))
foreach (var absence in report.Absences) loaded.Add((item.Student, 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; if (!TryDate(item.Absence.Date, out var date)) continue;
localSessions.TryGetValue(date, out var session); localSessions.TryGetValue(date, out var session);
@@ -13,6 +13,7 @@ using LehrerApp.Desktop.Views.Shared;
using LehrerApp.Desktop.Views.Students; using LehrerApp.Desktop.Views.Students;
using LehrerApp.Desktop.Views.Workload; using LehrerApp.Desktop.Views.Workload;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using System.Globalization;
using System.Text; using System.Text;
namespace LehrerApp.Desktop.Views.Groups; namespace LehrerApp.Desktop.Views.Groups;
@@ -174,7 +175,7 @@ public partial class GroupDetailView : UserControl
var values = new[] var values = new[]
{ {
student.LongName ?? student.Name, student.ForeName, student.Gender, 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.ExternKey.ToString(), student.Address.Email, student.Address.Mobile,
student.Address.Phone, student.Address.City, student.Address.PostCode, student.Address.Street, 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())); 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', ' ') private static string SafeTsv(string? value) => (value ?? "").Replace('\t', ' ')
.Replace('\r', ' ').Replace('\n', ' '); .Replace('\r', ' ').Replace('\n', ' ');
@@ -1,4 +1,5 @@
using System.Net; using System.Net;
using System.Diagnostics;
using System.Text; using System.Text;
using LehrerApp.WebUntis; using LehrerApp.WebUntis;
using Xunit; using Xunit;
@@ -84,6 +85,20 @@ public sealed class WebUntisClientTests
await client.DisposeAsync(); 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] [Fact]
public async Task VollstaendigeLoginUrl_TrenntRegionalenServerUndSchulkennung() public async Task VollstaendigeLoginUrl_TrenntRegionalenServerUndSchulkennung()
{ {
@@ -128,7 +143,7 @@ public sealed class WebUntisClientTests
} }
[Fact] [Fact]
public async Task FehlzeitenUndKlassenbuch_WerdenNachSchuelerAbgerufenUndTypisiert() public async Task FehlzeitenUndKlassenbuch_WerdenTypisiertAbgerufen()
{ {
var handler = new QueueHandler( var handler = new QueueHandler(
Json("{\"result\":{\"sessionId\":\"s\"}}"), Json("{\"result\":{\"sessionId\":\"s\"}}"),
@@ -147,15 +162,15 @@ public sealed class WebUntisClientTests
Json("{\"result\":{}}")); Json("{\"result\":{}}"));
var client = CreateClient(handler); var client = CreateClient(handler);
var report = await client.GetStudentAbsencesAsync(9001, 20260801, 20270731, var absences = await client.GetAbsencesAsync(20260801, 20270731, CancellationToken.None);
CancellationToken.None);
var entries = await client.GetClassRegisterEntriesAsync(17, 20260801, 20270731, var entries = await client.GetClassRegisterEntriesAsync(17, 20260801, 20270731,
CancellationToken.None); CancellationToken.None);
var categories = await client.GetClassRegisterCategoriesAsync(CancellationToken.None); var categories = await client.GetClassRegisterCategoriesAsync(CancellationToken.None);
var groups = await client.GetClassRegisterCategoryGroupsAsync(CancellationToken.None); var groups = await client.GetClassRegisterCategoryGroupsAsync(CancellationToken.None);
var absence = Assert.Single(report.Absences); Assert.Equal(2, absences.Count);
Assert.Equal(45, report.AbsentMinutes); var absence = Assert.Single(absences, x => x.StudentKey == 9001);
Assert.Equal(45, absence.AbsentMinutes);
Assert.Equal("entschuldigt", absence.ExcuseStatus); Assert.Equal("entschuldigt", absence.ExcuseStatus);
Assert.Equal(7, Assert.Single(absence.TeacherIds)); Assert.Equal(7, Assert.Single(absence.TeacherIds));
var entry = Assert.Single(entries); 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<HttpResponseMessage> 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); private sealed record CapturedRequest(string Uri, string Body, string Cookie);
} }
@@ -37,6 +37,20 @@ public sealed class WebUntisStudentReportParserTests
Assert.Equal("MUS\"T", student.DisplayName); 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] [Fact]
public void Parse_LehntUngueltigePflichtIdAb() public void Parse_LehntUngueltigePflichtIdAb()
{ {
+8 -10
View File
@@ -9,6 +9,7 @@ public sealed class WebUntisClient : IAsyncDisposable
{ {
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private static readonly UTF8Encoding StrictUtf8 = new(false, true); private static readonly UTF8Encoding StrictUtf8 = new(false, true);
private static readonly TimeSpan LogoutTimeout = TimeSpan.FromSeconds(2);
private readonly HttpClient _http; private readonly HttpClient _http;
private readonly WebUntisOptions _options; private readonly WebUntisOptions _options;
private readonly SemaphoreSlim _sessionGate = new(1, 1); private readonly SemaphoreSlim _sessionGate = new(1, 1);
@@ -177,7 +178,7 @@ public sealed class WebUntisClient : IAsyncDisposable
Entities(entry, "ro"), entry.Clone())).ToList(); Entities(entry, "ro"), entry.Clone())).ToList();
}, cancellationToken); }, cancellationToken);
public Task<UntisStudentAbsenceReport> GetStudentAbsencesAsync(int studentKey, int startDate, int endDate, public Task<IReadOnlyList<UntisStudentAbsence>> GetAbsencesAsync(int startDate, int endDate,
CancellationToken cancellationToken) => WithSessionAsync(async sessionId => CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
{ {
var result = await RpcAsync("getTimetableWithAbsences", new 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) if (!TryProperty(result, "periodsWithAbsences", out var periods) || periods.ValueKind != JsonValueKind.Array)
throw new WebUntisException("WebUntis hat keine gültigen Fehlzeiten-Daten geliefert."); throw new WebUntisException("WebUntis hat keine gültigen Fehlzeiten-Daten geliefert.");
var absences = periods.EnumerateArray() return (IReadOnlyList<UntisStudentAbsence>)periods.EnumerateArray()
.Where(entry => OptionalInt(entry, "studentId") == studentKey)
.Select(entry => new UntisStudentAbsence( .Select(entry => new UntisStudentAbsence(
RequiredInt(entry, "studentId"), RequiredInt(entry, "studentId"),
RequiredInt(entry, "date"), RequiredInt(entry, "date"),
@@ -205,9 +205,6 @@ public sealed class WebUntisClient : IAsyncDisposable
.OrderBy(entry => entry.Date) .OrderBy(entry => entry.Date)
.ThenBy(entry => entry.StartTime) .ThenBy(entry => entry.StartTime)
.ToList(); .ToList();
return new UntisStudentAbsenceReport(studentKey, startDate, endDate, absences.Count,
absences.Sum(entry => entry.AbsentMinutes), absences);
}, cancellationToken); }, cancellationToken);
public Task<IReadOnlyList<UntisClassRegisterEntry>> GetClassRegisterEntriesAsync(int studentId, public Task<IReadOnlyList<UntisClassRegisterEntry>> GetClassRegisterEntriesAsync(int studentId,
@@ -346,14 +343,15 @@ public sealed class WebUntisClient : IAsyncDisposable
private async Task TryLogoutAsync(string sessionId) 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. */ } catch { /* Ein fehlgeschlagener Logout darf Abrufe und Shutdown nicht fehlschlagen lassen. */ }
} }
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
string? sessionId; string? sessionId;
await _sessionGate.WaitAsync(); await _sessionGate.WaitAsync().ConfigureAwait(false);
try try
{ {
if (_disposed) return; 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 // 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. // auf dieses Gate warten und würde sonst den Shutdown blockieren.
await _sessionExpiryTimer.DisposeAsync(); await _sessionExpiryTimer.DisposeAsync().ConfigureAwait(false);
if (sessionId is not null) await TryLogoutAsync(sessionId); if (sessionId is not null) await TryLogoutAsync(sessionId).ConfigureAwait(false);
} }
private TimeSpan SessionIdleTimeout => private TimeSpan SessionIdleTimeout =>
-7
View File
@@ -146,13 +146,6 @@ public sealed record UntisStudentAbsence(
string? StudentGroup, string? StudentGroup,
JsonElement Raw); JsonElement Raw);
public sealed record UntisStudentAbsenceReport(
int StudentKey,
int StartDate,
int EndDate,
int EntryCount,
int AbsentMinutes,
IReadOnlyList<UntisStudentAbsence> Absences);
public sealed record UntisClassRegisterEntry( public sealed record UntisClassRegisterEntry(
int? StudentKey, int? StudentKey,
@@ -125,7 +125,7 @@ public static class WebUntisStudentReportParser
private static int? GermanDate(string? value) 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)) DateTimeStyles.None, out var date))
return null; return null;
return date.Year * 10_000 + date.Month * 100 + date.Day; return date.Year * 10_000 + date.Month * 100 + date.Day;