Untis API Integration

This commit is contained in:
2026-08-24 21:52:00 +02:00
parent 5841d96c5b
commit 0f10f754d0
26 changed files with 1237 additions and 58 deletions
@@ -0,0 +1,140 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
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<int> 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<UntisTimeUnitDto> 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<UntisEntityDto> Classes, IReadOnlyList<UntisEntityDto> Teachers,
IReadOnlyList<UntisEntityDto> Subjects, IReadOnlyList<UntisEntityDto> 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<UntisStudentDto> Students);
public sealed record UntisStudentAbsenceDto(int StudentKey, int Date, int StartTime, int EndTime, int AbsentMinutes,
bool Checked, string? AbsenceReason, string? ExcuseStatus, int? SubjectId, IReadOnlyList<int> TeacherIds,
string? StudentGroup);
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)
{
public bool IsAvailable => syncSettings.IsLoggedIn && !string.IsNullOrWhiteSpace(syncSettings.ServerUrl)
&& settings.ApiIsConfigured;
public async Task ConnectAsync(WebUntisCredentials credentials, CancellationToken token = default)
{
using var response = await SendAsync(HttpMethod.Post, "/api/webuntis/connection", new
{
credentials.School,
Host = string.IsNullOrWhiteSpace(credentials.Host) ? null : credentials.Host,
credentials.Username,
credentials.Password,
}, token);
await EnsureSuccessAsync(response);
}
public async Task DisconnectAsync(CancellationToken token = default)
{
using var response = await SendAsync(HttpMethod.Delete, "/api/webuntis/connection", null, token);
await EnsureSuccessAsync(response);
}
public Task<IReadOnlyList<UntisSchoolYearDto>> GetSchoolYearsAsync(CancellationToken token = default) =>
GetAsync<IReadOnlyList<UntisSchoolYearDto>>("/api/webuntis/schoolyears", token);
public Task<IReadOnlyList<UntisClassDto>> GetClassesAsync(int schoolYearId, CancellationToken token = default) =>
GetAsync<IReadOnlyList<UntisClassDto>>($"/api/webuntis/classes?schoolyearId={schoolYearId}", token);
public Task<IReadOnlyList<UntisTeacherDto>> GetTeachersAsync(CancellationToken token = default) =>
GetAsync<IReadOnlyList<UntisTeacherDto>>("/api/webuntis/teachers", token);
public Task<IReadOnlyList<UntisTimeGridDayDto>> GetTimeGridAsync(CancellationToken token = default) =>
GetAsync<IReadOnlyList<UntisTimeGridDayDto>>("/api/webuntis/timegrid", 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);
public Task<UntisStudentReportDto> GetStudentsAsync(string className, CancellationToken token = default) =>
GetAsync<UntisStudentReportDto>($"/api/webuntis/student-report?className={Uri.EscapeDataString(className)}", 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)
{
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.");
}
private async Task EnsureConnectedAsync(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.");
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()!);
}
catch (JsonException) { }
throw new WebUntisIntegrationException("Der WebUntis-Abruf ist fehlgeschlagen.");
}
private static int Date(DateOnly date) => date.Year * 10000 + date.Month * 100 + date.Day;
}