using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; namespace LehrerApp.WebUntis; public sealed record UntisOpenElement(int Id, string Name) { public override string ToString() => Name; } public sealed record UntisOpenPeriodsMeta( IReadOnlyList Teachers, IReadOnlyList Classes, IReadOnlyList MyClassIds, DateOnly SchoolYearStart, DateOnly SchoolYearEnd, int? OwnTeacherId); public sealed record UntisOpenPeriod(int Id, DateTime Start, DateTime End, int Hour, string Classes, string Teachers, string Subject, bool TopicMissing, bool AttendanceMissing) { public string When => $"{Start:dd.MM.yyyy} · {Hour}. Stunde · {Start:HH:mm}–{End:HH:mm}"; public string Missing => (TopicMissing, AttendanceMissing) switch { (true, true) => "Thema und Anwesenheitskontrolle fehlen", (true, false) => "Thema fehlt", (false, true) => "Anwesenheitskontrolle fehlt", _ => "Von WebUntis als offen gemeldet" }; } public sealed partial class WebUntisClient { private const string OpenPeriodsPath = "/WebUntis/api/rest/view/v1/classreg/open-periods"; public Task GetOpenPeriodsMetaAsync(int schoolYearId, CancellationToken token) => WithSessionAsync(async session => { var json = await OpenPeriodsRequestAsync(session, schoolYearId, null, token); var year = json.GetProperty("schoolYear"); return new UntisOpenPeriodsMeta(OpenElements(json.GetProperty("teachers")), OpenElements(json.GetProperty("classes")), json.GetProperty("myClassIds").EnumerateArray().Select(x => x.GetInt32()).ToArray(), year.GetProperty("start").GetDateOnly(), year.GetProperty("end").GetDateOnly(), _myTeacherUntisId); }, token); public Task> GetOpenPeriodsAsync(int schoolYearId, int? teacherId, int? classId, DateOnly start, DateOnly end, CancellationToken token) { if ((teacherId is null) == (classId is null) || teacherId is <= 0 || classId is <= 0) throw new ArgumentException("Genau eine gültige Lehrer- oder Klassen-ID ist erforderlich."); if (start > end) throw new ArgumentException("Der Beginn muss vor dem Ende liegen."); var body = new Dictionary { [teacherId.HasValue ? "teacherId" : "classId"] = teacherId ?? classId!.Value, ["filter"] = "TOPIC_OR_ABSENCE_OPEN", ["dateRange"] = new { start = start.ToString("yyyy-MM-dd"), end = end.ToString("yyyy-MM-dd") } }; return WithSessionAsync(async session => ParseOpenPeriods( await OpenPeriodsRequestAsync(session, schoolYearId, body, token)), token); } public static IReadOnlyList ParseOpenPeriods(JsonElement json) => json.GetProperty("periods").EnumerateArray().Select(row => { var p = row.GetProperty("period"); var range = p.GetProperty("dtRange"); return new UntisOpenPeriod(p.GetProperty("id").GetInt32(), range.GetProperty("start").GetDateTime(), range.GetProperty("end").GetDateTime(), p.GetProperty("hr").GetInt32(), string.Join(", ", OpenElements(p.GetProperty("classes")).Select(x => x.Name)), string.Join(", ", OpenElements(p.GetProperty("teachers")).Select(x => x.Name)), p.GetProperty("subject").GetProperty("el").GetProperty("name").GetString() ?? "", row.GetProperty("topicNeeded").GetBoolean() && string.IsNullOrWhiteSpace(row.GetProperty("topicShort").GetString()), row.GetProperty("absCheckNeeded").GetBoolean() && !row.GetProperty("absChecked").GetBoolean()); }).OrderBy(x => x.Start).ToArray(); private static IReadOnlyList OpenElements(JsonElement array) => array.EnumerateArray() .Select(x => x.GetProperty("el")) .Select(x => new UntisOpenElement(x.GetProperty("id").GetInt32(), x.GetProperty("nameShort").GetString() ?? x.GetProperty("name").GetString() ?? "")) .ToArray(); private async Task OpenPeriodsRequestAsync(string session, int schoolYearId, object? body, CancellationToken token) { // The REST UI uses a bearer token issued for the existing WebUntis session. using var tokenRequest = ReportRequest($"https://{GetConfiguration().Host}/WebUntis/api/token/new", session, true); using var tokenResponse = await SendAsync(tokenRequest, TimeSpan.FromSeconds(20), token); if (!tokenResponse.IsSuccessStatusCode) throw new WebUntisException($"WebUntis-Token konnte nicht abgerufen werden: HTTP {(int)tokenResponse.StatusCode}."); var bearer = (await tokenResponse.Content.ReadAsStringAsync(token)).Trim(); if (bearer.StartsWith('"')) bearer = JsonSerializer.Deserialize(bearer) ?? ""; if (string.IsNullOrWhiteSpace(bearer) || bearer.Any(c => !char.IsAsciiLetterOrDigit(c) && c is not '-' and not '_' and not '.')) throw new WebUntisException("WebUntis hat keinen gültigen Zugriffstoken geliefert."); using var request = ReportRequest($"https://{GetConfiguration().Host}{OpenPeriodsPath}{(body is null ? "/meta" : "")}", session, true); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer); request.Headers.Add("x-webuntis-api-school-year-id", schoolYearId.ToString()); if (body is not null) { request.Method = HttpMethod.Post; request.Content = JsonContent.Create(body, options: JsonOptions); } using var response = await SendAsync(request, TimeSpan.FromSeconds(30), token); if (!response.IsSuccessStatusCode) throw new WebUntisException($"Offene Stunden konnten nicht abgerufen werden: HTTP {(int)response.StatusCode}."); return await ReadJsonAsync(response, token); } } internal static class OpenPeriodJsonDates { public static DateOnly GetDateOnly(this JsonElement value) => DateOnly.ParseExact(value.GetString()!, "yyyy-MM-dd"); }