2 Commits
Author SHA1 Message Date
admin 17475a781f Untis-API Optimierung. 2026-08-24 22:33:30 +02:00
admin 1db16f8b69 Untis-API: Download Bug behoben 2026-08-24 22:06:42 +02:00
9 changed files with 150 additions and 54 deletions
+16 -4
View File
@@ -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<LiteDbContext>()?.Checkpoint();
serviceProvider.GetService<LiteDbContext>()?.Checkpoint();
}
catch (Exception ex)
{
@@ -112,8 +113,19 @@ public class App : Application
}
finally
{
_serviceProvider.Dispose();
_serviceProvider = null;
// 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
{
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);
}
}
}
@@ -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<int> TeacherIds,
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
/// 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;
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<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());
}, 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 =>
{
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(
var absences = await client.GetAbsencesAsync(Date(start), Date(end), token);
return (IReadOnlyList<UntisStudentAbsenceDto>)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());
x.ExcuseStatus, x.SubjectId, x.TeacherIds, x.StudentGroup)).ToList();
}, 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) =>
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 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);
@@ -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', ' ');
@@ -1,4 +1,5 @@
using System.Net;
using System.Diagnostics;
using System.Text;
using LehrerApp.WebUntis;
using Xunit;
@@ -16,7 +17,7 @@ public sealed class WebUntisClientTests
"2\t20\t8b\tMeier\tBerta\r\n");
var handler = new QueueHandler(
Json("{\"jsonrpc\":\"2.0\",\"result\":{\"sessionId\":\"session-1\"}}"),
Json("{\"data\":{\"finished\":true,\"reportParams\":\"foo=bar\"}}"),
Json("{\"data\":{\"finished\":true,\"error\":false,\"reportParams\":\"foo=bar\"}}"),
new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(latin1),
@@ -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<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);
}
@@ -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()
{
+41 -13
View File
@@ -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<UntisStudentAbsenceReport> GetStudentAbsencesAsync(int studentKey, int startDate, int endDate,
public Task<IReadOnlyList<UntisStudentAbsence>> 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<UntisStudentAbsence>)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<IReadOnlyList<UntisClassRegisterEntry>> 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 =>
@@ -417,7 +415,7 @@ public sealed class WebUntisClient : IAsyncDisposable
}
if (!TryProperty(payload, "data", out var data) || data.ValueKind != JsonValueKind.Object ||
(TryProperty(data, "error", out var reportError) && reportError.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined))
(TryProperty(data, "error", out var reportError) && HasErrorValue(reportError)))
throw new WebUntisException($"Report-Anfrage fehlgeschlagen: {ErrorMessage(payload, response)}");
if (!OptionalBoolean(data, "finished")) return null;
return ReportData.From(data);
@@ -646,6 +644,19 @@ public sealed class WebUntisClient : IAsyncDisposable
(string.Equals(value.GetString(), "true", StringComparison.OrdinalIgnoreCase) || value.GetString() == "1");
}
private static bool HasErrorValue(JsonElement value) => value.ValueKind switch
{
JsonValueKind.Null or JsonValueKind.Undefined or JsonValueKind.False => false,
JsonValueKind.True => true,
JsonValueKind.String => !string.IsNullOrWhiteSpace(value.GetString()) &&
!string.Equals(value.GetString(), "false", StringComparison.OrdinalIgnoreCase) &&
value.GetString() != "0",
JsonValueKind.Number => !value.TryGetInt32(out var number) || number != 0,
JsonValueKind.Array => value.GetArrayLength() > 0,
JsonValueKind.Object => value.EnumerateObject().Any(),
_ => true,
};
private static string ErrorMessage(JsonElement payload, HttpResponseMessage response)
{
if (payload.ValueKind == JsonValueKind.String) return payload.GetString() ?? "Unerwartete Antwort.";
@@ -654,8 +665,25 @@ public sealed class WebUntisClient : IAsyncDisposable
if (TryProperty(payload, "error", out var error) && OptionalString(error, "message") is { } errorMessage)
return errorMessage;
if (OptionalString(payload, "message") is { } message) return message;
if (TryProperty(payload, "data", out var data) && OptionalString(data, "message") is { } dataMessage)
return dataMessage;
if (TryProperty(payload, "data", out var data))
{
if (OptionalString(data, "message") is { } dataMessage) return dataMessage;
if (data.ValueKind == JsonValueKind.Object)
{
var dataFields = data.EnumerateObject().Select(property => property.Name).Take(8).ToArray();
if (dataFields.Length > 0)
return $"HTTP {(int)response.StatusCode}; unerwartete data-Felder: " +
$"{string.Join(", ", dataFields)}.";
}
else
{
return $"HTTP {(int)response.StatusCode}; data hat den Typ {data.ValueKind}.";
}
}
var fields = payload.EnumerateObject().Select(property => property.Name).Take(8).ToArray();
return fields.Length == 0
? $"HTTP {(int)response.StatusCode}; leeres JSON-Objekt."
: $"HTTP {(int)response.StatusCode}; unerwartete JSON-Felder: {string.Join(", ", fields)}.";
}
return response.IsSuccessStatusCode ? "Unerwartete JSON-Antwort." : $"HTTP {(int)response.StatusCode}";
}
-7
View File
@@ -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<UntisStudentAbsence> Absences);
public sealed record UntisClassRegisterEntry(
int? StudentKey,
@@ -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;