From 5841d96c5bd23d003a8d4b8cdc90d2d432afec5f Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Mon, 24 Aug 2026 12:14:36 +0200 Subject: [PATCH] Vorarbeit: WebUntis-API --- .env.example | 10 + LehrerApp.Api.Tests/WebUntisClientTests.cs | 168 +++++ .../WebUntisStudentReportParserTests.cs | 48 ++ LehrerApp.Api/Endpoints/Endpoints.cs | 127 ++++ LehrerApp.Api/Program.cs | 23 + LehrerApp.Api/WebUntisClient.cs | 630 ++++++++++++++++++ LehrerApp.Api/WebUntisModels.cs | 184 +++++ LehrerApp.Api/WebUntisStudentReportParser.cs | 133 ++++ docker/README.md | 48 ++ docker/docker-compose.yml | 6 + 10 files changed, 1377 insertions(+) create mode 100644 LehrerApp.Api.Tests/WebUntisClientTests.cs create mode 100644 LehrerApp.Api.Tests/WebUntisStudentReportParserTests.cs create mode 100644 LehrerApp.Api/WebUntisClient.cs create mode 100644 LehrerApp.Api/WebUntisModels.cs create mode 100644 LehrerApp.Api/WebUntisStudentReportParser.cs diff --git a/.env.example b/.env.example index b2458d2..4393497 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,13 @@ JWT_SECRET=hier-einen-langen-zufaelligen-wert-eintragen # Identifiziert die Installation gegenüber Nominatim/DWD. Bei eigener Domain bitte anpassen. GEOCODING_USER_AGENT=LehrerApp-Server/1.0 (+https://example.org) + +# Optional: granularer WebUntis-Zugriff über die Server-API. Der technische Benutzer muss die +# benötigten Leserechte besitzen. Benutzer mit aktivierter 2FA funktionieren nicht mit der alten +# JSON-RPC-Schnittstelle. WEBUNTIS_HOST ist nur nötig, wenn der Host nicht .webuntis.com ist. +WEBUNTIS_SCHOOL=meine-schule +WEBUNTIS_HOST=meine-schule.webuntis.com +WEBUNTIS_USER=technischer-benutzer +WEBUNTIS_PASSWORD=geheimes-passwort +WEBUNTIS_CLIENT=LehrerApp +WEBUNTIS_SESSION_IDLE_MINUTES=10 diff --git a/LehrerApp.Api.Tests/WebUntisClientTests.cs b/LehrerApp.Api.Tests/WebUntisClientTests.cs new file mode 100644 index 0000000..61583a7 --- /dev/null +++ b/LehrerApp.Api.Tests/WebUntisClientTests.cs @@ -0,0 +1,168 @@ +using System.Net; +using System.Text; +using Microsoft.Extensions.Options; +using Xunit; + +namespace LehrerApp.Api.Tests; + +public sealed class WebUntisClientTests +{ + [Fact] + public async Task AufeinanderfolgendeAbrufe_VerwendenDieselbeSessionBisZumDispose() + { + var latin1 = Encoding.Latin1.GetBytes( + "id\texternKey\tklasse.name\tlongName\tforeName\r\n" + + "1\t10\t7a\tMüller\tAda\r\n" + + "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\"}}"), + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(latin1), + }, + Json("{\"jsonrpc\":\"2.0\",\"result\":[{\"id\":7,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"), + Json("{\"jsonrpc\":\"2.0\",\"result\":{}}")); + var client = CreateClient(handler); + + var result = await client.GetStudentReportAsync("7a", CancellationToken.None); + var schoolYears = await client.GetSchoolYearsAsync(CancellationToken.None); + + var student = Assert.Single(result.Students); + Assert.Equal("Ada Müller", student.DisplayName); + Assert.Equal("7a", result.ClassNameFilter); + Assert.Equal("2026/27", Assert.Single(schoolYears).Name); + Assert.Equal(4, handler.Requests.Count); + Assert.Contains("jsonrpc.do?school=meine-schule", handler.Requests[0].Uri); + Assert.Contains("\"method\":\"authenticate\"", handler.Requests[0].Body); + Assert.Contains("reports.do?name=Student", handler.Requests[1].Uri); + Assert.Contains("schoolname=\"_", handler.Requests[1].Cookie); + Assert.EndsWith("reports.do?foo=bar", handler.Requests[2].Uri); + Assert.Contains("\"method\":\"getSchoolyears\"", handler.Requests[3].Body); + Assert.Single(handler.Requests, + request => request.Body.Contains("\"method\":\"authenticate\"")); + + await client.DisposeAsync(); + + Assert.Equal(5, handler.Requests.Count); + Assert.Contains("\"method\":\"logout\"", handler.Requests[4].Body); + } + + [Fact] + public async Task GetTimetableAsync_SendetElementUndZeitraumAlsSeparateAbfrage() + { + var handler = new QueueHandler( + Json("{\"result\":{\"sessionId\":\"s\"}}"), + Json("{\"result\":[{\"id\":99,\"date\":20260824,\"startTime\":800,\"endTime\":845," + + "\"kl\":[{\"id\":4,\"name\":\"7a\"}],\"te\":[],\"su\":[],\"ro\":[]}]}"), + Json("{\"result\":{}}")); + var client = CreateClient(handler); + + var periods = await client.GetTimetableAsync(UntisTimetableElementType.Teacher, 42, + 20260824, 20260828, CancellationToken.None); + + var period = Assert.Single(periods); + Assert.Equal(99, period.Id); + Assert.Equal("7a", Assert.Single(period.Classes).Name); + Assert.Contains("\"method\":\"getTimetable\"", handler.Requests[1].Body); + Assert.Contains("\"element\":{\"id\":42,\"type\":2}", handler.Requests[1].Body); + Assert.Contains("\"startDate\":20260824", handler.Requests[1].Body); + + await client.DisposeAsync(); + } + + [Fact] + public async Task FehlendeKonfiguration_BrichtVorHttpRequestAb() + { + var handler = new QueueHandler(); + var client = new WebUntisClient(new HttpClient(handler), Options.Create(new WebUntisOptions())); + + await Assert.ThrowsAsync( + () => client.GetSchoolYearsAsync(CancellationToken.None)); + + Assert.Empty(handler.Requests); + await client.DisposeAsync(); + } + + [Fact] + public async Task FehlzeitenUndKlassenbuch_WerdenNachSchuelerAbgerufenUndTypisiert() + { + var handler = new QueueHandler( + Json("{\"result\":{\"sessionId\":\"s\"}}"), + Json("{\"result\":{\"periodsWithAbsences\":[" + + "{\"studentId\":9001,\"date\":20260901,\"startTime\":800,\"endTime\":845," + + "\"absentTime\":45,\"checked\":true,\"absenceReason\":\"Krank\"," + + "\"excuseStatus\":\"entschuldigt\",\"subjectId\":12,\"teacherIds\":[\"7\"]}," + + "{\"studentId\":9999,\"date\":20260901,\"startTime\":800,\"endTime\":845," + + "\"absentTime\":45,\"checked\":false}]}}"), + Json("{\"result\":[{\"studentid\":9001,\"surname\":\"Müller\",\"forname\":\"Ada\"," + + "\"date\":20260902,\"subject\":\"MA\",\"categoryId\":3,\"reason\":\"Material\"," + + "\"text\":\"Buch vergessen\"}]}"), + Json("{\"result\":[{\"id\":3,\"name\":\"Material\",\"longName\":\"Material vergessen\"," + + "\"groupId\":2}]}"), + Json("{\"result\":[{\"id\":2,\"name\":\"Organisation\"}]}"), + Json("{\"result\":{}}")); + var client = CreateClient(handler); + + var report = await client.GetStudentAbsencesAsync(9001, 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("entschuldigt", absence.ExcuseStatus); + Assert.Equal(7, Assert.Single(absence.TeacherIds)); + var entry = Assert.Single(entries); + Assert.Equal("Ada Müller", entry.DisplayName); + Assert.Equal("Buch vergessen", entry.Text); + Assert.Equal(3, Assert.Single(categories).Id); + Assert.Equal("Organisation", Assert.Single(groups).Name); + Assert.Contains("\"method\":\"getTimetableWithAbsences\"", handler.Requests[1].Body); + Assert.Contains("\"method\":\"getClassregEvents\"", handler.Requests[2].Body); + Assert.Contains("\"id\":17,\"type\":5", handler.Requests[2].Body); + Assert.Single(handler.Requests, + request => request.Body.Contains("\"method\":\"authenticate\"")); + + await client.DisposeAsync(); + Assert.Contains("\"method\":\"logout\"", handler.Requests[^1].Body); + } + + private static WebUntisClient CreateClient(HttpMessageHandler handler) => new( + new HttpClient(handler), + Options.Create(new WebUntisOptions + { + School = "meine-schule", + Host = "meine-schule.webuntis.com", + Username = "api-user", + Password = "secret", + Client = "tests", + })); + + private static HttpResponseMessage Json(string json) => new(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }; + + private sealed class QueueHandler(params HttpResponseMessage[] responses) : HttpMessageHandler + { + private readonly Queue _responses = new(responses); + public List Requests { get; } = []; + + protected override async Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) + { + Requests.Add(new CapturedRequest( + request.RequestUri?.ToString() ?? "", + request.Content is null ? "" : await request.Content.ReadAsStringAsync(cancellationToken), + string.Join("; ", request.Headers.TryGetValues("Cookie", out var cookies) ? cookies : []))); + return _responses.Count > 0 + ? _responses.Dequeue() + : throw new InvalidOperationException("Keine Testantwort mehr vorhanden."); + } + } + + private sealed record CapturedRequest(string Uri, string Body, string Cookie); +} diff --git a/LehrerApp.Api.Tests/WebUntisStudentReportParserTests.cs b/LehrerApp.Api.Tests/WebUntisStudentReportParserTests.cs new file mode 100644 index 0000000..7ca06cf --- /dev/null +++ b/LehrerApp.Api.Tests/WebUntisStudentReportParserTests.cs @@ -0,0 +1,48 @@ +using Xunit; + +namespace LehrerApp.Api.Tests; + +public sealed class WebUntisStudentReportParserTests +{ + [Fact] + public void Parse_UebernimmtAlleFelderUndDeutscheDatumswerte() + { + const string report = "\uFEFFid\texternKey\tklasse.name\tname\tlongName\tforeName\tgender\tbirthDate\tentryDate\texitDate\ttext\tmedicalReportDuty\tschulpflicht\tmajority\tadress.email\tadress.mobile\tadress.phone\tadress.city\tadress.postCode\tadress.street\tattribute.iL\r\n" + + "17\t9001\t10a\tMUST\tMustermann\tErika\tw\t03.02.2010\t01.08.2021\t\t\"Zeile 1\nZeile 2\"\tja\tja\tnein\terika@example.org\t0151\t030\tBerlin\t10115\tTestweg 1\tIL-A\r\n"; + + var student = Assert.Single(WebUntisStudentReportParser.Parse(report)); + + Assert.Equal(17, student.UntisId); + Assert.Equal(9001, student.ExternKey); + Assert.Equal("10a", student.ClassName); + Assert.Equal("Erika Mustermann", student.DisplayName); + Assert.Equal(20100203, student.BirthDate); + Assert.Equal(20210801, student.EntryDate); + Assert.Null(student.ExitDate); + Assert.Equal("Zeile 1\nZeile 2", student.Text); + Assert.Equal("erika@example.org", student.Address.Email); + Assert.Equal("IL-A", student.AttributeIL); + } + + [Fact] + public void Parse_BehandeltEscapteAnfuehrungszeichenUndLeereZeilen() + { + const string report = "id\texternKey\tklasse.name\tname\r\n" + + "1\t2\t5b\t\"MUS\"\"T\"\r\n\t\t\t\r\n"; + + var student = Assert.Single(WebUntisStudentReportParser.Parse(report)); + + Assert.Equal("MUS\"T", student.Name); + Assert.Equal("MUS\"T", student.DisplayName); + } + + [Fact] + public void Parse_LehntUngueltigePflichtIdAb() + { + const string report = "id\texternKey\tklasse.name\r\nkeine-zahl\t1\t5b\r\n"; + + var error = Assert.Throws(() => WebUntisStudentReportParser.Parse(report)); + + Assert.Contains("id", error.Message); + } +} diff --git a/LehrerApp.Api/Endpoints/Endpoints.cs b/LehrerApp.Api/Endpoints/Endpoints.cs index e7c696d..03caafa 100644 --- a/LehrerApp.Api/Endpoints/Endpoints.cs +++ b/LehrerApp.Api/Endpoints/Endpoints.cs @@ -1,6 +1,7 @@ using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; +using System.Globalization; using LehrerApp.Core.Models; using LehrerApp.Sync.Models; using Microsoft.AspNetCore.Mvc; @@ -243,6 +244,132 @@ public static class Endpoints }); } + // ── WebUntis (granulare, zustandslose Abrufe) ───────────────────────────── + + public static void MapWebUntisEndpoints(this WebApplication app) + { + var group = app.MapGroup("/api/webuntis").RequireAuthorization(); + + group.MapGet("/schoolyears", (WebUntisClient client, CancellationToken cancellationToken) => + WebUntisResult(() => client.GetSchoolYearsAsync(cancellationToken))); + + group.MapGet("/classes", ([FromQuery] int schoolyearId, WebUntisClient client, + CancellationToken cancellationToken) => schoolyearId <= 0 + ? Task.FromResult(Results.BadRequest("schoolyearId muss größer als 0 sein.")) + : WebUntisResult(() => client.GetClassesAsync(schoolyearId, cancellationToken))); + + group.MapGet("/teachers", (WebUntisClient client, CancellationToken cancellationToken) => + WebUntisResult(() => client.GetTeachersAsync(cancellationToken))); + + group.MapGet("/student-report", ([FromQuery] string? className, WebUntisClient client, + CancellationToken cancellationToken) => + WebUntisResult(() => client.GetStudentReportAsync(className, cancellationToken))); + + group.MapGet("/holidays", (WebUntisClient client, CancellationToken cancellationToken) => + WebUntisResult(() => client.GetHolidaysAsync(cancellationToken))); + + group.MapGet("/timegrid", (WebUntisClient client, CancellationToken cancellationToken) => + WebUntisResult(() => client.GetTimeGridAsync(cancellationToken))); + + group.MapGet("/substitutions", ([FromQuery] int startDate, [FromQuery] int endDate, + [FromQuery] int? departmentId, WebUntisClient client, CancellationToken cancellationToken) => + { + if (!ValidDateRange(startDate, endDate, 31, out var error)) + return Task.FromResult(Results.BadRequest(error)); + return WebUntisResult(() => + client.GetSubstitutionsAsync(startDate, endDate, departmentId, cancellationToken)); + }); + + group.MapGet("/timetable", ([FromQuery] string elementType, [FromQuery] int elementId, + [FromQuery] int startDate, [FromQuery] int endDate, WebUntisClient client, + CancellationToken cancellationToken) => + { + if (!Enum.TryParse(elementType, true, out var parsedType) || + !Enum.IsDefined(parsedType)) + return Task.FromResult(Results.BadRequest( + "elementType muss class, teacher, subject, room oder student sein.")); + if (elementId <= 0) + return Task.FromResult(Results.BadRequest("elementId muss größer als 0 sein.")); + if (!ValidDateRange(startDate, endDate, 62, out var error)) + return Task.FromResult(Results.BadRequest(error)); + return WebUntisResult(() => + client.GetTimetableAsync(parsedType, elementId, startDate, endDate, cancellationToken)); + }); + + group.MapGet("/students/{studentKey:int}/absences", (int studentKey, [FromQuery] int startDate, + [FromQuery] int endDate, WebUntisClient client, CancellationToken cancellationToken) => + { + if (studentKey <= 0) + return Task.FromResult(Results.BadRequest("studentKey muss größer als 0 sein.")); + if (!ValidDateRange(startDate, endDate, 400, out var error)) + return Task.FromResult(Results.BadRequest(error)); + return WebUntisResult(() => + client.GetStudentAbsencesAsync(studentKey, startDate, endDate, cancellationToken)); + }); + + group.MapGet("/students/{studentId:int}/class-register-entries", (int studentId, + [FromQuery] int startDate, [FromQuery] int endDate, WebUntisClient client, + CancellationToken cancellationToken) => + { + if (studentId <= 0) + return Task.FromResult(Results.BadRequest("studentId muss größer als 0 sein.")); + if (!ValidDateRange(startDate, endDate, 400, out var error)) + return Task.FromResult(Results.BadRequest(error)); + return WebUntisResult(() => + client.GetClassRegisterEntriesAsync(studentId, startDate, endDate, cancellationToken)); + }); + + group.MapGet("/class-register/categories", (WebUntisClient client, + CancellationToken cancellationToken) => + WebUntisResult(() => client.GetClassRegisterCategoriesAsync(cancellationToken))); + + group.MapGet("/class-register/category-groups", (WebUntisClient client, + CancellationToken cancellationToken) => + WebUntisResult(() => client.GetClassRegisterCategoryGroupsAsync(cancellationToken))); + } + + private static async Task WebUntisResult(Func> operation) + { + try { return Results.Ok(await operation()); } + catch (WebUntisConfigurationException exception) + { + return Results.Problem(exception.Message, statusCode: StatusCodes.Status503ServiceUnavailable); + } + catch (WebUntisException exception) + { + return Results.Problem(exception.Message, statusCode: StatusCodes.Status502BadGateway); + } + catch (InvalidDataException exception) + { + return Results.Problem($"Der WebUntis-Report ist ungültig: {exception.Message}", + statusCode: StatusCodes.Status502BadGateway); + } + } + + private static bool ValidDateRange(int startDate, int endDate, int maximumDays, out string error) + { + error = ""; + if (!DateOnly.TryParseExact(startDate.ToString(CultureInfo.InvariantCulture), "yyyyMMdd", + CultureInfo.InvariantCulture, DateTimeStyles.None, out var start) || + !DateOnly.TryParseExact(endDate.ToString(CultureInfo.InvariantCulture), "yyyyMMdd", + CultureInfo.InvariantCulture, DateTimeStyles.None, out var end)) + { + error = "startDate und endDate müssen gültige Datumswerte im Format yyyyMMdd sein."; + return false; + } + if (end < start) + { + error = "endDate darf nicht vor startDate liegen."; + return false; + } + if (end.DayNumber - start.DayNumber + 1 > maximumDays) + { + error = $"Der Zeitraum darf höchstens {maximumDays} Tage umfassen."; + return false; + } + return true; + } + private static Dictionary ValidateLocation(SchoolLocationRequest request) { var errors = new Dictionary(); diff --git a/LehrerApp.Api/Program.cs b/LehrerApp.Api/Program.cs index fb4c224..f4eb9d7 100644 --- a/LehrerApp.Api/Program.cs +++ b/LehrerApp.Api/Program.cs @@ -97,6 +97,28 @@ builder.Services.AddHttpClient("dwd", client => }); builder.Services.AddSingleton(sp => new DwdWeatherService( sp.GetRequiredService().CreateClient("dwd"))); +builder.Services.Configure(options => +{ + builder.Configuration.GetSection("WebUntis").Bind(options); + options.School = builder.Configuration["WEBUNTIS_SCHOOL"] ?? options.School; + options.Host = builder.Configuration["WEBUNTIS_HOST"] ?? options.Host; + options.Username = builder.Configuration["WEBUNTIS_USER"] ?? options.Username; + options.Password = builder.Configuration["WEBUNTIS_PASSWORD"] ?? options.Password; + options.Client = builder.Configuration["WEBUNTIS_CLIENT"] ?? options.Client; + if (int.TryParse(builder.Configuration["WEBUNTIS_SESSION_IDLE_MINUTES"], out var idleMinutes)) + options.SessionIdleTimeoutMinutes = idleMinutes; +}); +builder.Services.AddHttpClient("webuntis", client => +{ + // Die einzelnen WebUntis-Schritte besitzen eigene Timeouts; insbesondere ein asynchron + // erzeugter Schülerreport darf länger als der HttpClient-Standardtimeout pollen. + client.Timeout = Timeout.InfiniteTimeSpan; +}); +// Der Client hält eine WebUntis-Session über mehrere API-Aufrufe hinweg. Deshalb muss seine +// Lebensdauer der Serveranwendung entsprechen und darf nicht pro HTTP-Anfrage neu beginnen. +builder.Services.AddSingleton(sp => new WebUntisClient( + sp.GetRequiredService().CreateClient("webuntis"), + sp.GetRequiredService>())); var app = builder.Build(); app.UseForwardedHeaders(); @@ -112,5 +134,6 @@ app.MapSnapshotEndpoints(); app.MapReadableSnapshotEndpoints(); app.MapPlainSyncEndpoints(); app.MapSchoolWeatherEndpoints(); +app.MapWebUntisEndpoints(); app.Run(); return 0; diff --git a/LehrerApp.Api/WebUntisClient.cs b/LehrerApp.Api/WebUntisClient.cs new file mode 100644 index 0000000..ceeff96 --- /dev/null +++ b/LehrerApp.Api/WebUntisClient.cs @@ -0,0 +1,630 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Options; + +namespace LehrerApp.Api; + +public sealed class WebUntisClient : IAsyncDisposable +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private static readonly UTF8Encoding StrictUtf8 = new(false, true); + private readonly HttpClient _http; + private readonly WebUntisOptions _options; + private readonly SemaphoreSlim _sessionGate = new(1, 1); + private readonly Timer _sessionExpiryTimer; + private string? _sessionId; + private DateTimeOffset _sessionExpiresAt; + private int _activeRequests; + private bool _disposed; + + public WebUntisClient(HttpClient http, IOptions options) + { + _http = http; + _options = options.Value; + _sessionExpiryTimer = new Timer( + static state => _ = ((WebUntisClient)state!).CloseExpiredSessionAsync(), + this, + Timeout.InfiniteTimeSpan, + Timeout.InfiniteTimeSpan); + } + + public Task> GetSchoolYearsAsync(CancellationToken cancellationToken) => + WithSessionAsync(async sessionId => + { + var entries = RequireArray(await RpcAsync("getSchoolyears", new { }, sessionId, cancellationToken), + "WebUntis hat keine gültige Schuljahr-Liste geliefert."); + return (IReadOnlyList)entries.Select(entry => + { + var id = RequiredInt(entry, "id"); + var name = OptionalString(entry, "name") + ?? throw new WebUntisException($"WebUntis-Schuljahr {id} hat keinen Namen."); + return new UntisSchoolYear(id, name, RequiredInt(entry, "startDate"), RequiredInt(entry, "endDate")); + }).ToList(); + }, cancellationToken); + + public Task> GetClassesAsync(int schoolYearId, CancellationToken cancellationToken) => + WithSessionAsync(async sessionId => + { + var entries = RequireArray(await RpcAsync("getKlassen", new { schoolyearId = schoolYearId }, sessionId, + cancellationToken), "WebUntis hat keine gültige Klassen-Liste geliefert."); + return (IReadOnlyList)entries.Select(entry => + { + var id = RequiredInt(entry, "id"); + var name = OptionalString(entry, "name") + ?? throw new WebUntisException($"WebUntis-Klasse {id} hat keinen Namen."); + return new UntisClass(id, name, OptionalString(entry, "longName"), + OptionalString(entry, "foreColor"), OptionalString(entry, "backColor"), + OptionalInt(entry, "did"), OptionalInt(entry, "teacher1"), OptionalInt(entry, "teacher2")); + }).ToList(); + }, cancellationToken); + + public Task> GetTeachersAsync(CancellationToken cancellationToken) => + WithSessionAsync(async sessionId => + { + var entries = RequireArray(await RpcAsync("getTeachers", new { }, sessionId, cancellationToken), + "WebUntis hat keine gültige Lehrer-Liste geliefert."); + return (IReadOnlyList)entries.Select(entry => + { + var id = RequiredInt(entry, "id"); + var name = OptionalString(entry, "name") + ?? throw new WebUntisException($"WebUntis-Lehrer {id} hat kein Kürzel."); + var departments = TryProperty(entry, "dids", out var dids) && dids.ValueKind == JsonValueKind.Array + ? dids.EnumerateArray().Select(OptionalInt).Where(value => value is not null) + .Select(value => value!.Value).Distinct().Order().ToList() + : []; + return new UntisTeacher(id, name, OptionalString(entry, "foreName"), + OptionalString(entry, "longName"), OptionalString(entry, "title"), + OptionalBoolean(entry, "active"), departments); + }).ToList(); + }, cancellationToken); + + public Task> GetHolidaysAsync(CancellationToken cancellationToken) => + WithSessionAsync(async sessionId => + { + var entries = RequireArray(await RpcAsync("getHolidays", new { }, sessionId, cancellationToken), + "WebUntis hat keine gültige Ferien-Liste geliefert."); + return (IReadOnlyList)entries.Select(entry => + { + var id = RequiredInt(entry, "id"); + var name = OptionalString(entry, "name") + ?? throw new WebUntisException($"WebUntis-Ferien {id} haben keinen Namen."); + return new UntisHoliday(id, name, OptionalString(entry, "longName"), + RequiredInt(entry, "startDate"), RequiredInt(entry, "endDate")); + }).ToList(); + }, cancellationToken); + + public Task> GetTimeGridAsync(CancellationToken cancellationToken) => + WithSessionAsync(async sessionId => + { + var entries = RequireArray(await RpcAsync("getTimegridUnits", new { }, sessionId, cancellationToken), + "WebUntis hat keine gültigen Zeitraster-Daten geliefert."); + return (IReadOnlyList)entries.Select(entry => + { + var units = TryProperty(entry, "timeUnits", out var values) && values.ValueKind == JsonValueKind.Array + ? values.EnumerateArray().Select(unit => new UntisTimeUnit( + OptionalString(unit, "name") ?? "", RequiredInt(unit, "startTime"), RequiredInt(unit, "endTime"))) + .ToList() + : []; + return new UntisTimeGridDay(RequiredInt(entry, "day"), units, entry.Clone()); + }).ToList(); + }, cancellationToken); + + public Task GetStudentReportAsync(string? classNameFilter, + CancellationToken cancellationToken) => WithSessionAsync(async sessionId => + { + var reportData = await RequestReportAsync(sessionId, cancellationToken) + ?? await PollReportAsync(sessionId, cancellationToken); + var reportText = await FetchReportTextAsync(sessionId, reportData, cancellationToken); + var allStudents = WebUntisStudentReportParser.Parse(reportText); + var normalizedFilter = string.IsNullOrWhiteSpace(classNameFilter) ? null : classNameFilter.Trim(); + var students = normalizedFilter is null + ? allStudents + : allStudents.Where(student => string.Equals(student.ClassName, normalizedFilter, + StringComparison.OrdinalIgnoreCase)).ToList(); + return new UntisStudentReport(students.Count, normalizedFilter, students); + }, cancellationToken); + + public Task> GetSubstitutionsAsync(int startDate, int endDate, + int? departmentId, CancellationToken cancellationToken) => WithSessionAsync(async sessionId => + { + var entries = RequireArray(await RpcAsync("getSubstitutions", new + { + startDate, + endDate, + departmentId = departmentId ?? 0, + }, sessionId, cancellationToken), "WebUntis hat keine gültigen Vertretungsplan-Daten geliefert."); + + return (IReadOnlyList)entries.Select(entry => new UntisSubstitution( + OptionalString(entry, "type") ?? "unknown", + OptionalInt(entry, "lsid"), + OptionalString(entry, "lstype"), + RequiredInt(entry, "date"), + RequiredInt(entry, "startTime"), + RequiredInt(entry, "endTime"), + OptionalString(entry, "txt"), + Entities(entry, "kl"), + Entities(entry, "te"), + Entities(entry, "su"), + Entities(entry, "ro"), + ParseReschedule(entry), + entry.Clone())).ToList(); + }, cancellationToken); + + public Task> GetTimetableAsync(UntisTimetableElementType elementType, + int elementId, int startDate, int endDate, CancellationToken cancellationToken) => + WithSessionAsync(async sessionId => + { + var options = new + { + element = new { id = elementId, type = (int)elementType }, + startDate, + endDate, + showBooking = false, + showInfo = true, + showSubstText = true, + showLsText = true, + showLsNumber = true, + showStudentgroup = true, + }; + var entries = RequireArray(await RpcAsync("getTimetable", new { options }, sessionId, cancellationToken), + "WebUntis hat keine gültigen Stundenplan-Daten geliefert."); + return (IReadOnlyList)entries.Select(entry => new UntisTimetablePeriod( + RequiredInt(entry, "id"), RequiredInt(entry, "date"), RequiredInt(entry, "startTime"), + RequiredInt(entry, "endTime"), OptionalString(entry, "code"), OptionalString(entry, "activityType"), + OptionalString(entry, "info"), OptionalString(entry, "lstext"), OptionalString(entry, "substText"), + OptionalString(entry, "sg"), Entities(entry, "kl"), Entities(entry, "te"), Entities(entry, "su"), + Entities(entry, "ro"), entry.Clone())).ToList(); + }, cancellationToken); + + public Task GetStudentAbsencesAsync(int studentKey, int startDate, int endDate, + CancellationToken cancellationToken) => WithSessionAsync(async sessionId => + { + var result = await RpcAsync("getTimetableWithAbsences", new + { + options = new { startDate, endDate }, + }, sessionId, cancellationToken); + 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) + .Select(entry => new UntisStudentAbsence( + RequiredInt(entry, "studentId"), + RequiredInt(entry, "date"), + RequiredInt(entry, "startTime"), + RequiredInt(entry, "endTime"), + OptionalInt(entry, "absentTime") ?? 0, + OptionalBoolean(entry, "checked"), + OptionalString(entry, "absenceReason"), + OptionalString(entry, "excuseStatus"), + OptionalInt(entry, "subjectId"), + IntArray(entry, "teacherIds"), + OptionalString(entry, "studentGroup"), + entry.Clone())) + .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, + int startDate, int endDate, CancellationToken cancellationToken) => WithSessionAsync(async sessionId => + { + var entries = RequireArray(await RpcAsync("getClassregEvents", new + { + startDate, + endDate, + id = studentId, + type = (int)UntisTimetableElementType.Student, + }, sessionId, cancellationToken), "WebUntis hat keine gültigen Klassenbuch-Daten geliefert."); + + return (IReadOnlyList)entries.Select(entry => + { + var surname = OptionalString(entry, "surname"); + var foreName = OptionalString(entry, "forname"); + var displayName = string.Join(' ', new[] { foreName, surname }.Where(value => value is not null)); + return new UntisClassRegisterEntry(OptionalInt(entry, "studentid"), surname, foreName, + displayName, RequiredInt(entry, "date"), OptionalString(entry, "subject"), + OptionalInt(entry, "categoryId"), OptionalString(entry, "reason"), + OptionalString(entry, "text"), entry.Clone()); + }).OrderBy(entry => entry.Date).ThenBy(entry => entry.DisplayName).ToList(); + }, cancellationToken); + + public Task> GetClassRegisterCategoriesAsync( + CancellationToken cancellationToken) => WithSessionAsync(async sessionId => + { + var entries = RequireArray(await RpcAsync("getClassregCategories", new { }, sessionId, cancellationToken), + "WebUntis hat keine gültigen Klassenbuch-Kategorien geliefert."); + return (IReadOnlyList)entries.Select(entry => + new UntisClassRegisterCategory(RequiredInt(entry, "id"), OptionalString(entry, "name") ?? "", + OptionalString(entry, "longName"), OptionalInt(entry, "groupId"), entry.Clone())).ToList(); + }, cancellationToken); + + public Task> GetClassRegisterCategoryGroupsAsync( + CancellationToken cancellationToken) => WithSessionAsync(async sessionId => + { + var entries = RequireArray(await RpcAsync("getClassregCategoryGroups", new { }, sessionId, + cancellationToken), "WebUntis hat keine gültigen Klassenbuch-Kategoriegruppen geliefert."); + return (IReadOnlyList)entries.Select(entry => + new UntisClassRegisterCategoryGroup(RequiredInt(entry, "id"), + OptionalString(entry, "name") ?? "", entry.Clone())).ToList(); + }, cancellationToken); + + private async Task WithSessionAsync(Func> action, CancellationToken cancellationToken) + { + await using var lease = await AcquireSessionAsync(cancellationToken); + return await action(lease.SessionId); + } + + private async Task AcquireSessionAsync(CancellationToken cancellationToken) + { + await _sessionGate.WaitAsync(cancellationToken); + try + { + ObjectDisposedException.ThrowIf(_disposed, this); + _sessionExpiryTimer.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + + // Falls der Timer durch Threadpool-Last verspätet ausgeführt wird, darf eine bereits + // abgelaufene Sitzung nicht noch einmal für einen neuen Abruf verwendet werden. + if (_sessionId is not null && _activeRequests == 0 && _sessionExpiresAt <= DateTimeOffset.UtcNow) + { + var expiredSessionId = _sessionId; + _sessionId = null; + await TryLogoutAsync(expiredSessionId); + } + + if (_sessionId is null) + { + var configuration = GetConfiguration(); + var result = await RpcAsync("authenticate", new + { + user = configuration.Username, + password = configuration.Password, + client = configuration.Client, + }, null, cancellationToken); + _sessionId = OptionalString(result, "sessionId") + ?? throw new WebUntisException("WebUntis-Login fehlgeschlagen: Keine sessionId erhalten."); + } + + _activeRequests++; + return new SessionLease(this, _sessionId); + } + finally + { + _sessionGate.Release(); + } + } + + private async ValueTask ReleaseSessionAsync() + { + await _sessionGate.WaitAsync(); + try + { + if (_activeRequests > 0) _activeRequests--; + if (_disposed || _activeRequests != 0 || _sessionId is null) return; + + var timeout = SessionIdleTimeout; + _sessionExpiresAt = DateTimeOffset.UtcNow.Add(timeout); + _sessionExpiryTimer.Change(timeout, Timeout.InfiniteTimeSpan); + } + finally + { + _sessionGate.Release(); + } + } + + private async Task CloseExpiredSessionAsync() + { + string? sessionId = null; + await _sessionGate.WaitAsync(); + try + { + if (_disposed || _activeRequests != 0 || _sessionId is null) return; + var remaining = _sessionExpiresAt - DateTimeOffset.UtcNow; + if (remaining > TimeSpan.Zero) + { + _sessionExpiryTimer.Change(remaining, Timeout.InfiniteTimeSpan); + return; + } + + sessionId = _sessionId; + _sessionId = null; + } + finally + { + _sessionGate.Release(); + } + + if (sessionId is not null) await TryLogoutAsync(sessionId); + } + + private async Task TryLogoutAsync(string sessionId) + { + try { await RpcAsync("logout", new { }, sessionId, CancellationToken.None); } + catch { /* Ein fehlgeschlagener Logout darf Abrufe und Shutdown nicht fehlschlagen lassen. */ } + } + + public async ValueTask DisposeAsync() + { + string? sessionId; + await _sessionGate.WaitAsync(); + try + { + if (_disposed) return; + _disposed = true; + sessionId = _sessionId; + _sessionId = null; + } + finally + { + _sessionGate.Release(); + } + + // 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); + } + + private TimeSpan SessionIdleTimeout => + TimeSpan.FromMinutes(Math.Clamp(_options.SessionIdleTimeoutMinutes, 1, 30)); + + private async Task RpcAsync(string method, object parameters, string? sessionId, + CancellationToken cancellationToken) + { + var configuration = GetConfiguration(); + var uri = $"https://{configuration.Host}/WebUntis/jsonrpc.do?school={Uri.EscapeDataString(configuration.School)}"; + using var request = new HttpRequestMessage(HttpMethod.Post, uri) + { + 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}"); + + using var response = await SendAsync(request, TimeSpan.FromSeconds(20), cancellationToken); + var payload = await ReadJsonAsync(response, cancellationToken); + if (!response.IsSuccessStatusCode) + throw new WebUntisException($"WebUntis RPC ({method}) fehlgeschlagen: {ErrorMessage(payload, response)}"); + if (payload.ValueKind != JsonValueKind.Object) + throw new WebUntisException($"Leere Antwort von WebUntis RPC ({method})."); + if (TryProperty(payload, "error", out var error) && error.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined) + throw new WebUntisException($"WebUntis RPC-Fehler ({method}): {ErrorMessage(error, response)}"); + if (!TryProperty(payload, "result", out var result)) + throw new WebUntisException($"WebUntis RPC ({method}) ohne Ergebnis."); + return result.Clone(); + } + + private async Task RequestReportAsync(string sessionId, CancellationToken cancellationToken) + { + var query = new Dictionary + { + ["name"] = "Student", ["format"] = "csv", ["klasseId"] = "-1", + ["studentsForDate"] = "true", ["context"] = "klasseId", + }; + var uri = QueryHelpers.AddQueryString($"https://{GetConfiguration().Host}/WebUntis/reports.do", query); + using var request = ReportRequest(uri, sessionId, acceptJson: true); + using var response = await SendAsync(request, TimeSpan.FromSeconds(20), cancellationToken); + var payload = await ReadJsonAsync(response, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + if (TryProperty(payload, "errors", out var errors) && errors.ValueKind == JsonValueKind.Array && + errors.EnumerateArray().Any(error => OptionalString(error, "code") == "4" || OptionalInt(error, "code") == 4)) + return null; + throw new WebUntisException($"Report-Anfrage fehlgeschlagen: {ErrorMessage(payload, response)}"); + } + + 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)) + throw new WebUntisException($"Report-Anfrage fehlgeschlagen: {ErrorMessage(payload, response)}"); + if (!OptionalBoolean(data, "finished")) return null; + return ReportData.From(data); + } + + private async Task PollReportAsync(string sessionId, CancellationToken cancellationToken) + { + var uri = $"https://{GetConfiguration().Host}/WebUntis/api/polling/REPORT"; + for (var attempt = 0; attempt < 60; attempt++) + { + using var request = ReportRequest(uri, sessionId, acceptJson: true); + using var response = await SendAsync(request, TimeSpan.FromSeconds(20), cancellationToken); + var payload = await ReadJsonAsync(response, cancellationToken); + if (!response.IsSuccessStatusCode) + throw new WebUntisException($"Report-Polling fehlgeschlagen: HTTP {(int)response.StatusCode}."); + + if (TryProperty(payload, "data", out var data) && TryProperty(data, "pollingJobs", out var jobs) && + jobs.ValueKind == JsonValueKind.Array) + { + foreach (var job in jobs.EnumerateArray().Where(job => OptionalBoolean(job, "isJobFinished"))) + { + if (OptionalBoolean(job, "hasJobError")) + throw new WebUntisException("Report-Polling enthält einen Job-Fehler."); + if (TryProperty(job, "data", out var jobData)) return ReportData.From(jobData); + } + } + await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken); + } + throw new WebUntisException("Report wurde nicht rechtzeitig fertiggestellt."); + } + + private async Task FetchReportTextAsync(string sessionId, ReportData reportData, + CancellationToken cancellationToken) + { + var uri = reportData.ReportParams is not null + ? $"https://{GetConfiguration().Host}/WebUntis/reports.do?{reportData.ReportParams}" + : $"https://{GetConfiguration().Host}/WebUntis/reports.do?msgId={Uri.EscapeDataString(reportData.MessageId!)}"; + using var request = ReportRequest(uri, sessionId, acceptJson: false); + using var response = await SendAsync(request, TimeSpan.FromSeconds(60), cancellationToken); + var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken); + if (response.Content.Headers.ContentType?.MediaType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true) + { + var payload = ParseJson(bytes); + throw new WebUntisException($"Report-Abruf fehlgeschlagen: {ErrorMessage(payload, response)}"); + } + if (!response.IsSuccessStatusCode) + throw new WebUntisException($"Report-Abruf fehlgeschlagen: HTTP {(int)response.StatusCode}."); + try { return StrictUtf8.GetString(bytes); } + catch (DecoderFallbackException) { return Encoding.Latin1.GetString(bytes); } + } + + private HttpRequestMessage ReportRequest(string uri, string sessionId, bool acceptJson) + { + 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()}\""); + return request; + } + + private async Task SendAsync(HttpRequestMessage request, TimeSpan timeout, + CancellationToken cancellationToken) + { + using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutSource.CancelAfter(timeout); + try { return await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeoutSource.Token); } + catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested) + { + throw new WebUntisException($"Timeout nach {timeout.TotalSeconds:0} Sekunden.", exception); + } + catch (HttpRequestException exception) + { + throw new WebUntisException("WebUntis ist momentan nicht erreichbar.", exception); + } + } + + private Config GetConfiguration() + { + var schoolValue = _options.School.Trim(); + var username = _options.Username.Trim(); + var password = _options.Password.Trim(); + if (schoolValue.Length == 0) throw new WebUntisConfigurationException("WEBUNTIS_SCHOOL fehlt."); + if (username.Length == 0) throw new WebUntisConfigurationException("WEBUNTIS_USER fehlt."); + if (password.Length == 0) throw new WebUntisConfigurationException("WEBUNTIS_PASSWORD fehlt."); + + var cleaned = schoolValue.Replace("https://", "", StringComparison.OrdinalIgnoreCase) + .Replace("http://", "", StringComparison.OrdinalIgnoreCase).Split('/')[0]; + var school = cleaned.Contains('.') ? cleaned.Split('.')[0] : cleaned; + var host = string.IsNullOrWhiteSpace(_options.Host) + ? (cleaned.Contains('.') ? cleaned : $"{school}.webuntis.com") + : _options.Host.Trim().Replace("https://", "", StringComparison.OrdinalIgnoreCase) + .Replace("http://", "", StringComparison.OrdinalIgnoreCase).TrimEnd('/'); + if (host.Contains('/') || !Uri.CheckHostName(host).Equals(UriHostNameType.Dns)) + throw new WebUntisConfigurationException("WEBUNTIS_HOST ist ungültig."); + return new Config(school, host, username, password, + string.IsNullOrWhiteSpace(_options.Client) ? "LehrerApp" : _options.Client.Trim()); + } + + private string SchoolCookie() => Convert.ToBase64String(Encoding.UTF8.GetBytes(GetConfiguration().School)); + + private static async Task ReadJsonAsync(HttpResponseMessage response, CancellationToken token) => + ParseJson(await response.Content.ReadAsByteArrayAsync(token)); + + private static JsonElement ParseJson(byte[] bytes) + { + try { return JsonSerializer.Deserialize(bytes, JsonOptions); } + catch (JsonException) { return default; } + } + + private static IReadOnlyList RequireArray(JsonElement value, string error) => + value.ValueKind == JsonValueKind.Array ? value.EnumerateArray().Select(item => item.Clone()).ToList() + : throw new WebUntisException(error); + + private static IReadOnlyList Entities(JsonElement parent, string property) => + TryProperty(parent, property, out var entries) && entries.ValueKind == JsonValueKind.Array + ? entries.EnumerateArray().Select(entry => new UntisEntity(OptionalInt(entry, "id") ?? 0, + OptionalString(entry, "name") ?? "", OptionalInt(entry, "orgid"), + OptionalString(entry, "orgname"), OptionalString(entry, "externalkey"))).ToList() + : []; + + private static IReadOnlyList IntArray(JsonElement parent, string property) => + TryProperty(parent, property, out var entries) && entries.ValueKind == JsonValueKind.Array + ? entries.EnumerateArray().Select(OptionalInt).Where(value => value is not null) + .Select(value => value!.Value).ToList() + : []; + + private static UntisReschedule? ParseReschedule(JsonElement parent) + { + if (!TryProperty(parent, "reschedule", out var value) || value.ValueKind != JsonValueKind.Object) return null; + var date = OptionalInt(value, "date"); + var start = OptionalInt(value, "startTime"); + var end = OptionalInt(value, "endTime"); + return date is not null && start is not null && end is not null + ? new UntisReschedule(date.Value, start.Value, end.Value) + : null; + } + + private static bool TryProperty(JsonElement value, string name, out JsonElement property) + { + property = default; + return value.ValueKind == JsonValueKind.Object && value.TryGetProperty(name, out property); + } + + private static int RequiredInt(JsonElement value, string property) => OptionalInt(value, property) + ?? throw new WebUntisException($"WebUntis-Feld \"{property}\" ist ungültig."); + + private static int? OptionalInt(JsonElement parent, string property) => + TryProperty(parent, property, out var value) ? OptionalInt(value) : null; + + private static int? OptionalInt(JsonElement value) + { + if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number)) return number; + return value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), out number) ? number : null; + } + + private static string? OptionalString(JsonElement parent, string property) + { + if (!TryProperty(parent, property, out var value)) return null; + var text = value.ValueKind == JsonValueKind.String ? value.GetString() : + value.ValueKind is JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False ? value.ToString() : null; + return string.IsNullOrWhiteSpace(text) ? null : text.Trim(); + } + + private static bool OptionalBoolean(JsonElement parent, string property) + { + if (!TryProperty(parent, property, out var value)) return false; + if (value.ValueKind is JsonValueKind.True or JsonValueKind.False) return value.GetBoolean(); + if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number)) return number != 0; + return value.ValueKind == JsonValueKind.String && + (string.Equals(value.GetString(), "true", StringComparison.OrdinalIgnoreCase) || value.GetString() == "1"); + } + + private static string ErrorMessage(JsonElement payload, HttpResponseMessage response) + { + if (payload.ValueKind == JsonValueKind.String) return payload.GetString() ?? "Unerwartete Antwort."; + if (payload.ValueKind == JsonValueKind.Object) + { + 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; + } + return response.IsSuccessStatusCode ? "Unerwartete JSON-Antwort." : $"HTTP {(int)response.StatusCode}"; + } + + private sealed record Config(string School, string Host, string Username, string Password, string Client); + + private sealed class SessionLease(WebUntisClient owner, string sessionId) : IAsyncDisposable + { + private int _released; + public string SessionId { get; } = sessionId; + + public ValueTask DisposeAsync() => Interlocked.Exchange(ref _released, 1) == 0 + ? owner.ReleaseSessionAsync() + : ValueTask.CompletedTask; + } + + private sealed record ReportData(string? ReportParams, string? MessageId) + { + public static ReportData From(JsonElement data) + { + var result = new ReportData(OptionalString(data, "reportParams"), OptionalString(data, "messageId")); + return result.ReportParams is null && result.MessageId is null + ? throw new WebUntisException("Report-Antwort enthält weder reportParams noch messageId.") + : result; + } + } +} diff --git a/LehrerApp.Api/WebUntisModels.cs b/LehrerApp.Api/WebUntisModels.cs new file mode 100644 index 0000000..594d54d --- /dev/null +++ b/LehrerApp.Api/WebUntisModels.cs @@ -0,0 +1,184 @@ +using System.Text.Json; + +namespace LehrerApp.Api; + +public sealed class WebUntisOptions +{ + public string School { get; set; } = ""; + public string Host { get; set; } = ""; + public string Username { get; set; } = ""; + public string Password { get; set; } = ""; + public string Client { get; set; } = "LehrerApp"; + public int SessionIdleTimeoutMinutes { get; set; } = 10; +} + +public sealed record UntisSchoolYear(int UntisId, string Name, int StartDate, int EndDate); + +public sealed record UntisClass( + int UntisId, + string Name, + string? LongName, + string? ForeColor, + string? BackColor, + int? DepartmentUntisId, + int? Teacher1UntisId, + int? Teacher2UntisId); + +public sealed record UntisTeacher( + int UntisId, + string Name, + string? ForeName, + string? LongName, + string? Title, + bool Active, + IReadOnlyList DepartmentUntisIds); + +public sealed record UntisStudentAddress( + string? Email, + string? Mobile, + string? Phone, + string? City, + string? PostCode, + string? Street); + +public sealed record UntisStudent( + int UntisId, + int ExternKey, + string ClassName, + string? Name, + string? LongName, + string? ForeName, + string DisplayName, + string? Gender, + int? BirthDate, + string? BirthDateRaw, + int? EntryDate, + string? EntryDateRaw, + int? ExitDate, + string? ExitDateRaw, + string? Text, + string? MedicalReportDuty, + string? Schulpflicht, + string? Majority, + UntisStudentAddress Address, + string? AttributeIL); + +public sealed record UntisStudentReport( + int Count, + string? ClassNameFilter, + IReadOnlyList Students); + +public sealed record UntisTimeUnit(string Name, int StartTime, int EndTime); + +public sealed record UntisTimeGridDay( + int Day, + IReadOnlyList TimeUnits, + JsonElement Raw); + +public sealed record UntisHoliday( + int UntisId, + string Name, + string? LongName, + int StartDate, + int EndDate); + +public sealed record UntisEntity( + int Id, + string Name, + int? OriginalId, + string? OriginalName, + string? ExternalKey); + +public sealed record UntisReschedule(int Date, int StartTime, int EndTime); + +public sealed record UntisSubstitution( + string Type, + int? LessonId, + string? LessonType, + int Date, + int StartTime, + int EndTime, + string? Text, + IReadOnlyList Classes, + IReadOnlyList Teachers, + IReadOnlyList Subjects, + IReadOnlyList Rooms, + UntisReschedule? Reschedule, + JsonElement Raw); + +public enum UntisTimetableElementType +{ + Class = 1, + Teacher = 2, + Subject = 3, + Room = 4, + Student = 5, +} + +public sealed record UntisTimetablePeriod( + int Id, + int Date, + int StartTime, + int EndTime, + string? Code, + string? ActivityType, + string? Info, + string? LessonText, + string? SubstitutionText, + string? StudentGroup, + IReadOnlyList Classes, + IReadOnlyList Teachers, + IReadOnlyList Subjects, + IReadOnlyList Rooms, + JsonElement Raw); + +public sealed record UntisStudentAbsence( + int StudentKey, + int Date, + int StartTime, + int EndTime, + int AbsentMinutes, + bool Checked, + string? AbsenceReason, + string? ExcuseStatus, + int? SubjectId, + IReadOnlyList TeacherIds, + 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, + string? Surname, + string? ForeName, + string DisplayName, + int Date, + string? Subject, + int? CategoryId, + string? Reason, + string? Text, + JsonElement Raw); + +public sealed record UntisClassRegisterCategory( + int Id, + string Name, + string? LongName, + int? GroupId, + JsonElement Raw); + +public sealed record UntisClassRegisterCategoryGroup( + int Id, + string Name, + JsonElement Raw); + +public sealed class WebUntisException(string message, Exception? innerException = null) + : Exception(message, innerException); + +public sealed class WebUntisConfigurationException(string message) : Exception(message); diff --git a/LehrerApp.Api/WebUntisStudentReportParser.cs b/LehrerApp.Api/WebUntisStudentReportParser.cs new file mode 100644 index 0000000..a99f462 --- /dev/null +++ b/LehrerApp.Api/WebUntisStudentReportParser.cs @@ -0,0 +1,133 @@ +using System.Globalization; +using System.Text; + +namespace LehrerApp.Api; + +public static class WebUntisStudentReportParser +{ + public static IReadOnlyList Parse(string content) + { + var rows = ParseSeparatedRows(content, '\t'); + if (rows.Count == 0) return []; + + var headers = rows[0] + .Select((header, index) => (index == 0 ? header.TrimStart('\uFEFF') : header).Trim()) + .ToArray(); + var result = new List(); + + foreach (var row in rows.Skip(1)) + { + if (row.All(string.IsNullOrWhiteSpace)) continue; + + var values = new Dictionary(StringComparer.Ordinal); + for (var index = 0; index < headers.Length; index++) + values[headers[index]] = index < row.Count ? row[index].Trim() : null; + + var untisId = RequiredInt(Get(values, "id"), "id"); + var externalKey = RequiredInt(Get(values, "externKey"), "externKey"); + var name = Optional(Get(values, "name")); + var lastName = Optional(Get(values, "longName")); + var firstName = Optional(Get(values, "foreName")); + var displayName = string.Join(' ', new[] { firstName, lastName }.Where(value => value is not null)); + if (string.IsNullOrWhiteSpace(displayName)) displayName = name ?? $"Schüler {untisId}"; + + result.Add(new UntisStudent( + untisId, + externalKey, + Get(values, "klasse.name")?.Trim() ?? "", + name, + lastName, + firstName, + displayName, + Optional(Get(values, "gender")), + GermanDate(Get(values, "birthDate")), + Optional(Get(values, "birthDate")), + GermanDate(Get(values, "entryDate")), + Optional(Get(values, "entryDate")), + GermanDate(Get(values, "exitDate")), + Optional(Get(values, "exitDate")), + Optional(Get(values, "text")), + Optional(Get(values, "medicalReportDuty")), + Optional(Get(values, "schulpflicht")), + Optional(Get(values, "majority")), + new UntisStudentAddress( + Optional(Get(values, "adress.email")), + Optional(Get(values, "adress.mobile")), + Optional(Get(values, "adress.phone")), + Optional(Get(values, "adress.city")), + Optional(Get(values, "adress.postCode")), + Optional(Get(values, "adress.street"))), + Optional(Get(values, "attribute.iL")))); + } + + return result; + } + + private static List> ParseSeparatedRows(string content, char separator) + { + var rows = new List>(); + var row = new List(); + var field = new StringBuilder(); + var inQuotes = false; + + for (var index = 0; index < content.Length; index++) + { + var character = content[index]; + if (character == '"') + { + if (inQuotes && index + 1 < content.Length && content[index + 1] == '"') + { + field.Append('"'); + index++; + } + else + { + inQuotes = !inQuotes; + } + continue; + } + + if (!inQuotes && character == separator) + { + row.Add(field.ToString()); + field.Clear(); + continue; + } + + if (!inQuotes && character == '\n') + { + row.Add(field.ToString()); + rows.Add(row); + row = []; + field.Clear(); + continue; + } + + if (!inQuotes && character == '\r') continue; + field.Append(character); + } + + row.Add(field.ToString()); + if (row.Count > 1 || !string.IsNullOrWhiteSpace(row[0])) rows.Add(row); + return rows; + } + + private static string? Get(IReadOnlyDictionary values, string key) => + values.TryGetValue(key, out var value) ? value : null; + + private static string? Optional(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private static int RequiredInt(string? value, string field) => + int.TryParse(value?.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) + ? parsed + : throw new InvalidDataException($"Ungültige Zahl in Spalte \"{field}\"."); + + private static int? GermanDate(string? value) + { + if (!DateOnly.TryParseExact(value?.Trim(), "dd.MM.yyyy", CultureInfo.InvariantCulture, + DateTimeStyles.None, out var date)) + return null; + return date.Year * 10_000 + date.Month * 100 + date.Day; + } +} diff --git a/docker/README.md b/docker/README.md index c121a6c..dece4b3 100644 --- a/docker/README.md +++ b/docker/README.md @@ -85,6 +85,54 @@ DWD-Ausfall liefert der Server den letzten erfolgreichen Stand. Für eine eigene `LehrerApp-Server/1.0 (+https://schule.example)`. Der Container benötigt ausgehenden HTTPS-Zugriff auf `nominatim.openstreetmap.org`, `www.dwd.de` und `opendata.dwd.de`. +## WebUntis + +Die WebUntis-Zugangsdaten werden ausschließlich im API-Container konfiguriert und nie an den +Desktop-Client ausgegeben: + +```dotenv +WEBUNTIS_SCHOOL=meine-schule +WEBUNTIS_HOST=meine-schule.webuntis.com +WEBUNTIS_USER=technischer-benutzer +WEBUNTIS_PASSWORD=geheimes-passwort +WEBUNTIS_CLIENT=LehrerApp +WEBUNTIS_SESSION_IDLE_MINUTES=10 +``` + +`WEBUNTIS_HOST` kann entfallen, wenn der Host `.webuntis.com` entspricht. Der +technische Benutzer benötigt die jeweiligen WebUntis-Leserechte und darf für die alte JSON-RPC- +Schnittstelle keine aktivierte Zwei-Faktor-Authentifizierung haben. + +Der API-Server meldet sich beim ersten Abruf an und verwendet diese Sitzung für weitere Abrufe. +Erst wenn zehn Minuten lang kein WebUntis-Abruf mehr aktiv war, meldet er sich automatisch ab. +`WEBUNTIS_SESSION_IDLE_MINUTES` kann bei Bedarf auf einen Wert zwischen 1 und 30 Minuten geändert +werden. + +Alle Endpunkte benötigen dasselbe Bearer-Token wie die Sync-API und führen genau einen Abruf aus; +sie speichern das Ergebnis nicht serverseitig: + +| Endpunkt | Zweck | +| --- | --- | +| `GET /api/webuntis/student-report?className=7a` | Schülerreport, optional nach Klasse gefiltert | +| `GET /api/webuntis/schoolyears` | Schuljahre | +| `GET /api/webuntis/classes?schoolyearId=123` | Klassen eines Schuljahres | +| `GET /api/webuntis/teachers` | Lehrkräfte und WebUntis-IDs | +| `GET /api/webuntis/holidays` | Ferienzeiträume | +| `GET /api/webuntis/timegrid` | Stunden-/Zeitraster | +| `GET /api/webuntis/substitutions?startDate=20260824&endDate=20260828` | Vertretungen im Zeitraum | +| `GET /api/webuntis/timetable?elementType=teacher&elementId=42&startDate=20260824&endDate=20260828` | Stundenplan für Klasse, Lehrkraft, Fach, Raum oder Schüler | +| `GET /api/webuntis/students/9001/absences?startDate=20260801&endDate=20270731` | Fehlzeiten eines Schülers; `9001` ist der `externKey` aus dem Schülerreport | +| `GET /api/webuntis/students/17/class-register-entries?startDate=20260801&endDate=20270731` | Klassenbucheinträge eines Schülers; `17` ist dessen `untisId` | +| `GET /api/webuntis/class-register/categories` | Kategorien der Klassenbucheinträge | +| `GET /api/webuntis/class-register/category-groups` | Kategoriegruppen der Klassenbucheinträge | + +Datumsparameter verwenden das WebUntis-Format `yyyyMMdd`. Vertretungen sind auf 31 Tage und +Stundenpläne auf 62 Tage pro Anfrage begrenzt, damit ein einzelner Client keine unkontrolliert +großen WebUntis-Abfragen auslösen kann. Fehlzeiten und Klassenbucheinträge dürfen für einen +kompletten Schuljahreszeitraum von bis zu 400 Tagen geladen werden. Diese Klassenbuchfunktionen +sind nur verfügbar, wenn das Modul an der Schule aktiv ist und der technische Benutzer die +benötigten Leserechte besitzt. + ## Deployment über Dokploy Kein manuelles Bauen/Hochladen nötig – Dokploy zieht das Repo direkt per Git und baut das Image diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 7be6e0e..0d50d26 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -13,6 +13,12 @@ services: environment: - JWT_SECRET=${JWT_SECRET} - Geocoding__UserAgent=${GEOCODING_USER_AGENT:-LehrerApp-Server/1.0 (+https://science-teaching.de)} + - WEBUNTIS_SCHOOL=${WEBUNTIS_SCHOOL:-} + - WEBUNTIS_HOST=${WEBUNTIS_HOST:-} + - WEBUNTIS_USER=${WEBUNTIS_USER:-} + - WEBUNTIS_PASSWORD=${WEBUNTIS_PASSWORD:-} + - WEBUNTIS_CLIENT=${WEBUNTIS_CLIENT:-LehrerApp} + - WEBUNTIS_SESSION_IDLE_MINUTES=${WEBUNTIS_SESSION_IDLE_MINUTES:-10} - ASPNETCORE_ENVIRONMENT=Production restart: unless-stopped