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 UntisStudentAbsenceDto(int StudentKey, int Date, int StartTime, int EndTime, int AbsentMinutes, bool Checked, string? AbsenceReason, string? ExcuseStatus, int? SubjectId, IReadOnlyList TeacherIds, string? StudentGroup); /// 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> GetAbsencesAsync(DateOnly start, DateOnly end, CancellationToken token = default) => ExecuteAsync(async client => { var absences = await client.GetAbsencesAsync(Date(start), Date(end), token); return (IReadOnlyList)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 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); }