Untis-API von Server zu Client

This commit is contained in:
2026-08-24 22:00:26 +02:00
parent 0f10f754d0
commit 34a9fdf73b
21 changed files with 243 additions and 445 deletions
@@ -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<UntisStudentAbsenceDto> Absences);
/// <summary>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.</summary>
public sealed class WebUntisIntegrationService(HttpClient http, SyncSettingsService syncSettings,
WebUntisSettingsService settings)
/// <summary>Direkter WebUntis-Client des Desktops. Personenbezogene Antworten und der
/// unverschlüsselte CSV-Report passieren zu keinem Zeitpunkt den LehrerApp-Server.</summary>
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<IReadOnlyList<UntisSchoolYearDto>> GetSchoolYearsAsync(CancellationToken token = default) =>
GetAsync<IReadOnlyList<UntisSchoolYearDto>>("/api/webuntis/schoolyears", token);
public Task<IReadOnlyList<UntisSchoolYearDto>> GetSchoolYearsAsync(CancellationToken token = default) => ExecuteAsync(
async client => (IReadOnlyList<UntisSchoolYearDto>)(await client.GetSchoolYearsAsync(token))
.Select(x => new UntisSchoolYearDto(x.UntisId, x.Name, x.StartDate, x.EndDate)).ToList(), token);
public Task<IReadOnlyList<UntisClassDto>> GetClassesAsync(int schoolYearId, CancellationToken token = default) =>
GetAsync<IReadOnlyList<UntisClassDto>>($"/api/webuntis/classes?schoolyearId={schoolYearId}", token);
public Task<IReadOnlyList<UntisClassDto>> GetClassesAsync(int schoolYearId, CancellationToken token = default) => ExecuteAsync(
async client => (IReadOnlyList<UntisClassDto>)(await client.GetClassesAsync(schoolYearId, token))
.Select(x => new UntisClassDto(x.UntisId, x.Name, x.LongName)).ToList(), token);
public Task<IReadOnlyList<UntisTeacherDto>> GetTeachersAsync(CancellationToken token = default) =>
GetAsync<IReadOnlyList<UntisTeacherDto>>("/api/webuntis/teachers", token);
public Task<IReadOnlyList<UntisTeacherDto>> GetTeachersAsync(CancellationToken token = default) => ExecuteAsync(
async client => (IReadOnlyList<UntisTeacherDto>)(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<IReadOnlyList<UntisTimeGridDayDto>> GetTimeGridAsync(CancellationToken token = default) =>
GetAsync<IReadOnlyList<UntisTimeGridDayDto>>("/api/webuntis/timegrid", token);
public Task<IReadOnlyList<UntisTimeGridDayDto>> GetTimeGridAsync(CancellationToken token = default) => ExecuteAsync(
async client => (IReadOnlyList<UntisTimeGridDayDto>)(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<IReadOnlyList<UntisTimetablePeriodDto>> GetTimetableAsync(int teacherId, DateOnly start,
DateOnly end, CancellationToken token = default) => GetAsync<IReadOnlyList<UntisTimetablePeriodDto>>(
$"/api/webuntis/timetable?elementType=teacher&elementId={teacherId}&startDate={Date(start)}&endDate={Date(end)}", token);
DateOnly end, CancellationToken token = default) => ExecuteAsync(async client =>
(IReadOnlyList<UntisTimetablePeriodDto>)(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<UntisStudentReportDto> GetStudentsAsync(string className, CancellationToken token = default) =>
GetAsync<UntisStudentReportDto>($"/api/webuntis/student-report?className={Uri.EscapeDataString(className)}", token);
public Task<UntisStudentReportDto> 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<UntisStudentAbsenceReportDto> GetAbsencesAsync(int studentKey, DateOnly start, DateOnly end,
CancellationToken token = default) => GetAsync<UntisStudentAbsenceReportDto>(
$"/api/webuntis/students/{studentKey}/absences?startDate={Date(start)}&endDate={Date(end)}", token);
private async Task<T> GetAsync<T>(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<T>(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<T> ExecuteAsync<T>(Func<WebUntisClient, Task<T>> 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<WebUntisClient> 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<HttpResponseMessage> 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<UntisEntityDto> Entities(IReadOnlyList<UntisEntity> 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();
}