using LehrerApp.WebUntis; 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 UntisLessonAbsenceDto(string StudentName, int Date, int AbsentPeriods, int UnexcusedAbsentPeriods, int AbsentMinutes, int UnexcusedAbsentMinutes, int? StartTime, int? EndTime, string? Reason, int? ExternKey, bool ExternKeyInParentheses, string? HandledOn, bool Counts); public sealed record UntisClassRegisterEventDto(string ClassName, int Date, string? Subject, string StudentName, string? CategoryName, string? CategoryGroup, string? Text); public sealed record UntisForeignClassRegisterEventDto(string ClassName, int Date, string? Subject, string StudentName, string? TeacherUsername, string? CategoryName, string? CategoryGroup, string? Text); public sealed record UntisClassAbsenceEntryDto(string StudentName, int? ExternKey, string ClassName, int Date, int AbsentPeriods, int AbsentMinutes, string? TeacherUsernames, string? Subject, string? AbsenceReason, string? Note, int? EntryId, string? HandledOn, bool Counts, string? ExcuseNote, int? PeriodNumber, string? Status, bool CountsAsFullDay); /// 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 { private readonly SemaphoreSlim _clientGate = new(1, 1); private WebUntisClient? _client; public bool IsAvailable => settings.ApiIsConfigured; public async Task ConnectAsync(WebUntisCredentials credentials, CancellationToken token = default) { var candidate = CreateClient(credentials); try { await candidate.GetSchoolYearsAsync(token); } catch (Exception exception) when (IsUntisError(exception)) { await candidate.DisposeAsync(); throw Translate(exception); } WebUntisClient? previous; await _clientGate.WaitAsync(token).ConfigureAwait(false); try { previous = _client; _client = candidate; } finally { _clientGate.Release(); } if (previous is not null) await previous.DisposeAsync().ConfigureAwait(false); } public async Task DisconnectAsync(CancellationToken token = default) { WebUntisClient? previous; await _clientGate.WaitAsync(token).ConfigureAwait(false); try { previous = _client; _client = null; } finally { _clientGate.Release(); } if (previous is not null) await previous.DisposeAsync().ConfigureAwait(false); } 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) => 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) => 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) => 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) => 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) => 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> GetLessonAbsencesAsync(int lessonId, DateOnly start, DateOnly end, CancellationToken token = default) => ExecuteAsync(async client => { var absences = await client.GetLessonAbsencesAsync(lessonId, Date(start), Date(end), token); return (IReadOnlyList)absences.Select(x => new UntisLessonAbsenceDto( x.StudentName, x.Date, x.AbsentPeriods, x.UnexcusedAbsentPeriods, x.AbsentMinutes, x.UnexcusedAbsentMinutes, x.StartTime, x.EndTime, x.Reason, x.ExternKey, x.ExternKeyInParentheses, x.HandledOn, x.Counts)).ToList(); }, token); // "-alle-"-Bericht, hier auf eigene Einträge gefiltert (Benutzer == eigener WebUntis-Login) - Einträge // anderer Lehrkräfte zu Schülern der eigenen Klasse gehören zu einem eigenständigen, noch nicht // gebauten "Klassenlehrer"-Feature (siehe TODO.md), nicht zum reinen Dokumentations-Abgleich. public Task> GetOwnClassRegisterEventsAsync(DateOnly start, DateOnly end, CancellationToken token = default) => ExecuteAsync(async client => { var ownUsername = settings.GetApiCredentials()?.Username; var entries = await client.GetClassRegisterEventsReportAsync(Date(start), Date(end), token); return (IReadOnlyList)entries .Where(x => ownUsername is not null && string.Equals(x.TeacherUsername, ownUsername, StringComparison.OrdinalIgnoreCase)) .Select(x => new UntisClassRegisterEventDto(x.ClassName, x.Date, x.Subject, x.StudentName, x.CategoryName, x.CategoryGroup, x.Text)) .ToList(); }, token); // Spiegelbild von GetOwnClassRegisterEventsAsync für das "Klassenlehrer"-Feature (siehe TODO.md): // dieselben "-alle-"-Rohdaten, aber Einträge anderer Lehrkräfte statt der eigenen, zusätzlich auf // die übergebene Klasse eingeschränkt (der "-alle-"-Bericht liefert sonst auch andere Klassen mit, // sobald eine Lehrkraft mehrere Klassen unterrichtet). public Task> GetForeignClassRegisterEventsAsync( string className, DateOnly start, DateOnly end, CancellationToken token = default) => ExecuteAsync(async client => { var ownUsername = settings.GetApiCredentials()?.Username; var entries = await client.GetClassRegisterEventsReportAsync(Date(start), Date(end), token); return (IReadOnlyList)entries .Where(x => string.Equals(x.ClassName, className, StringComparison.OrdinalIgnoreCase) // Nicht einfach "!=" gegen ownUsername: wäre ownUsername unbekannt (null), würde // ein ebenfalls fehlender TeacherUsername sonst fälschlich als "eigen" erkannt // (Equals(null,null)==true) und rausgefiltert statt als fremd angezeigt zu werden. && !(ownUsername is not null && string.Equals(x.TeacherUsername, ownUsername, StringComparison.OrdinalIgnoreCase))) .Select(x => new UntisForeignClassRegisterEventDto(x.ClassName, x.Date, x.Subject, x.StudentName, x.TeacherUsername, x.CategoryName, x.CategoryGroup, x.Text)) .ToList(); }, token); // Fehlzeiten der ganzen Klasse über alle Fächer, Ergänzung zu GetForeignClassRegisterEventsAsync // für das "Klassenlehrer"-Feature. Der Bericht adressiert die Klasse über "KL"+getKlassen-ID statt // eines Namens, siehe ResolveClassIdAsync. public Task> GetClassAbsencesAsync(string className, DateOnly start, DateOnly end, CancellationToken token = default) => ExecuteAsync(async client => { var classId = await ResolveClassIdAsync(client, className, token); var entries = await client.GetClassAbsencesAsync(classId, Date(start), Date(end), token); return (IReadOnlyList)entries.Select(x => new UntisClassAbsenceEntryDto( x.StudentName, x.ExternKey, x.ClassName, x.Date, x.AbsentPeriods, x.AbsentMinutes, x.TeacherUsernames, x.Subject, x.AbsenceReason, x.Note, x.EntryId, x.HandledOn, x.Counts, x.ExcuseNote, x.PeriodNumber, x.Status, x.CountsAsFullDay)) .ToList(); }, token); // "KL" + getKlassen-ID, dasselbe Element-Kürzel-Schema wie kl/te/su/ro bei anderen Berichten // (siehe Entities(...) unten). Unbestätigt (kein offizieller Report-Parameter dokumentiert), aber // aus einem echten Browser-Request rückgeschlossen - schlägt die Zuordnung fehl, liefert der // Bericht erkennbar leere/falsche Daten statt eines stillen Fehlers. private static async Task ResolveClassIdAsync(WebUntisClient client, string className, CancellationToken token) { var years = await client.GetSchoolYearsAsync(token); var today = Date(DateOnly.FromDateTime(DateTime.Today)); var year = years.FirstOrDefault(y => y.StartDate <= today && y.EndDate >= today) ?? years.OrderByDescending(y => y.StartDate).FirstOrDefault() ?? throw new WebUntisIntegrationException("WebUntis hat kein Schuljahr geliefert."); var classes = await client.GetClassesAsync(year.UntisId, token); var matches = classes.Where(c => string.Equals(c.Name, className, StringComparison.OrdinalIgnoreCase)).ToList(); return matches.Count switch { 1 => $"KL{matches[0].UntisId}", 0 => throw new WebUntisIntegrationException($"WebUntis kennt keine Klasse namens \"{className}\"."), _ => throw new WebUntisIntegrationException( $"Mehrere WebUntis-Klassen heißen \"{className}\" - bitte manuell prüfen."), }; } 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 GetClientAsync(CancellationToken token) { if (_client is not null) return _client; await _clientGate.WaitAsync(token); try { if (_client is not null) return _client; var credentials = settings.GetApiCredentials() ?? throw new WebUntisIntegrationException("Bitte zuerst die WebUntis-Anmeldedaten einrichten."); return _client = CreateClient(credentials); } 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().ConfigureAwait(false); }