diff --git a/LehrerApp.Api.Tests/WebUntisClientTests.cs b/LehrerApp.Api.Tests/WebUntisClientTests.cs index 61583a7..aee682c 100644 --- a/LehrerApp.Api.Tests/WebUntisClientTests.cs +++ b/LehrerApp.Api.Tests/WebUntisClientTests.cs @@ -84,6 +84,49 @@ public sealed class WebUntisClientTests await client.DisposeAsync(); } + [Fact] + public async Task VollstaendigeLoginUrl_TrenntRegionalenServerUndSchulkennung() + { + var handler = new QueueHandler( + 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 + { + School = "https://arche.webuntis.com/WebUntis/?school=bk-ostvest#/basic/login", + Username = "api-user", + Password = "secret", + })); + + await client.GetSchoolYearsAsync(CancellationToken.None); + + Assert.StartsWith("https://arche.webuntis.com/WebUntis/jsonrpc.do", handler.Requests[0].Uri); + Assert.Contains("school=bk-ostvest", handler.Requests[0].Uri); + await client.DisposeAsync(); + } + + [Fact] + public async Task VollstaendigeServerUrl_WirdOhnePfadVerwendet() + { + var handler = new QueueHandler( + 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 + { + School = "bk-ostvest", + Host = "https://arche.webuntis.com/WebUntis/?school=bk-ostvest#/basic/login", + Username = "api-user", + Password = "secret", + })); + + await client.GetSchoolYearsAsync(CancellationToken.None); + + Assert.StartsWith("https://arche.webuntis.com/WebUntis/jsonrpc.do", handler.Requests[0].Uri); + Assert.Contains("school=bk-ostvest", handler.Requests[0].Uri); + await client.DisposeAsync(); + } + [Fact] public async Task FehlzeitenUndKlassenbuch_WerdenNachSchuelerAbgerufenUndTypisiert() { diff --git a/LehrerApp.Api/Endpoints/Endpoints.cs b/LehrerApp.Api/Endpoints/Endpoints.cs index 03caafa..0fae623 100644 --- a/LehrerApp.Api/Endpoints/Endpoints.cs +++ b/LehrerApp.Api/Endpoints/Endpoints.cs @@ -244,45 +244,79 @@ public static class Endpoints }); } - // ── WebUntis (granulare, zustandslose Abrufe) ───────────────────────────── + // ── WebUntis (granulare Abrufe über benutzergebundene RAM-Sitzungen) ────── 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("/connection", (ClaimsPrincipal user, WebUntisConnectionStore connections) => + { + var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + return uid is null ? Results.Unauthorized() : Results.Ok(connections.GetStatus(uid)); + }); - group.MapGet("/classes", ([FromQuery] int schoolyearId, WebUntisClient client, + 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.")) - : WebUntisResult(() => client.GetClassesAsync(schoolyearId, cancellationToken))); + : WithWebUntisClient(user, connections, fallback, + client => client.GetClassesAsync(schoolyearId, cancellationToken))); - group.MapGet("/teachers", (WebUntisClient client, CancellationToken cancellationToken) => - WebUntisResult(() => client.GetTeachersAsync(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, WebUntisClient client, - CancellationToken cancellationToken) => - WebUntisResult(() => client.GetStudentReportAsync(className, 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", (WebUntisClient client, CancellationToken cancellationToken) => - WebUntisResult(() => client.GetHolidaysAsync(cancellationToken))); + group.MapGet("/holidays", (ClaimsPrincipal user, WebUntisConnectionStore connections, + WebUntisClient fallback, CancellationToken cancellationToken) => + WithWebUntisClient(user, connections, fallback, client => client.GetHolidaysAsync(cancellationToken))); - group.MapGet("/timegrid", (WebUntisClient client, CancellationToken cancellationToken) => - WebUntisResult(() => client.GetTimeGridAsync(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, WebUntisClient client, CancellationToken cancellationToken) => + [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 WebUntisResult(() => - client.GetSubstitutionsAsync(startDate, endDate, departmentId, cancellationToken)); + 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, WebUntisClient client, - CancellationToken cancellationToken) => + [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)) @@ -292,40 +326,51 @@ public static class Endpoints 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)); + 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, WebUntisClient client, CancellationToken cancellationToken) => + [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 WebUntisResult(() => - client.GetStudentAbsencesAsync(studentKey, startDate, endDate, cancellationToken)); + 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, WebUntisClient client, - CancellationToken cancellationToken) => + [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 WebUntisResult(() => - client.GetClassRegisterEntriesAsync(studentId, startDate, endDate, cancellationToken)); + return WithWebUntisClient(user, connections, fallback, + client => client.GetClassRegisterEntriesAsync(studentId, startDate, endDate, cancellationToken)); }); - group.MapGet("/class-register/categories", (WebUntisClient client, - CancellationToken cancellationToken) => - WebUntisResult(() => client.GetClassRegisterCategoriesAsync(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", (WebUntisClient client, - CancellationToken cancellationToken) => - WebUntisResult(() => client.GetClassRegisterCategoryGroupsAsync(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) diff --git a/LehrerApp.Api/Program.cs b/LehrerApp.Api/Program.cs index f4eb9d7..b27d775 100644 --- a/LehrerApp.Api/Program.cs +++ b/LehrerApp.Api/Program.cs @@ -119,6 +119,7 @@ builder.Services.AddHttpClient("webuntis", client => builder.Services.AddSingleton(sp => new WebUntisClient( sp.GetRequiredService().CreateClient("webuntis"), sp.GetRequiredService>())); +builder.Services.AddSingleton(); var app = builder.Build(); app.UseForwardedHeaders(); diff --git a/LehrerApp.Api/WebUntisClient.cs b/LehrerApp.Api/WebUntisClient.cs index ceeff96..7645b51 100644 --- a/LehrerApp.Api/WebUntisClient.cs +++ b/LehrerApp.Api/WebUntisClient.cs @@ -287,7 +287,10 @@ public sealed class WebUntisClient : IAsyncDisposable client = configuration.Client, }, null, cancellationToken); _sessionId = OptionalString(result, "sessionId") - ?? throw new WebUntisException("WebUntis-Login fehlgeschlagen: Keine sessionId erhalten."); + ?? throw new WebUntisException( + $"WebUntis-Login fehlgeschlagen: {configuration.Host} hat für die Schulkennung " + + $"„{configuration.School}“ keine Sitzung geliefert. Bitte insbesondere Server und " + + "Schulkennung mit der WebUntis-Anmeldeseite vergleichen."); } _activeRequests++; @@ -499,24 +502,71 @@ public sealed class WebUntisClient : IAsyncDisposable { var schoolValue = _options.School.Trim(); var username = _options.Username.Trim(); - var password = _options.Password.Trim(); + // Passwörter dürfen führende/abschließende Leerzeichen enthalten und werden deshalb + // anders als Schule/Benutzername nicht normalisiert. + var password = _options.Password; 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)) + var (school, host) = ResolveLocation(schoolValue, _options.Host); + if (host.Contains('/') || !Uri.CheckHostName(host).Equals(UriHostNameType.Dns) || + !(host.Equals("webuntis.com", StringComparison.OrdinalIgnoreCase) || + host.EndsWith(".webuntis.com", StringComparison.OrdinalIgnoreCase))) throw new WebUntisConfigurationException("WEBUNTIS_HOST ist ungültig."); return new Config(school, host, username, password, string.IsNullOrWhiteSpace(_options.Client) ? "LehrerApp" : _options.Client.Trim()); } + private static (string School, string Host) ResolveLocation(string schoolValue, string? hostValue) + { + var school = schoolValue.Trim(); + string? hostFromSchool = null; + 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() + : schoolLocation.Host.Split('.')[0]; + } + + string host; + if (!string.IsNullOrWhiteSpace(hostValue)) + { + if (!TryParseLocation(hostValue, out var hostLocation)) + throw new WebUntisConfigurationException("WEBUNTIS_HOST ist ungültig."); + 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(); + } + else + { + host = hostFromSchool ?? $"{school}.webuntis.com"; + } + + if (school.Length == 0) + throw new WebUntisConfigurationException("WEBUNTIS_SCHOOL fehlt oder ist ungültig."); + return (school, host); + } + + private static bool LooksLikeLocation(string value) => value.Contains('.') || value.Contains('/') || + value.StartsWith("http:", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("https:", StringComparison.OrdinalIgnoreCase); + + private static bool TryParseLocation(string value, out Uri location) + { + var candidate = value.Trim(); + if (!candidate.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && + !candidate.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + candidate = $"https://{candidate}"; + return Uri.TryCreate(candidate, UriKind.Absolute, out location!) && !string.IsNullOrWhiteSpace(location.Host); + } + private string SchoolCookie() => Convert.ToBase64String(Encoding.UTF8.GetBytes(GetConfiguration().School)); private static async Task ReadJsonAsync(HttpResponseMessage response, CancellationToken token) => diff --git a/LehrerApp.Api/WebUntisConnectionStore.cs b/LehrerApp.Api/WebUntisConnectionStore.cs new file mode 100644 index 0000000..744de09 --- /dev/null +++ b/LehrerApp.Api/WebUntisConnectionStore.cs @@ -0,0 +1,75 @@ +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/WebUntisSettingsServiceTests.cs b/LehrerApp.Desktop.Tests/WebUntisSettingsServiceTests.cs index 03ce7a4..8a86b1d 100644 --- a/LehrerApp.Desktop.Tests/WebUntisSettingsServiceTests.cs +++ b/LehrerApp.Desktop.Tests/WebUntisSettingsServiceTests.cs @@ -86,4 +86,39 @@ public sealed class WebUntisSettingsServiceTests Assert.Equal(at, reloaded.LastSyncAt); Assert.Equal("3 Vertretungen erkannt", reloaded.LastSyncStatus); } + + [Fact] + public void ApiCredentials_SindVerschluesseltUndPersistieren() + { + var path = BuildTempPath(); + var credentials = new WebUntisCredentials("schule", "mese.webuntis.com", "lehrkraft", "sehr geheim"); + var service = new WebUntisSettingsService(path); + + service.SetApiCredentials(credentials); + service.SetTeacherUntisId(4711); + + var raw = File.ReadAllText(Path.Combine(path, "webuntis-settings.json")); + var reloaded = new WebUntisSettingsService(path); + Assert.DoesNotContain("sehr geheim", raw); + Assert.True(reloaded.ApiIsConfigured); + Assert.Equal(credentials, reloaded.GetApiCredentials()); + Assert.Equal(4711, reloaded.TeacherUntisId); + } + + [Fact] + public void ClearApiCredentials_LaesstIcalKonfigurationUnveraendert() + { + var service = new WebUntisSettingsService(BuildTempPath()); + service.SetIcalUrl(SampleUrl); + service.SetApiCredentials(new("schule", "", "user", "password")); + service.SetTeacherUntisId(12); + + service.ClearApiCredentials(); + + Assert.False(service.ApiIsConfigured); + Assert.Null(service.GetApiCredentials()); + Assert.Null(service.TeacherUntisId); + Assert.True(service.IsConfigured); + Assert.Equal(SampleUrl, service.GetIcalUrl()); + } } diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index 4938975..3a87ede 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -223,6 +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)); // 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/Services/WebUntisIntegrationService.cs b/LehrerApp.Desktop/Services/WebUntisIntegrationService.cs new file mode 100644 index 0000000..3f4e805 --- /dev/null +++ b/LehrerApp.Desktop/Services/WebUntisIntegrationService.cs @@ -0,0 +1,140 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; + +namespace LehrerApp.Desktop.Services; + +public sealed class WebUntisIntegrationException(string message) : Exception(message); + +public sealed record UntisSchoolYearDto(int UntisId, string Name, int StartDate, int EndDate); +public sealed record UntisClassDto(int UntisId, string Name, string? LongName); +public sealed record UntisTeacherDto(int UntisId, string Name, string? ForeName, string? LongName, string? Title, + bool Active, IReadOnlyList DepartmentUntisIds) +{ + public string DisplayName => string.IsNullOrWhiteSpace(LongName) + ? Name + : $"{ForeName} {LongName} ({Name})".Trim(); +} +public sealed record UntisEntityDto(int Id, string Name, int? OriginalId, string? OriginalName, string? ExternalKey); +public sealed record UntisTimeUnitDto(string Name, int StartTime, int EndTime); +public sealed record UntisTimeGridDayDto(int Day, IReadOnlyList TimeUnits); +public sealed record UntisTimetablePeriodDto(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); +public sealed record UntisStudentAddressDto(string? Email, string? Mobile, string? Phone, string? City, + string? PostCode, string? Street); +public sealed record UntisStudentDto(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, UntisStudentAddressDto Address, string? AttributeIL); +public sealed record UntisStudentReportDto(int Count, string? ClassNameFilter, IReadOnlyList Students); +public sealed record UntisStudentAbsenceDto(int StudentKey, int Date, int StartTime, int EndTime, int AbsentMinutes, + bool Checked, string? AbsenceReason, string? ExcuseStatus, int? SubjectId, IReadOnlyList TeacherIds, + string? StudentGroup); +public sealed record UntisStudentAbsenceReportDto(int StudentKey, int StartDate, int EndDate, int EntryCount, + int AbsentMinutes, IReadOnlyList Absences); + +/// 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) +{ + public bool IsAvailable => syncSettings.IsLoggedIn && !string.IsNullOrWhiteSpace(syncSettings.ServerUrl) + && settings.ApiIsConfigured; + + public async Task ConnectAsync(WebUntisCredentials credentials, CancellationToken token = default) + { + using var response = await SendAsync(HttpMethod.Post, "/api/webuntis/connection", new + { + credentials.School, + Host = string.IsNullOrWhiteSpace(credentials.Host) ? null : credentials.Host, + credentials.Username, + credentials.Password, + }, token); + await EnsureSuccessAsync(response); + } + + public async Task DisconnectAsync(CancellationToken token = default) + { + using var response = await SendAsync(HttpMethod.Delete, "/api/webuntis/connection", null, token); + await EnsureSuccessAsync(response); + } + + public Task> GetSchoolYearsAsync(CancellationToken token = default) => + GetAsync>("/api/webuntis/schoolyears", token); + + public Task> GetClassesAsync(int schoolYearId, CancellationToken token = default) => + GetAsync>($"/api/webuntis/classes?schoolyearId={schoolYearId}", token); + + public Task> GetTeachersAsync(CancellationToken token = default) => + GetAsync>("/api/webuntis/teachers", token); + + public Task> GetTimeGridAsync(CancellationToken token = default) => + GetAsync>("/api/webuntis/timegrid", 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); + + public Task GetStudentsAsync(string className, CancellationToken token = default) => + GetAsync($"/api/webuntis/student-report?className={Uri.EscapeDataString(className)}", 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) + { + 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."); + } + + private async Task EnsureConnectedAsync(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."); + 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()!); + } + catch (JsonException) { } + throw new WebUntisIntegrationException("Der WebUntis-Abruf ist fehlgeschlagen."); + } + + private static int Date(DateOnly date) => date.Year * 10000 + date.Month * 100 + date.Day; +} diff --git a/LehrerApp.Desktop/Services/WebUntisSettingsService.cs b/LehrerApp.Desktop/Services/WebUntisSettingsService.cs index 65f7350..1af427e 100644 --- a/LehrerApp.Desktop/Services/WebUntisSettingsService.cs +++ b/LehrerApp.Desktop/Services/WebUntisSettingsService.cs @@ -9,8 +9,12 @@ internal class WebUntisSettingsConfig public string? EncryptedIcalUrl { get; set; } public DateTime? LastSyncAt { get; set; } public string LastSyncStatus { get; set; } = ""; + public string? EncryptedApiCredentials { get; set; } + public int? TeacherUntisId { get; set; } } +public sealed record WebUntisCredentials(string School, string Host, string Username, string Password); + /// /// Einstellungen für den WebUntis-iCal-Abgleich (Nutzer-Feedback, siehe TODO.md). Liegt wie /// AiSettingsService/SyncSettingsService in LehrerApp.Desktop statt LehrerApp.Core, da die @@ -30,6 +34,8 @@ public class WebUntisSettingsService public bool Enabled => _config.Enabled; public bool IsConfigured => _config.EncryptedIcalUrl is not null; + public bool ApiIsConfigured => _config.EncryptedApiCredentials is not null; + public int? TeacherUntisId => _config.TeacherUntisId; public DateTime? LastSyncAt => _config.LastSyncAt; public string LastSyncStatus => _config.LastSyncStatus; @@ -63,6 +69,29 @@ public class WebUntisSettingsService Save(); } + public void SetApiCredentials(WebUntisCredentials credentials) + { + _config.EncryptedApiCredentials = SyncCrypto.EncryptObject(credentials, _urlKey); + Save(); + } + + public WebUntisCredentials? GetApiCredentials() => _config.EncryptedApiCredentials is null + ? null + : SyncCrypto.DecryptObject(_config.EncryptedApiCredentials, _urlKey); + + public void ClearApiCredentials() + { + _config.EncryptedApiCredentials = null; + _config.TeacherUntisId = null; + Save(); + } + + public void SetTeacherUntisId(int? teacherUntisId) + { + _config.TeacherUntisId = teacherUntisId; + Save(); + } + public void SetLastSync(DateTime at, string status) { _config.LastSyncAt = at; diff --git a/LehrerApp.Desktop/ViewModels/Groups/WebUntisAbsenceComparisonViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/WebUntisAbsenceComparisonViewModel.cs new file mode 100644 index 0000000..7fe212c --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Groups/WebUntisAbsenceComparisonViewModel.cs @@ -0,0 +1,131 @@ +using System.Collections.Concurrent; +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Importing; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using LehrerApp.Desktop.Services; + +namespace LehrerApp.Desktop.ViewModels.Groups; + +public partial class WebUntisAbsenceRow : ObservableObject +{ + public required string StudentName { get; init; } + public required DateOnly Date { get; init; } + public required string TimeLabel { get; init; } + public required string UntisStatus { get; init; } + public required string LocalStatus { get; init; } + public required AttendanceStatus TargetStatus { get; init; } + public required Guid StudentId { get; init; } + public required Guid? SessionId { get; init; } + public string? Reason { get; init; } + public string DateLabel => Date.ToString("dd.MM.yyyy"); + public bool CanApply => SessionId is not null; + [ObservableProperty] private bool _selected; +} + +public partial class WebUntisAbsenceComparisonViewModel : ObservableObject +{ + private readonly LearningGroup _group; + private readonly WebUntisIntegrationService _untis; + private readonly IStudentRepository _students; + private readonly IParticipationSessionRepository _sessions; + private readonly IParticipationRepository _participation; + + public ObservableCollection Rows { get; } = []; + [ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddMonths(-2); + [ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now; + [ObservableProperty] private string _status = "Zeitraum wählen und Fehlzeiten laden."; + [ObservableProperty] private bool _busy; + + public WebUntisAbsenceComparisonViewModel(LearningGroup group, WebUntisIntegrationService untis, + IStudentRepository students, IParticipationSessionRepository sessions, + IParticipationRepository participation) + { + _group = group; _untis = untis; _students = students; _sessions = sessions; + _participation = participation; + } + + [RelayCommand] + private async Task Load() + { + var start = DateOnly.FromDateTime(StartDate.LocalDateTime); + var end = DateOnly.FromDateTime(EndDate.LocalDateTime); + if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; } + Busy = true; Rows.Clear(); + try + { + var courseStudents = _students.GetByGroup(_group.Id); + var localSessions = _sessions.GetByGroup(_group.Id) + .Where(x => x.Date >= start && x.Date <= end).GroupBy(x => x.Date) + .ToDictionary(x => x.Key, x => x.First()); + var loaded = new ConcurrentBag<(Student Student, UntisStudentAbsenceDto Absence)>(); + var linked = courseStudents.Select(student => (Student: student, Key: StudentKey(student))) + .Where(x => x.Key is not null).ToList(); + await Parallel.ForEachAsync(linked, new ParallelOptions { MaxDegreeOfParallelism = 4 }, async (item, token) => + { + var report = await _untis.GetAbsencesAsync(item.Key!.Value, start, end, token); + foreach (var absence in report.Absences) loaded.Add((item.Student, absence)); + }); + + foreach (var item in loaded.OrderBy(x => x.Absence.Date).ThenBy(x => x.Student.FullName)) + { + if (!TryDate(item.Absence.Date, out var date)) continue; + localSessions.TryGetValue(date, out var session); + var entry = session is null ? null : _participation.GetBySessionAndStudent(session.Id, item.Student.Id); + var target = MapStatus(item.Absence.ExcuseStatus); + Rows.Add(new WebUntisAbsenceRow + { + StudentName = item.Student.FullName, StudentId = item.Student.Id, Date = date, + TimeLabel = $"{Time(item.Absence.StartTime)}–{Time(item.Absence.EndTime)}", + UntisStatus = DisplayUntisStatus(item.Absence), + LocalStatus = entry?.Attendance?.ToString() ?? (session is null ? "keine lokale Stunde" : "nicht erfasst"), + TargetStatus = target, SessionId = session?.Id, Reason = item.Absence.AbsenceReason, + Selected = session is not null && entry?.Attendance != target, + }); + } + var withoutKey = courseStudents.Count - linked.Count; + Status = $"{Rows.Count} Untis-Fehlzeiten gefunden; {Rows.Count(x => x.CanApply)} sind einer lokalen Kursstunde zuordenbar." + + (withoutKey > 0 ? $" {withoutKey} Schüler haben noch keine WebUntis-Kennung." : ""); + } + catch (WebUntisIntegrationException ex) { Status = ex.Message; } + finally { Busy = false; } + } + + [RelayCommand] + private void Apply() + { + var selected = Rows.Where(x => x.Selected && x.SessionId is not null).ToList(); + foreach (var row in selected) + { + var entry = _participation.GetBySessionAndStudent(row.SessionId!.Value, row.StudentId) + ?? new ParticipationEntry { SessionId = row.SessionId.Value, StudentId = row.StudentId }; + entry.Attendance = row.TargetStatus; + entry.UpdatedAt = DateTime.UtcNow; + _participation.Save(entry); + } + Status = $"{selected.Count} Anwesenheitsstatus übernommen."; + foreach (var row in selected) row.Selected = false; + } + + private static int? StudentKey(Student student) + { + student.ExternalIds ??= []; + return student.ExternalIds.TryGetValue(StudentImportFormats.MasterDataCsv.Value, out var value) + && int.TryParse(value, out var key) ? key : null; + } + private static AttendanceStatus MapStatus(string? value) + { + var text = value?.Trim().ToLowerInvariant() ?? ""; + if (text.Contains("unexcused") || text.Contains("unentschuldigt") || text.Contains("nicht entschuldigt")) + return AttendanceStatus.Unexcused; + if (text.Contains("excused") || text.Contains("entschuldigt")) return AttendanceStatus.Excused; + return AttendanceStatus.ExcusePending; + } + private static string DisplayUntisStatus(UntisStudentAbsenceDto absence) => + string.Join(" · ", new[] { absence.ExcuseStatus, absence.AbsenceReason }.Where(x => !string.IsNullOrWhiteSpace(x))) + is { Length: > 0 } text ? text : "offen"; + private static bool TryDate(int value, out DateOnly date) => DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date); + private static string Time(int value) => $"{value / 100:00}:{value % 100:00}"; +} diff --git a/LehrerApp.Desktop/ViewModels/Groups/WebUntisClassSelectionViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/WebUntisClassSelectionViewModel.cs new file mode 100644 index 0000000..d23d09a --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Groups/WebUntisClassSelectionViewModel.cs @@ -0,0 +1,56 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Desktop.Services; + +namespace LehrerApp.Desktop.ViewModels.Groups; + +public partial class WebUntisClassSelectionViewModel(WebUntisIntegrationService untis) : ObservableObject +{ + public ObservableCollection SchoolYears { get; } = []; + public ObservableCollection Classes { get; } = []; + [ObservableProperty] private UntisSchoolYearDto? _selectedSchoolYear; + [ObservableProperty] private UntisClassDto? _selectedClass; + [ObservableProperty] private string _status = ""; + [ObservableProperty] private bool _busy; + public bool CanConfirm => SelectedClass is not null && !Busy; + + partial void OnSelectedClassChanged(UntisClassDto? value) => OnPropertyChanged(nameof(CanConfirm)); + partial void OnBusyChanged(bool value) => OnPropertyChanged(nameof(CanConfirm)); + + public async Task InitializeAsync() + { + Busy = true; + try + { + foreach (var year in (await untis.GetSchoolYearsAsync()).OrderByDescending(x => x.StartDate)) + SchoolYears.Add(year); + SelectedSchoolYear = SchoolYears.FirstOrDefault(x => x.StartDate <= Today() && x.EndDate >= Today()) + ?? SchoolYears.FirstOrDefault(); + await LoadClasses(); + } + catch (WebUntisIntegrationException ex) { Status = ex.Message; } + finally { Busy = false; } + } + + [RelayCommand] + private async Task LoadClasses() + { + if (SelectedSchoolYear is null) return; + Busy = true; Classes.Clear(); SelectedClass = null; + try + { + foreach (var entry in (await untis.GetClassesAsync(SelectedSchoolYear.UntisId)).OrderBy(x => x.Name)) + Classes.Add(entry); + Status = Classes.Count == 0 ? "Keine Klassen gefunden." : $"{Classes.Count} Klassen gefunden."; + } + catch (WebUntisIntegrationException ex) { Status = ex.Message; } + finally { Busy = false; } + } + + private static int Today() + { + var date = DateOnly.FromDateTime(DateTime.Today); + return date.Year * 10000 + date.Month * 100 + date.Day; + } +} diff --git a/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs b/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs index fb49be4..1d93148 100644 --- a/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs @@ -103,6 +103,7 @@ public partial class TimetableViewModel : ObservableObject public Func? OnEditSlot { get; set; } public Action? OnNavigateToGroup { get; set; } public Func? OnAddSubstitution { get; set; } + public Func? OnImportWebUntisTimetable { get; set; } public Action? OnNavigateToSettings { get; set; } public Func? OnOpenLessonViewer { get; set; } public Func? OnOpenTeachingMode { get; set; } @@ -143,6 +144,14 @@ public partial class TimetableViewModel : ObservableObject Load(); } + [RelayCommand] + private async Task ImportWebUntisTimetable() + { + if (OnImportWebUntisTimetable is null) return; + await OnImportWebUntisTimetable(); + Load(); + } + public void Load() { var today = DateOnly.FromDateTime(DateTime.Today); diff --git a/LehrerApp.Desktop/ViewModels/Planning/WebUntisTimetableImportViewModel.cs b/LehrerApp.Desktop/ViewModels/Planning/WebUntisTimetableImportViewModel.cs new file mode 100644 index 0000000..367a9dd --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Planning/WebUntisTimetableImportViewModel.cs @@ -0,0 +1,159 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using LehrerApp.Desktop.Services; + +namespace LehrerApp.Desktop.ViewModels.Planning; + +public sealed record WebUntisGroupOption(Guid Id, string DisplayName); + +public partial class WebUntisTimetableRow : ObservableObject +{ + public required DayOfWeek Weekday { get; init; } + public required int PeriodNumber { get; init; } + public required string TimeLabel { get; init; } + public required string UntisLabel { get; init; } + public required string SuggestedGroupName { get; init; } + public string? SubjectName { get; init; } + public string? Room { get; init; } + public ObservableCollection GroupOptions { get; } = []; + [ObservableProperty] private WebUntisGroupOption? _selectedGroup; + public string WeekdayLabel => Weekday switch + { + DayOfWeek.Monday => "Mo", DayOfWeek.Tuesday => "Di", DayOfWeek.Wednesday => "Mi", + DayOfWeek.Thursday => "Do", DayOfWeek.Friday => "Fr", _ => Weekday.ToString()[..2], + }; +} + +public partial class WebUntisTimetableImportViewModel : ObservableObject +{ + private readonly WebUntisIntegrationService _untis; + private readonly WebUntisSettingsService _settings; + private readonly ITimetableSlotRepository _slots; + private readonly IGroupRepository _groups; + + public ObservableCollection Teachers { get; } = []; + public ObservableCollection Rows { get; } = []; + [ObservableProperty] private UntisTeacherDto? _selectedTeacher; + [ObservableProperty] private DateTimeOffset _weekDate = DateTimeOffset.Now; + [ObservableProperty] private string _status = "Lehrkraft auswählen und Untis-Woche laden."; + [ObservableProperty] private bool _busy; + public bool Saved { get; private set; } + public Func>? OnCreateGroup { get; set; } + + public WebUntisTimetableImportViewModel(WebUntisIntegrationService untis, WebUntisSettingsService settings, + ITimetableSlotRepository slots, IGroupRepository groups) + { + _untis = untis; _settings = settings; _slots = slots; _groups = groups; + } + + public async Task InitializeAsync() + { + Busy = true; + try + { + foreach (var teacher in (await _untis.GetTeachersAsync()).Where(x => x.Active).OrderBy(x => x.DisplayName)) + Teachers.Add(teacher); + SelectedTeacher = Teachers.FirstOrDefault(x => x.UntisId == _settings.TeacherUntisId) + ?? Teachers.FirstOrDefault(); + Status = Teachers.Count == 0 ? "WebUntis hat keine Lehrkräfte geliefert." : "Bereit zum Laden."; + } + catch (WebUntisIntegrationException ex) { Status = ex.Message; } + finally { Busy = false; } + } + + [RelayCommand] + private async Task Load() + { + if (SelectedTeacher is null) { Status = "Bitte eine Lehrkraft auswählen."; return; } + Busy = true; Rows.Clear(); + try + { + var selected = DateOnly.FromDateTime(WeekDate.LocalDateTime); + var monday = selected.AddDays(-(((int)selected.DayOfWeek + 6) % 7)); + var periods = await _untis.GetTimetableAsync(SelectedTeacher.UntisId, monday, monday.AddDays(6)); + var grid = await _untis.GetTimeGridAsync(); + var groupOptions = BuildGroupOptions(); + var localSlots = _slots.GetAll().ToDictionary(x => (x.Weekday, x.PeriodNumber)); + + foreach (var period in periods.Where(x => string.IsNullOrWhiteSpace(x.Code) || x.Code != "cancelled") + .GroupBy(x => (x.Date, x.StartTime, x.EndTime, x.StudentGroup, + Class: string.Join("/", x.Classes.Select(c => c.Name)), + Subject: string.Join("/", x.Subjects.Select(s => s.Name)), + Room: string.Join("/", x.Rooms.Select(r => r.Name)))) + .Select(x => x.First()).OrderBy(x => x.Date).ThenBy(x => x.StartTime)) + { + if (!TryDate(period.Date, out var date) || date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday) + continue; + var dayGrid = grid.FirstOrDefault(x => x.Day == UntisDay(date.DayOfWeek)); + var number = dayGrid?.TimeUnits.ToList().FindIndex(x => x.StartTime == period.StartTime) + 1 ?? 0; + if (number <= 0) continue; + var className = period.Classes.FirstOrDefault()?.Name ?? ""; + var subject = period.Subjects.FirstOrDefault()?.Name; + var suggested = !string.IsNullOrWhiteSpace(period.StudentGroup) ? period.StudentGroup! : className; + var row = new WebUntisTimetableRow + { + Weekday = date.DayOfWeek, PeriodNumber = number, + TimeLabel = $"{Time(period.StartTime)}–{Time(period.EndTime)}", + UntisLabel = string.Join(" · ", new[] { subject, suggested, period.Rooms.FirstOrDefault()?.Name } + .Where(x => !string.IsNullOrWhiteSpace(x))), + SuggestedGroupName = suggested, SubjectName = subject, + Room = period.Rooms.FirstOrDefault()?.Name, + }; + foreach (var option in groupOptions) row.GroupOptions.Add(option); + if (localSlots.TryGetValue((row.Weekday, row.PeriodNumber), out var existing)) + row.SelectedGroup = groupOptions.FirstOrDefault(x => x.Id == existing.GroupId); + row.SelectedGroup ??= BestMatch(groupOptions, suggested, className, subject); + Rows.Add(row); + } + _settings.SetTeacherUntisId(SelectedTeacher.UntisId); + Status = Rows.Count == 0 ? "In dieser Woche wurde kein Unterricht gefunden." + : $"{Rows.Count} regelmäßige Termine gefunden. Zuordnung prüfen und übernehmen."; + } + catch (WebUntisIntegrationException ex) { Status = ex.Message; } + finally { Busy = false; } + } + + [RelayCommand] + private async Task CreateGroup(WebUntisTimetableRow row) + { + if (OnCreateGroup is null) return; + var group = await OnCreateGroup(row); + if (group is null) return; + var option = new WebUntisGroupOption(group.Id, group.Name); + foreach (var item in Rows.Where(x => x.SuggestedGroupName == row.SuggestedGroupName)) + { + item.GroupOptions.Add(option); + item.SelectedGroup = option; + } + } + + [RelayCommand] + private void Save() + { + var selected = Rows.Where(x => x.SelectedGroup is not null).ToList(); + foreach (var row in selected) + { + var existing = _slots.GetAll().FirstOrDefault(x => x.Weekday == row.Weekday && x.PeriodNumber == row.PeriodNumber); + var slot = existing ?? new TimetableSlot { Weekday = row.Weekday, PeriodNumber = row.PeriodNumber }; + slot.GroupId = row.SelectedGroup!.Id; + slot.Room = string.IsNullOrWhiteSpace(row.Room) ? null : row.Room; + _slots.Save(slot); + } + Saved = true; + Status = $"{selected.Count} Stundenplan-Einträge übernommen."; + } + + private List BuildGroupOptions() => _groups.GetAll().OrderBy(x => x.Name) + .Select(x => new WebUntisGroupOption(x.Id, x.Name)).ToList(); + + private static WebUntisGroupOption? BestMatch(IEnumerable options, params string?[] terms) => + options.FirstOrDefault(x => terms.Any(term => !string.IsNullOrWhiteSpace(term) && + (x.DisplayName.Equals(term, StringComparison.OrdinalIgnoreCase) || + x.DisplayName.Contains(term, StringComparison.OrdinalIgnoreCase)))); + private static int UntisDay(DayOfWeek day) => day == DayOfWeek.Sunday ? 7 : (int)day; + private static bool TryDate(int value, out DateOnly date) => DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date); + private static string Time(int value) => $"{value / 100:00}:{value % 100:00}"; +} diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs index c05da80..fb23578 100644 --- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs @@ -210,6 +210,13 @@ public partial class SettingsViewModel : ObservableObject [ObservableProperty] private string _untisUrlError = ""; [ObservableProperty] private string _untisStatusDisplay = ""; [ObservableProperty] private bool _untisFetchBusy; + [ObservableProperty] private bool _untisApiIsConfigured; + [ObservableProperty] private string _untisSchool = ""; + [ObservableProperty] private string _untisHost = ""; + [ObservableProperty] private string _untisUsername = ""; + [ObservableProperty] private string _untisPassword = ""; + [ObservableProperty] private string _untisApiStatus = ""; + [ObservableProperty] private bool _untisApiBusy; public Func? OnReviewUntisMapping { get; set; } // ── Schulweiter Jahresplan (ClassyPlan-iCal) ───────────────────────────── @@ -299,6 +306,7 @@ public partial class SettingsViewModel : ObservableObject private readonly AiSettingsService _aiSettings; private readonly AiPlanningService _aiPlanning; private readonly WebUntisSettingsService _untisSettings; + private readonly WebUntisIntegrationService? _untisIntegration; private readonly UntisSyncService? _untisSync; private readonly AnnualPlanSettingsService _annualPlanSettings; private readonly AnnualPlanSyncService? _annualPlanSync; @@ -330,7 +338,7 @@ public partial class SettingsViewModel : ObservableObject AppearanceSettingsService appearance, TrashViewModel trashTab, SnapshotService? snapshotService = null, SyncEngine? syncEngine = null, UntisSyncService? untisSync = null, AnnualPlanSyncService? annualPlanSync = null, - SchoolWeatherService? schoolWeather = null) + SchoolWeatherService? schoolWeather = null, WebUntisIntegrationService? untisIntegration = null) { _logger = logger; _syncKeyRecovery = syncKeyRecovery; @@ -360,6 +368,7 @@ public partial class SettingsViewModel : ObservableObject _aiSettings = aiSettings; _aiPlanning = aiPlanning; _untisSettings = untisSettings; + _untisIntegration = untisIntegration; _untisSync = untisSync; _annualPlanSettings = annualPlanSettings; _annualPlanSync = annualPlanSync; @@ -542,6 +551,60 @@ public partial class SettingsViewModel : ObservableObject UntisStatusDisplay = _untisSettings.LastSyncAt is { } at ? $"Letzter Abgleich: {at.ToLocalTime():dd.MM.yyyy HH:mm} — {_untisSettings.LastSyncStatus}" : "Noch kein Abgleich durchgeführt."; + UntisApiIsConfigured = _untisSettings.ApiIsConfigured; + if (_untisSettings.GetApiCredentials() is { } credentials) + { + UntisSchool = credentials.School; + UntisHost = credentials.Host; + UntisUsername = credentials.Username; + UntisApiStatus = $"API-Zugang für {credentials.Username} ist lokal verschlüsselt gespeichert."; + } + } + + [RelayCommand] + private async Task UntisSaveApi() + { + UntisApiStatus = ""; + if (_untisIntegration is null) + { + UntisApiStatus = "Die WebUntis-Integration ist nicht verfügbar."; + return; + } + if (string.IsNullOrWhiteSpace(UntisSchool) || string.IsNullOrWhiteSpace(UntisUsername) || + string.IsNullOrWhiteSpace(UntisPassword)) + { + UntisApiStatus = "Schule, Benutzername und Passwort sind erforderlich."; + return; + } + UntisApiBusy = true; + try + { + var credentials = new WebUntisCredentials(UntisSchool.Trim(), UntisHost.Trim(), + UntisUsername.Trim(), UntisPassword); + await _untisIntegration.ConnectAsync(credentials); + _untisSettings.SetApiCredentials(credentials); + UntisPassword = ""; + UntisApiIsConfigured = true; + UntisApiStatus = "Anmeldung erfolgreich. Die WebUntis-Session bleibt bei Nutzung bis zu 10 Minuten offen."; + } + catch (WebUntisIntegrationException ex) { UntisApiStatus = ex.Message; } + finally { UntisApiBusy = false; } + } + + [RelayCommand] + private async Task UntisRemoveApi() + { + UntisApiBusy = true; + try { if (_untisIntegration is not null) await _untisIntegration.DisconnectAsync(); } + catch (WebUntisIntegrationException) { /* lokale Zugangsdaten trotzdem sicher entfernen */ } + finally + { + _untisSettings.ClearApiCredentials(); + UntisPassword = ""; + UntisApiIsConfigured = false; + UntisApiStatus = "WebUntis-API-Zugang entfernt."; + UntisApiBusy = false; + } } [RelayCommand] diff --git a/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml b/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml index e2be1d7..1c45520 100644 --- a/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml +++ b/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml @@ -25,6 +25,9 @@