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 -1
View File
@@ -223,7 +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));
services.AddSingleton(sp => new WebUntisIntegrationService(new HttpClient(), 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.
@@ -9,6 +9,7 @@
<ProjectReference Include="..\LehrerApp.Core\LehrerApp.Core.csproj" />
<ProjectReference Include="..\LehrerApp.Data\LehrerApp.Data.csproj" />
<ProjectReference Include="..\LehrerApp.Sync\LehrerApp.Sync.csproj" />
<ProjectReference Include="..\LehrerApp.WebUntis\LehrerApp.WebUntis.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" />
@@ -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();
}
@@ -971,7 +971,7 @@
<TextBlock Text="WebUntis-API" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Ermöglicht Stundenplan-, Schüler- und Fehlzeitenabgleich. Die Zugangsdaten werden nur auf diesem Gerät verschlüsselt gespeichert; der LehrerApp-Server hält sie und die WebUntis-Sitzung nur im Arbeitsspeicher."/>
Text="Ermöglicht Stundenplan-, Schüler- und Fehlzeitenabgleich direkt zwischen diesem Gerät und WebUntis. Zugangsdaten werden lokal verschlüsselt gespeichert; weder sie noch Schülerdaten oder CSV-Reports passieren den LehrerApp-Server."/>
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto" ColumnSpacing="8" RowSpacing="8">
<StackPanel Grid.Row="0" Grid.Column="0" Spacing="4">
<TextBlock Text="Schule" FontSize="12" Opacity="0.7"/>