From 34a9fdf73bf8ed117aab0d4ab54433335315af5e Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Mon, 24 Aug 2026 22:00:26 +0200 Subject: [PATCH] Untis-API von Server zu Client --- .env.example | 10 - CLAUDE.md | 4 + LehrerApp.Api/Endpoints/Endpoints.cs | 171 ------------------ LehrerApp.Api/Program.cs | 24 --- LehrerApp.Api/WebUntisConnectionStore.cs | 75 -------- .../WebUntisIntegrationServiceTests.cs | 53 ++++++ LehrerApp.Desktop/AppBootstrapper.cs | 2 +- LehrerApp.Desktop/LehrerApp.Desktop.csproj | 1 + .../Services/WebUntisIntegrationService.cs | 169 +++++++++-------- .../Views/Settings/SettingsView.axaml | 2 +- .../LehrerApp.WebUntis.Tests.csproj | 18 ++ .../WebUntisClientTests.cs | 18 +- .../WebUntisStudentReportParserTests.cs | 3 +- LehrerApp.WebUntis/LehrerApp.WebUntis.csproj | 5 + .../WebUntisClient.cs | 41 +++-- .../WebUntisModels.cs | 2 +- .../WebUntisStudentReportParser.cs | 2 +- LehrerApp.sln | 28 +++ TODO.md | 6 +- docker/README.md | 48 ----- docker/docker-compose.yml | 6 - 21 files changed, 243 insertions(+), 445 deletions(-) delete mode 100644 LehrerApp.Api/WebUntisConnectionStore.cs create mode 100644 LehrerApp.Desktop.Tests/WebUntisIntegrationServiceTests.cs create mode 100644 LehrerApp.WebUntis.Tests/LehrerApp.WebUntis.Tests.csproj rename {LehrerApp.Api.Tests => LehrerApp.WebUntis.Tests}/WebUntisClientTests.cs (97%) rename {LehrerApp.Api.Tests => LehrerApp.WebUntis.Tests}/WebUntisStudentReportParserTests.cs (96%) create mode 100644 LehrerApp.WebUntis/LehrerApp.WebUntis.csproj rename {LehrerApp.Api => LehrerApp.WebUntis}/WebUntisClient.cs (96%) rename {LehrerApp.Api => LehrerApp.WebUntis}/WebUntisModels.cs (99%) rename {LehrerApp.Api => LehrerApp.WebUntis}/WebUntisStudentReportParser.cs (99%) diff --git a/.env.example b/.env.example index 4393497..b2458d2 100644 --- a/.env.example +++ b/.env.example @@ -7,13 +7,3 @@ 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/CLAUDE.md b/CLAUDE.md index b071941..b1ae585 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,12 +60,16 @@ The API can also run via `docker/docker-compose.yml` (reads `JWT_SECRET` from th orchestration, timer-driven), `SnapshotService`, `Crypto/SyncCrypto` (AES-256-GCM payload encryption — desktop events are encrypted at rest and in transit; Companion/WebApp events are plaintext, see `PlainSyncEvent` vs `SyncEvent`). +- **LehrerApp.WebUntis** — direkter, serverunabhängiger WebUntis-Client für den Desktop. Hält die + persönliche JSON-RPC-Sitzung lokal und parst den Schülerreport lokal; WebUntis-Zugangsdaten und + personenbezogene Antworten dürfen nicht über `LehrerApp.Api` geleitet werden. - **LehrerApp.Api** — minimal ASP.NET Core server: JWT auth, an append-only `EventStore` plus `SnapshotStore`/`ReadableSnapshotStore` per device, mapped in `Endpoints/Endpoints.cs`. Sync is optional — Desktop only registers `SyncEngine`/`SnapshotService` in DI when a server URL is configured (`AppBootstrapper.LoadServerUrl`). - Each library has a matching `*.Tests` project (`LehrerApp.Tests` → Core, `LehrerApp.Data.Tests` → Data, `LehrerApp.Desktop.Tests` → Desktop, `LehrerApp.Sync.Tests` → Sync), all xUnit. + `LehrerApp.WebUntis.Tests` covers the direct WebUntis client and report parser. ### MVVM conventions (Desktop) diff --git a/LehrerApp.Api/Endpoints/Endpoints.cs b/LehrerApp.Api/Endpoints/Endpoints.cs index 0fae623..44bb996 100644 --- a/LehrerApp.Api/Endpoints/Endpoints.cs +++ b/LehrerApp.Api/Endpoints/Endpoints.cs @@ -244,177 +244,6 @@ public static class Endpoints }); } - // ── WebUntis (granulare Abrufe über benutzergebundene RAM-Sitzungen) ────── - - public static void MapWebUntisEndpoints(this WebApplication app) - { - var group = app.MapGroup("/api/webuntis").RequireAuthorization(); - - group.MapGet("/connection", (ClaimsPrincipal user, WebUntisConnectionStore connections) => - { - var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; - return uid is null ? Results.Unauthorized() : Results.Ok(connections.GetStatus(uid)); - }); - - group.MapPost("/connection", async (WebUntisConnectRequest request, ClaimsPrincipal user, - WebUntisConnectionStore connections, CancellationToken cancellationToken) => - { - var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; - if (uid is null) return Results.Unauthorized(); - if (string.IsNullOrWhiteSpace(request.School) || string.IsNullOrWhiteSpace(request.Username) || - string.IsNullOrWhiteSpace(request.Password)) - return Results.BadRequest("Schule, Benutzername und Passwort sind erforderlich."); - return await WebUntisResult(() => connections.ConnectAsync(uid, request, cancellationToken)); - }); - - group.MapDelete("/connection", async (ClaimsPrincipal user, WebUntisConnectionStore connections) => - { - var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; - if (uid is null) return Results.Unauthorized(); - await connections.DisconnectAsync(uid); - return Results.NoContent(); - }); - - group.MapGet("/schoolyears", (ClaimsPrincipal user, WebUntisConnectionStore connections, - WebUntisClient fallback, CancellationToken cancellationToken) => - WithWebUntisClient(user, connections, fallback, - client => client.GetSchoolYearsAsync(cancellationToken))); - - group.MapGet("/classes", ([FromQuery] int schoolyearId, ClaimsPrincipal user, - WebUntisConnectionStore connections, WebUntisClient fallback, - CancellationToken cancellationToken) => schoolyearId <= 0 - ? Task.FromResult(Results.BadRequest("schoolyearId muss größer als 0 sein.")) - : WithWebUntisClient(user, connections, fallback, - client => client.GetClassesAsync(schoolyearId, cancellationToken))); - - group.MapGet("/teachers", (ClaimsPrincipal user, WebUntisConnectionStore connections, - WebUntisClient fallback, CancellationToken cancellationToken) => - WithWebUntisClient(user, connections, fallback, client => client.GetTeachersAsync(cancellationToken))); - - group.MapGet("/student-report", ([FromQuery] string? className, ClaimsPrincipal user, - WebUntisConnectionStore connections, WebUntisClient fallback, CancellationToken cancellationToken) => - WithWebUntisClient(user, connections, fallback, - client => client.GetStudentReportAsync(className, cancellationToken))); - - group.MapGet("/holidays", (ClaimsPrincipal user, WebUntisConnectionStore connections, - WebUntisClient fallback, CancellationToken cancellationToken) => - WithWebUntisClient(user, connections, fallback, client => client.GetHolidaysAsync(cancellationToken))); - - group.MapGet("/timegrid", (ClaimsPrincipal user, WebUntisConnectionStore connections, - WebUntisClient fallback, CancellationToken cancellationToken) => - WithWebUntisClient(user, connections, fallback, client => client.GetTimeGridAsync(cancellationToken))); - - group.MapGet("/substitutions", ([FromQuery] int startDate, [FromQuery] int endDate, - [FromQuery] int? departmentId, ClaimsPrincipal user, WebUntisConnectionStore connections, - WebUntisClient fallback, CancellationToken cancellationToken) => - { - if (!ValidDateRange(startDate, endDate, 31, out var error)) - return Task.FromResult(Results.BadRequest(error)); - return WithWebUntisClient(user, connections, fallback, - client => client.GetSubstitutionsAsync(startDate, endDate, departmentId, cancellationToken)); - }); - - group.MapGet("/timetable", ([FromQuery] string elementType, [FromQuery] int elementId, - [FromQuery] int startDate, [FromQuery] int endDate, ClaimsPrincipal user, - WebUntisConnectionStore connections, WebUntisClient fallback, 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 WithWebUntisClient(user, connections, fallback, - client => client.GetTimetableAsync(parsedType, elementId, startDate, endDate, cancellationToken)); - }); - - group.MapGet("/students/{studentKey:int}/absences", (int studentKey, [FromQuery] int startDate, - [FromQuery] int endDate, ClaimsPrincipal user, WebUntisConnectionStore connections, - WebUntisClient fallback, 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 WithWebUntisClient(user, connections, fallback, - client => client.GetStudentAbsencesAsync(studentKey, startDate, endDate, cancellationToken)); - }); - - group.MapGet("/students/{studentId:int}/class-register-entries", (int studentId, - [FromQuery] int startDate, [FromQuery] int endDate, ClaimsPrincipal user, - WebUntisConnectionStore connections, WebUntisClient fallback, 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 WithWebUntisClient(user, connections, fallback, - client => client.GetClassRegisterEntriesAsync(studentId, startDate, endDate, cancellationToken)); - }); - - group.MapGet("/class-register/categories", (ClaimsPrincipal user, - WebUntisConnectionStore connections, WebUntisClient fallback, CancellationToken cancellationToken) => - WithWebUntisClient(user, connections, fallback, - client => client.GetClassRegisterCategoriesAsync(cancellationToken))); - - group.MapGet("/class-register/category-groups", (ClaimsPrincipal user, - WebUntisConnectionStore connections, WebUntisClient fallback, CancellationToken cancellationToken) => - WithWebUntisClient(user, connections, fallback, - client => client.GetClassRegisterCategoryGroupsAsync(cancellationToken))); - } - - private static Task WithWebUntisClient(ClaimsPrincipal user, - WebUntisConnectionStore connections, WebUntisClient fallback, Func> operation) - { - var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; - if (uid is null) return Task.FromResult(Results.Unauthorized()); - return WebUntisResult(() => operation(connections.GetClient(uid) ?? fallback)); - } - - 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 b27d775..fb4c224 100644 --- a/LehrerApp.Api/Program.cs +++ b/LehrerApp.Api/Program.cs @@ -97,29 +97,6 @@ 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>())); -builder.Services.AddSingleton(); var app = builder.Build(); app.UseForwardedHeaders(); @@ -135,6 +112,5 @@ app.MapSnapshotEndpoints(); app.MapReadableSnapshotEndpoints(); app.MapPlainSyncEndpoints(); app.MapSchoolWeatherEndpoints(); -app.MapWebUntisEndpoints(); app.Run(); return 0; diff --git a/LehrerApp.Api/WebUntisConnectionStore.cs b/LehrerApp.Api/WebUntisConnectionStore.cs deleted file mode 100644 index 744de09..0000000 --- a/LehrerApp.Api/WebUntisConnectionStore.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.Collections.Concurrent; -using Microsoft.Extensions.Options; - -namespace LehrerApp.Api; - -public sealed record WebUntisConnectRequest( - string School, - string? Host, - string Username, - string Password); - -public sealed record WebUntisConnectionStatus(bool Connected, string? School, string? Username); - -/// -/// Hält persönliche WebUntis-Verbindungen ausschließlich im Arbeitsspeicher des Servers. -/// Passwörter werden weder protokolliert noch serverseitig persistiert. Der enthaltene Client -/// verwaltet die eigentliche WebUntis-Session und meldet sie nach Inaktivität wieder ab. -/// -public sealed class WebUntisConnectionStore(IHttpClientFactory httpClientFactory) : IAsyncDisposable -{ - private sealed record Entry(WebUntisClient Client, string School, string Username); - - private readonly ConcurrentDictionary _connections = new(); - - public WebUntisClient? GetClient(string userId) => - _connections.TryGetValue(userId, out var entry) ? entry.Client : null; - - public WebUntisConnectionStatus GetStatus(string userId) => - _connections.TryGetValue(userId, out var entry) - ? new(true, entry.School, entry.Username) - : new(false, null, null); - - public async Task ConnectAsync(string userId, WebUntisConnectRequest request, - CancellationToken cancellationToken) - { - var options = new WebUntisOptions - { - School = request.School, - Host = request.Host ?? "", - Username = request.Username, - Password = request.Password, - Client = "LehrerApp", - SessionIdleTimeoutMinutes = 10, - }; - var client = new WebUntisClient(httpClientFactory.CreateClient("webuntis"), Options.Create(options)); - try - { - // Authentifiziert wirklich gegen WebUntis; ungültige Daten werden nicht gespeichert. - await client.GetSchoolYearsAsync(cancellationToken); - } - catch - { - await client.DisposeAsync(); - throw; - } - - var entry = new Entry(client, request.School.Trim(), request.Username.Trim()); - if (_connections.TryGetValue(userId, out var previous)) - await previous.Client.DisposeAsync(); - _connections[userId] = entry; - return new(true, entry.School, entry.Username); - } - - public async Task DisconnectAsync(string userId) - { - if (_connections.TryRemove(userId, out var entry)) await entry.Client.DisposeAsync(); - } - - public async ValueTask DisposeAsync() - { - var entries = _connections.Values.ToList(); - _connections.Clear(); - foreach (var entry in entries) await entry.Client.DisposeAsync(); - } -} diff --git a/LehrerApp.Desktop.Tests/WebUntisIntegrationServiceTests.cs b/LehrerApp.Desktop.Tests/WebUntisIntegrationServiceTests.cs new file mode 100644 index 0000000..f8875a9 --- /dev/null +++ b/LehrerApp.Desktop.Tests/WebUntisIntegrationServiceTests.cs @@ -0,0 +1,53 @@ +using System.Net; +using System.Text; +using LehrerApp.Desktop.Services; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class WebUntisIntegrationServiceTests +{ + [Fact] + public async Task ConnectUndAbruf_GehenDirektAnWebUntisUndVerwendenDieselbeSession() + { + var handler = new QueueHandler( + Json("{\"result\":{\"sessionId\":\"desktop-session\"}}"), + Json("{\"result\":[{\"id\":1,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"), + Json("{\"result\":[{\"id\":42,\"name\":\"KUE\",\"longName\":\"Kürbis\",\"active\":true}]}"), + Json("{\"result\":{}}")); + var settings = TestSupport.BuildWebUntisSettingsService(); + await using var service = new WebUntisIntegrationService(new HttpClient(handler), settings); + + await service.ConnectAsync(new WebUntisCredentials( + "bk-ostvest", "arche.webuntis.com", "lehrkraft", "geheim")); + var teachers = await service.GetTeachersAsync(); + + Assert.Equal("KUE", Assert.Single(teachers).Name); + Assert.Equal(3, handler.Requests.Count); + Assert.All(handler.Requests, request => + Assert.StartsWith("https://arche.webuntis.com/WebUntis/", request.Uri)); + Assert.DoesNotContain(handler.Requests, request => request.Uri.Contains("/api/webuntis")); + Assert.Single(handler.Requests, request => request.Body.Contains("\"method\":\"authenticate\"")); + } + + 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(request.RequestUri?.ToString() ?? "", + request.Content is null ? "" : await request.Content.ReadAsStringAsync(cancellationToken))); + return _responses.Dequeue(); + } + } + + private sealed record CapturedRequest(string Uri, string Body); +} diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index 3a87ede..7cda975 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -223,7 +223,7 @@ public static class AppBootstrapper services.AddSingleton(syncSettings); services.AddSingleton(_ => new SyncAuthService(new HttpClient())); services.AddSingleton(sp => new SchoolWeatherService(new HttpClient(), syncSettings)); - services.AddSingleton(sp => new WebUntisIntegrationService(new HttpClient(), syncSettings, untisSettings)); + services.AddSingleton(sp => new WebUntisIntegrationService(new HttpClient(), untisSettings)); // War dieses Gerät schon eingeloggt, aber sync.key fehlt(e), wurde gerade eben (unten) // stillschweigend ein neuer, unabhängiger Schlüssel erzeugt - bisher unter dem ALTEN // Schlüssel synchronisierte Server-Daten sind für dieses Gerät dann nicht mehr lesbar. diff --git a/LehrerApp.Desktop/LehrerApp.Desktop.csproj b/LehrerApp.Desktop/LehrerApp.Desktop.csproj index a93a56b..9b9dc4c 100644 --- a/LehrerApp.Desktop/LehrerApp.Desktop.csproj +++ b/LehrerApp.Desktop/LehrerApp.Desktop.csproj @@ -9,6 +9,7 @@ + diff --git a/LehrerApp.Desktop/Services/WebUntisIntegrationService.cs b/LehrerApp.Desktop/Services/WebUntisIntegrationService.cs index 3f4e805..04c4500 100644 --- a/LehrerApp.Desktop/Services/WebUntisIntegrationService.cs +++ b/LehrerApp.Desktop/Services/WebUntisIntegrationService.cs @@ -1,6 +1,4 @@ -using System.Net; -using System.Net.Http.Json; -using System.Text.Json; +using LehrerApp.WebUntis; namespace LehrerApp.Desktop.Services; @@ -35,106 +33,125 @@ public sealed record UntisStudentAbsenceDto(int StudentKey, int Date, int StartT public sealed record UntisStudentAbsenceReportDto(int StudentKey, int StartDate, int EndDate, int EntryCount, int AbsentMinutes, IReadOnlyList Absences); -/// Authentifizierter Desktop-Client für die LehrerApp-API. Falls der Server neu gestartet -/// wurde, baut er die nur im Server-RAM gehaltene WebUntis-Verbindung automatisch erneut auf. -public sealed class WebUntisIntegrationService(HttpClient http, SyncSettingsService syncSettings, - WebUntisSettingsService settings) +/// Direkter WebUntis-Client des Desktops. Personenbezogene Antworten und der +/// unverschlüsselte CSV-Report passieren zu keinem Zeitpunkt den LehrerApp-Server. +public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettingsService settings) : IAsyncDisposable { - public bool IsAvailable => syncSettings.IsLoggedIn && !string.IsNullOrWhiteSpace(syncSettings.ServerUrl) - && settings.ApiIsConfigured; + private readonly SemaphoreSlim _clientGate = new(1, 1); + private WebUntisClient? _client; + + public bool IsAvailable => settings.ApiIsConfigured; public async Task ConnectAsync(WebUntisCredentials credentials, CancellationToken token = default) { - using var response = await SendAsync(HttpMethod.Post, "/api/webuntis/connection", new + var candidate = CreateClient(credentials); + try { - credentials.School, - Host = string.IsNullOrWhiteSpace(credentials.Host) ? null : credentials.Host, - credentials.Username, - credentials.Password, - }, token); - await EnsureSuccessAsync(response); + await candidate.GetSchoolYearsAsync(token); + } + catch (Exception exception) when (IsUntisError(exception)) + { + await candidate.DisposeAsync(); + throw Translate(exception); + } + + WebUntisClient? previous; + await _clientGate.WaitAsync(token); + try { previous = _client; _client = candidate; } + finally { _clientGate.Release(); } + if (previous is not null) await previous.DisposeAsync(); } public async Task DisconnectAsync(CancellationToken token = default) { - using var response = await SendAsync(HttpMethod.Delete, "/api/webuntis/connection", null, token); - await EnsureSuccessAsync(response); + WebUntisClient? previous; + await _clientGate.WaitAsync(token); + try { previous = _client; _client = null; } + finally { _clientGate.Release(); } + if (previous is not null) await previous.DisposeAsync(); } - public Task> GetSchoolYearsAsync(CancellationToken token = default) => - GetAsync>("/api/webuntis/schoolyears", token); + public Task> GetSchoolYearsAsync(CancellationToken token = default) => ExecuteAsync( + async client => (IReadOnlyList)(await client.GetSchoolYearsAsync(token)) + .Select(x => new UntisSchoolYearDto(x.UntisId, x.Name, x.StartDate, x.EndDate)).ToList(), token); - public Task> GetClassesAsync(int schoolYearId, CancellationToken token = default) => - GetAsync>($"/api/webuntis/classes?schoolyearId={schoolYearId}", token); + public Task> GetClassesAsync(int schoolYearId, CancellationToken token = default) => ExecuteAsync( + async client => (IReadOnlyList)(await client.GetClassesAsync(schoolYearId, token)) + .Select(x => new UntisClassDto(x.UntisId, x.Name, x.LongName)).ToList(), token); - public Task> GetTeachersAsync(CancellationToken token = default) => - GetAsync>("/api/webuntis/teachers", token); + public Task> GetTeachersAsync(CancellationToken token = default) => ExecuteAsync( + async client => (IReadOnlyList)(await client.GetTeachersAsync(token)) + .Select(x => new UntisTeacherDto(x.UntisId, x.Name, x.ForeName, x.LongName, x.Title, x.Active, + x.DepartmentUntisIds)).ToList(), token); - public Task> GetTimeGridAsync(CancellationToken token = default) => - GetAsync>("/api/webuntis/timegrid", token); + public Task> GetTimeGridAsync(CancellationToken token = default) => ExecuteAsync( + async client => (IReadOnlyList)(await client.GetTimeGridAsync(token)) + .Select(x => new UntisTimeGridDayDto(x.Day, + x.TimeUnits.Select(t => new UntisTimeUnitDto(t.Name, t.StartTime, t.EndTime)).ToList())).ToList(), token); public Task> GetTimetableAsync(int teacherId, DateOnly start, - DateOnly end, CancellationToken token = default) => GetAsync>( - $"/api/webuntis/timetable?elementType=teacher&elementId={teacherId}&startDate={Date(start)}&endDate={Date(end)}", token); + DateOnly end, CancellationToken token = default) => ExecuteAsync(async client => + (IReadOnlyList)(await client.GetTimetableAsync( + UntisTimetableElementType.Teacher, teacherId, Date(start), Date(end), token)) + .Select(x => new UntisTimetablePeriodDto(x.Id, x.Date, x.StartTime, x.EndTime, x.Code, + x.ActivityType, x.Info, x.LessonText, x.SubstitutionText, x.StudentGroup, + Entities(x.Classes), Entities(x.Teachers), Entities(x.Subjects), Entities(x.Rooms))).ToList(), token); - public Task GetStudentsAsync(string className, CancellationToken token = default) => - GetAsync($"/api/webuntis/student-report?className={Uri.EscapeDataString(className)}", token); + public Task GetStudentsAsync(string className, CancellationToken token = default) => ExecuteAsync( + async client => + { + var report = await client.GetStudentReportAsync(className, token); + return new UntisStudentReportDto(report.Count, report.ClassNameFilter, report.Students.Select(x => + new UntisStudentDto(x.UntisId, x.ExternKey, x.ClassName, x.Name, x.LongName, x.ForeName, + x.DisplayName, x.Gender, x.BirthDate, x.BirthDateRaw, x.EntryDate, x.EntryDateRaw, + x.ExitDate, x.ExitDateRaw, x.Text, x.MedicalReportDuty, x.Schulpflicht, x.Majority, + new UntisStudentAddressDto(x.Address.Email, x.Address.Mobile, x.Address.Phone, x.Address.City, + x.Address.PostCode, x.Address.Street), x.AttributeIL)).ToList()); + }, token); public Task GetAbsencesAsync(int studentKey, DateOnly start, DateOnly end, - CancellationToken token = default) => GetAsync( - $"/api/webuntis/students/{studentKey}/absences?startDate={Date(start)}&endDate={Date(end)}", token); - - private async Task GetAsync(string path, CancellationToken token) + CancellationToken token = default) => ExecuteAsync(async client => { - await EnsureConnectedAsync(token); - using var response = await SendAsync(HttpMethod.Get, path, null, token); - await EnsureSuccessAsync(response); - return await response.Content.ReadFromJsonAsync(cancellationToken: token) - ?? throw new WebUntisIntegrationException("Der Server hat keine WebUntis-Daten zurückgegeben."); + var report = await client.GetStudentAbsencesAsync(studentKey, Date(start), Date(end), token); + return new UntisStudentAbsenceReportDto(report.StudentKey, report.StartDate, report.EndDate, + report.EntryCount, report.AbsentMinutes, report.Absences.Select(x => new UntisStudentAbsenceDto( + x.StudentKey, x.Date, x.StartTime, x.EndTime, x.AbsentMinutes, x.Checked, x.AbsenceReason, + x.ExcuseStatus, x.SubjectId, x.TeacherIds, x.StudentGroup)).ToList()); + }, token); + + private async Task ExecuteAsync(Func> operation, CancellationToken token) + { + try { return await operation(await GetClientAsync(token)); } + catch (Exception exception) when (IsUntisError(exception)) { throw Translate(exception); } } - private async Task EnsureConnectedAsync(CancellationToken token) + private async Task GetClientAsync(CancellationToken token) { - var credentials = settings.GetApiCredentials() - ?? throw new WebUntisIntegrationException("Bitte zuerst die WebUntis-Anmeldedaten einrichten."); - using var status = await SendAsync(HttpMethod.Get, "/api/webuntis/connection", null, token); - await EnsureSuccessAsync(status); - using var json = JsonDocument.Parse(await status.Content.ReadAsStringAsync(token)); - if (!json.RootElement.GetProperty("connected").GetBoolean()) await ConnectAsync(credentials, token); - } - - private async Task SendAsync(HttpMethod method, string path, object? body, - CancellationToken token) - { - if (!syncSettings.IsLoggedIn || string.IsNullOrWhiteSpace(syncSettings.ServerUrl)) - throw new WebUntisIntegrationException("Bitte zuerst unter „Synchronisation“ am LehrerApp-Server anmelden."); - using var request = new HttpRequestMessage(method, new Uri(new Uri(syncSettings.ServerUrl), path)); - request.Headers.Authorization = new("Bearer", syncSettings.GetToken()); - if (body is not null) request.Content = JsonContent.Create(body); - try { return await http.SendAsync(request, token); } - catch (HttpRequestException) { throw new WebUntisIntegrationException("Der LehrerApp-Server ist nicht erreichbar."); } - } - - private static async Task EnsureSuccessAsync(HttpResponseMessage response) - { - if (response.IsSuccessStatusCode) return; - if (response.StatusCode == HttpStatusCode.Unauthorized) - throw new WebUntisIntegrationException("Die Server-Anmeldung ist abgelaufen. Bitte erneut anmelden."); - if (response.StatusCode == HttpStatusCode.NotFound && - response.RequestMessage?.RequestUri?.AbsolutePath.StartsWith("/api/webuntis", - StringComparison.OrdinalIgnoreCase) == true) - throw new WebUntisIntegrationException( - "Der konfigurierte LehrerApp-Server enthält die WebUntis-Integration noch nicht. " + - "Bitte zuerst die aktuelle LehrerApp.Api auf dem Server bereitstellen."); + if (_client is not null) return _client; + await _clientGate.WaitAsync(token); try { - using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); - if (json.RootElement.TryGetProperty("detail", out var detail) && !string.IsNullOrWhiteSpace(detail.GetString())) - throw new WebUntisIntegrationException(detail.GetString()!); + if (_client is not null) return _client; + var credentials = settings.GetApiCredentials() + ?? throw new WebUntisIntegrationException("Bitte zuerst die WebUntis-Anmeldedaten einrichten."); + return _client = CreateClient(credentials); } - catch (JsonException) { } - throw new WebUntisIntegrationException("Der WebUntis-Abruf ist fehlgeschlagen."); + finally { _clientGate.Release(); } } + private WebUntisClient CreateClient(WebUntisCredentials credentials) => new(http, new WebUntisOptions + { + School = credentials.School, Host = credentials.Host, Username = credentials.Username, + Password = credentials.Password, Client = "LehrerApp-Desktop", SessionIdleTimeoutMinutes = 10, + }); + private static int Date(DateOnly date) => date.Year * 10000 + date.Month * 100 + date.Day; + private static IReadOnlyList Entities(IReadOnlyList values) => values + .Select(x => new UntisEntityDto(x.Id, x.Name, x.OriginalId, x.OriginalName, x.ExternalKey)).ToList(); + private static bool IsUntisError(Exception exception) => exception is WebUntisException + or WebUntisConfigurationException or InvalidDataException; + private static WebUntisIntegrationException Translate(Exception exception) => + new(exception.Message); + + public async ValueTask DisposeAsync() => await DisconnectAsync(); } diff --git a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml index 71b9a01..74307e7 100644 --- a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml +++ b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml @@ -971,7 +971,7 @@ + Text="Ermöglicht Stundenplan-, Schüler- und Fehlzeitenabgleich direkt zwischen diesem Gerät und WebUntis. Zugangsdaten werden lokal verschlüsselt gespeichert; weder sie noch Schülerdaten oder CSV-Reports passieren den LehrerApp-Server."/> diff --git a/LehrerApp.WebUntis.Tests/LehrerApp.WebUntis.Tests.csproj b/LehrerApp.WebUntis.Tests/LehrerApp.WebUntis.Tests.csproj new file mode 100644 index 0000000..7cb01d0 --- /dev/null +++ b/LehrerApp.WebUntis.Tests/LehrerApp.WebUntis.Tests.csproj @@ -0,0 +1,18 @@ + + + net10.0 + false + true + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/LehrerApp.Api.Tests/WebUntisClientTests.cs b/LehrerApp.WebUntis.Tests/WebUntisClientTests.cs similarity index 97% rename from LehrerApp.Api.Tests/WebUntisClientTests.cs rename to LehrerApp.WebUntis.Tests/WebUntisClientTests.cs index aee682c..e6df3aa 100644 --- a/LehrerApp.Api.Tests/WebUntisClientTests.cs +++ b/LehrerApp.WebUntis.Tests/WebUntisClientTests.cs @@ -1,9 +1,9 @@ using System.Net; using System.Text; -using Microsoft.Extensions.Options; +using LehrerApp.WebUntis; using Xunit; -namespace LehrerApp.Api.Tests; +namespace LehrerApp.WebUntis.Tests; public sealed class WebUntisClientTests { @@ -75,7 +75,7 @@ public sealed class WebUntisClientTests public async Task FehlendeKonfiguration_BrichtVorHttpRequestAb() { var handler = new QueueHandler(); - var client = new WebUntisClient(new HttpClient(handler), Options.Create(new WebUntisOptions())); + var client = new WebUntisClient(new HttpClient(handler), new WebUntisOptions()); await Assert.ThrowsAsync( () => client.GetSchoolYearsAsync(CancellationToken.None)); @@ -91,12 +91,12 @@ public sealed class WebUntisClientTests Json("{\"result\":{\"sessionId\":\"s\"}}"), Json("{\"result\":[{\"id\":1,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"), Json("{\"result\":{}}")); - var client = new WebUntisClient(new HttpClient(handler), Options.Create(new WebUntisOptions + var client = new WebUntisClient(new HttpClient(handler), new WebUntisOptions { School = "https://arche.webuntis.com/WebUntis/?school=bk-ostvest#/basic/login", Username = "api-user", Password = "secret", - })); + }); await client.GetSchoolYearsAsync(CancellationToken.None); @@ -112,13 +112,13 @@ public sealed class WebUntisClientTests Json("{\"result\":{\"sessionId\":\"s\"}}"), Json("{\"result\":[{\"id\":1,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"), Json("{\"result\":{}}")); - var client = new WebUntisClient(new HttpClient(handler), Options.Create(new WebUntisOptions + var client = new WebUntisClient(new HttpClient(handler), new WebUntisOptions { School = "bk-ostvest", Host = "https://arche.webuntis.com/WebUntis/?school=bk-ostvest#/basic/login", Username = "api-user", Password = "secret", - })); + }); await client.GetSchoolYearsAsync(CancellationToken.None); @@ -175,14 +175,14 @@ public sealed class WebUntisClientTests private static WebUntisClient CreateClient(HttpMessageHandler handler) => new( new HttpClient(handler), - Options.Create(new WebUntisOptions + 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) { diff --git a/LehrerApp.Api.Tests/WebUntisStudentReportParserTests.cs b/LehrerApp.WebUntis.Tests/WebUntisStudentReportParserTests.cs similarity index 96% rename from LehrerApp.Api.Tests/WebUntisStudentReportParserTests.cs rename to LehrerApp.WebUntis.Tests/WebUntisStudentReportParserTests.cs index 7ca06cf..85e43de 100644 --- a/LehrerApp.Api.Tests/WebUntisStudentReportParserTests.cs +++ b/LehrerApp.WebUntis.Tests/WebUntisStudentReportParserTests.cs @@ -1,6 +1,7 @@ using Xunit; +using LehrerApp.WebUntis; -namespace LehrerApp.Api.Tests; +namespace LehrerApp.WebUntis.Tests; public sealed class WebUntisStudentReportParserTests { diff --git a/LehrerApp.WebUntis/LehrerApp.WebUntis.csproj b/LehrerApp.WebUntis/LehrerApp.WebUntis.csproj new file mode 100644 index 0000000..555ae43 --- /dev/null +++ b/LehrerApp.WebUntis/LehrerApp.WebUntis.csproj @@ -0,0 +1,5 @@ + + + net10.0 + + diff --git a/LehrerApp.Api/WebUntisClient.cs b/LehrerApp.WebUntis/WebUntisClient.cs similarity index 96% rename from LehrerApp.Api/WebUntisClient.cs rename to LehrerApp.WebUntis/WebUntisClient.cs index 7645b51..80d1905 100644 --- a/LehrerApp.Api/WebUntisClient.cs +++ b/LehrerApp.WebUntis/WebUntisClient.cs @@ -1,10 +1,9 @@ using System.Net.Http.Headers; +using System.Net.Http.Json; using System.Text; using System.Text.Json; -using Microsoft.AspNetCore.WebUtilities; -using Microsoft.Extensions.Options; -namespace LehrerApp.Api; +namespace LehrerApp.WebUntis; public sealed class WebUntisClient : IAsyncDisposable { @@ -19,10 +18,10 @@ public sealed class WebUntisClient : IAsyncDisposable private int _activeRequests; private bool _disposed; - public WebUntisClient(HttpClient http, IOptions options) + public WebUntisClient(HttpClient http, WebUntisOptions options) { _http = http; - _options = options.Value; + _options = options; _sessionExpiryTimer = new Timer( static state => _ = ((WebUntisClient)state!).CloseExpiredSessionAsync(), this, @@ -403,12 +402,8 @@ public sealed class WebUntisClient : IAsyncDisposable 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); + var uri = $"https://{GetConfiguration().Host}/WebUntis/reports.do" + + "?name=Student&format=csv&klasseId=-1&studentsForDate=true&context=klasseId"; using var request = ReportRequest(uri, sessionId, acceptJson: true); using var response = await SendAsync(request, TimeSpan.FromSeconds(20), cancellationToken); var payload = await ReadJsonAsync(response, cancellationToken); @@ -525,9 +520,8 @@ public sealed class WebUntisClient : IAsyncDisposable if (LooksLikeLocation(school) && TryParseLocation(school, out var schoolLocation)) { hostFromSchool = schoolLocation.Host; - school = QueryHelpers.ParseQuery(schoolLocation.Query).TryGetValue("school", out var querySchool) && - !string.IsNullOrWhiteSpace(querySchool) - ? querySchool.ToString().Trim() + school = QueryValue(schoolLocation, "school") is { Length: > 0 } querySchool + ? querySchool : schoolLocation.Host.Split('.')[0]; } @@ -539,10 +533,8 @@ public sealed class WebUntisClient : IAsyncDisposable host = hostLocation.Host; // Komfortfall: In das Serverfeld wurde die vollständige Login-URL kopiert. Eine // explizit im Schulfeld angegebene Kennung behält trotzdem Vorrang. - if (LooksLikeLocation(schoolValue) && - QueryHelpers.ParseQuery(hostLocation.Query).TryGetValue("school", out var querySchool) && - !string.IsNullOrWhiteSpace(querySchool)) - school = querySchool.ToString().Trim(); + if (LooksLikeLocation(schoolValue) && QueryValue(hostLocation, "school") is { Length: > 0 } querySchool) + school = querySchool; } else { @@ -567,6 +559,19 @@ public sealed class WebUntisClient : IAsyncDisposable return Uri.TryCreate(candidate, UriKind.Absolute, out location!) && !string.IsNullOrWhiteSpace(location.Host); } + private static string? QueryValue(Uri uri, string key) + { + foreach (var part in uri.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var pair = part.Split('=', 2); + if (!Uri.UnescapeDataString(pair[0]).Equals(key, StringComparison.OrdinalIgnoreCase)) continue; + return pair.Length == 2 + ? Uri.UnescapeDataString(pair[1].Replace('+', ' ')).Trim() + : ""; + } + return null; + } + private string SchoolCookie() => Convert.ToBase64String(Encoding.UTF8.GetBytes(GetConfiguration().School)); private static async Task ReadJsonAsync(HttpResponseMessage response, CancellationToken token) => diff --git a/LehrerApp.Api/WebUntisModels.cs b/LehrerApp.WebUntis/WebUntisModels.cs similarity index 99% rename from LehrerApp.Api/WebUntisModels.cs rename to LehrerApp.WebUntis/WebUntisModels.cs index 594d54d..a8ee514 100644 --- a/LehrerApp.Api/WebUntisModels.cs +++ b/LehrerApp.WebUntis/WebUntisModels.cs @@ -1,6 +1,6 @@ using System.Text.Json; -namespace LehrerApp.Api; +namespace LehrerApp.WebUntis; public sealed class WebUntisOptions { diff --git a/LehrerApp.Api/WebUntisStudentReportParser.cs b/LehrerApp.WebUntis/WebUntisStudentReportParser.cs similarity index 99% rename from LehrerApp.Api/WebUntisStudentReportParser.cs rename to LehrerApp.WebUntis/WebUntisStudentReportParser.cs index a99f462..6accfa8 100644 --- a/LehrerApp.Api/WebUntisStudentReportParser.cs +++ b/LehrerApp.WebUntis/WebUntisStudentReportParser.cs @@ -1,7 +1,7 @@ using System.Globalization; using System.Text; -namespace LehrerApp.Api; +namespace LehrerApp.WebUntis; public static class WebUntisStudentReportParser { diff --git a/LehrerApp.sln b/LehrerApp.sln index 880f825..9b71d4f 100644 --- a/LehrerApp.sln +++ b/LehrerApp.sln @@ -6,6 +6,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Data", "LehrerApp EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Sync", "LehrerApp.Sync\LehrerApp.Sync.csproj", "{A1000003-0000-0000-0000-000000000003}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.WebUntis", "LehrerApp.WebUntis\LehrerApp.WebUntis.csproj", "{A1000007-0000-0000-0000-000000000007}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.WebUntis.Tests", "LehrerApp.WebUntis.Tests\LehrerApp.WebUntis.Tests.csproj", "{A1000008-0000-0000-0000-000000000008}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Api", "LehrerApp.Api\LehrerApp.Api.csproj", "{A1000004-0000-0000-0000-000000000004}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Desktop", "LehrerApp.Desktop\LehrerApp.Desktop.csproj", "{A1000005-0000-0000-0000-000000000005}" @@ -138,6 +142,30 @@ Global {E8152216-11F1-427E-B189-D8CEC9A71C33}.Release|x64.Build.0 = Release|Any CPU {E8152216-11F1-427E-B189-D8CEC9A71C33}.Release|x86.ActiveCfg = Release|Any CPU {E8152216-11F1-427E-B189-D8CEC9A71C33}.Release|x86.Build.0 = Release|Any CPU + {A1000007-0000-0000-0000-000000000007}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1000007-0000-0000-0000-000000000007}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1000007-0000-0000-0000-000000000007}.Debug|x64.ActiveCfg = Debug|Any CPU + {A1000007-0000-0000-0000-000000000007}.Debug|x64.Build.0 = Debug|Any CPU + {A1000007-0000-0000-0000-000000000007}.Debug|x86.ActiveCfg = Debug|Any CPU + {A1000007-0000-0000-0000-000000000007}.Debug|x86.Build.0 = Debug|Any CPU + {A1000007-0000-0000-0000-000000000007}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1000007-0000-0000-0000-000000000007}.Release|Any CPU.Build.0 = Release|Any CPU + {A1000007-0000-0000-0000-000000000007}.Release|x64.ActiveCfg = Release|Any CPU + {A1000007-0000-0000-0000-000000000007}.Release|x64.Build.0 = Release|Any CPU + {A1000007-0000-0000-0000-000000000007}.Release|x86.ActiveCfg = Release|Any CPU + {A1000007-0000-0000-0000-000000000007}.Release|x86.Build.0 = Release|Any CPU + {A1000008-0000-0000-0000-000000000008}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1000008-0000-0000-0000-000000000008}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1000008-0000-0000-0000-000000000008}.Debug|x64.ActiveCfg = Debug|Any CPU + {A1000008-0000-0000-0000-000000000008}.Debug|x64.Build.0 = Debug|Any CPU + {A1000008-0000-0000-0000-000000000008}.Debug|x86.ActiveCfg = Debug|Any CPU + {A1000008-0000-0000-0000-000000000008}.Debug|x86.Build.0 = Debug|Any CPU + {A1000008-0000-0000-0000-000000000008}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1000008-0000-0000-0000-000000000008}.Release|Any CPU.Build.0 = Release|Any CPU + {A1000008-0000-0000-0000-000000000008}.Release|x64.ActiveCfg = Release|Any CPU + {A1000008-0000-0000-0000-000000000008}.Release|x64.Build.0 = Release|Any CPU + {A1000008-0000-0000-0000-000000000008}.Release|x86.ActiveCfg = Release|Any CPU + {A1000008-0000-0000-0000-000000000008}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/TODO.md b/TODO.md index 7a5a256..ba17fea 100644 --- a/TODO.md +++ b/TODO.md @@ -1223,9 +1223,9 @@ echte DATE-Ganztags-/Mehrtagstermine. **Nachtrag zu 4.3, WebUntis-API-Integration (August 2026):** Zusätzlich zu den unverändert weiterlaufenden iCal-Quellen kann ein persönlicher WebUntis-Zugang im selben Einstellungsreiter hinterlegt werden. Schule, Host, Benutzername und Passwort liegen auf dem Desktop AES-256-GCM- -verschlüsselt; der API-Server persistiert sie nicht, sondern hält je LehrerApp-Benutzer einen -eigenen Client im Arbeitsspeicher. Dessen WebUntis-Sitzung bleibt nach einem Abruf zehn Minuten -offen und wird bei weiterer Nutzung wiederverwendet. +verschlüsselt. Der Desktop verbindet sich direkt mit WebUntis und hält dessen Sitzung nach einem +Abruf zehn Minuten offen. Der LehrerApp-Server besitzt bewusst weder WebUntis-Endpunkte noch +Zugangsdaten; Schülerdaten, Fehlzeiten und der unverschlüsselte CSV-Report passieren ihn nie. - Der Bearbeiten-Tab des Stundenplans lädt eine wählbare Lehrer-/Kalenderwoche, ordnet die WebUntis-Zeitraster auf lokale Stundennummern ab und zeigt vor dem Speichern jede Zuordnung. diff --git a/docker/README.md b/docker/README.md index dece4b3..c121a6c 100644 --- a/docker/README.md +++ b/docker/README.md @@ -85,54 +85,6 @@ 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 0d50d26..7be6e0e 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -13,12 +13,6 @@ 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