Vorarbeit: WebUntis-API

This commit is contained in:
2026-08-24 12:14:36 +02:00
parent a30aab0fa5
commit 5841d96c5b
10 changed files with 1377 additions and 0 deletions
+10
View File
@@ -7,3 +7,13 @@ JWT_SECRET=hier-einen-langen-zufaelligen-wert-eintragen
# Identifiziert die Installation gegenüber Nominatim/DWD. Bei eigener Domain bitte anpassen. # Identifiziert die Installation gegenüber Nominatim/DWD. Bei eigener Domain bitte anpassen.
GEOCODING_USER_AGENT=LehrerApp-Server/1.0 (+https://example.org) GEOCODING_USER_AGENT=LehrerApp-Server/1.0 (+https://example.org)
# Optional: granularer WebUntis-Zugriff über die Server-API. Der technische Benutzer muss die
# benötigten Leserechte besitzen. Benutzer mit aktivierter 2FA funktionieren nicht mit der alten
# JSON-RPC-Schnittstelle. WEBUNTIS_HOST ist nur nötig, wenn der Host nicht <schule>.webuntis.com ist.
WEBUNTIS_SCHOOL=meine-schule
WEBUNTIS_HOST=meine-schule.webuntis.com
WEBUNTIS_USER=technischer-benutzer
WEBUNTIS_PASSWORD=geheimes-passwort
WEBUNTIS_CLIENT=LehrerApp
WEBUNTIS_SESSION_IDLE_MINUTES=10
+168
View File
@@ -0,0 +1,168 @@
using System.Net;
using System.Text;
using Microsoft.Extensions.Options;
using Xunit;
namespace LehrerApp.Api.Tests;
public sealed class WebUntisClientTests
{
[Fact]
public async Task AufeinanderfolgendeAbrufe_VerwendenDieselbeSessionBisZumDispose()
{
var latin1 = Encoding.Latin1.GetBytes(
"id\texternKey\tklasse.name\tlongName\tforeName\r\n" +
"1\t10\t7a\tMüller\tAda\r\n" +
"2\t20\t8b\tMeier\tBerta\r\n");
var handler = new QueueHandler(
Json("{\"jsonrpc\":\"2.0\",\"result\":{\"sessionId\":\"session-1\"}}"),
Json("{\"data\":{\"finished\":true,\"reportParams\":\"foo=bar\"}}"),
new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(latin1),
},
Json("{\"jsonrpc\":\"2.0\",\"result\":[{\"id\":7,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"),
Json("{\"jsonrpc\":\"2.0\",\"result\":{}}"));
var client = CreateClient(handler);
var result = await client.GetStudentReportAsync("7a", CancellationToken.None);
var schoolYears = await client.GetSchoolYearsAsync(CancellationToken.None);
var student = Assert.Single(result.Students);
Assert.Equal("Ada Müller", student.DisplayName);
Assert.Equal("7a", result.ClassNameFilter);
Assert.Equal("2026/27", Assert.Single(schoolYears).Name);
Assert.Equal(4, handler.Requests.Count);
Assert.Contains("jsonrpc.do?school=meine-schule", handler.Requests[0].Uri);
Assert.Contains("\"method\":\"authenticate\"", handler.Requests[0].Body);
Assert.Contains("reports.do?name=Student", handler.Requests[1].Uri);
Assert.Contains("schoolname=\"_", handler.Requests[1].Cookie);
Assert.EndsWith("reports.do?foo=bar", handler.Requests[2].Uri);
Assert.Contains("\"method\":\"getSchoolyears\"", handler.Requests[3].Body);
Assert.Single(handler.Requests,
request => request.Body.Contains("\"method\":\"authenticate\""));
await client.DisposeAsync();
Assert.Equal(5, handler.Requests.Count);
Assert.Contains("\"method\":\"logout\"", handler.Requests[4].Body);
}
[Fact]
public async Task GetTimetableAsync_SendetElementUndZeitraumAlsSeparateAbfrage()
{
var handler = new QueueHandler(
Json("{\"result\":{\"sessionId\":\"s\"}}"),
Json("{\"result\":[{\"id\":99,\"date\":20260824,\"startTime\":800,\"endTime\":845," +
"\"kl\":[{\"id\":4,\"name\":\"7a\"}],\"te\":[],\"su\":[],\"ro\":[]}]}"),
Json("{\"result\":{}}"));
var client = CreateClient(handler);
var periods = await client.GetTimetableAsync(UntisTimetableElementType.Teacher, 42,
20260824, 20260828, CancellationToken.None);
var period = Assert.Single(periods);
Assert.Equal(99, period.Id);
Assert.Equal("7a", Assert.Single(period.Classes).Name);
Assert.Contains("\"method\":\"getTimetable\"", handler.Requests[1].Body);
Assert.Contains("\"element\":{\"id\":42,\"type\":2}", handler.Requests[1].Body);
Assert.Contains("\"startDate\":20260824", handler.Requests[1].Body);
await client.DisposeAsync();
}
[Fact]
public async Task FehlendeKonfiguration_BrichtVorHttpRequestAb()
{
var handler = new QueueHandler();
var client = new WebUntisClient(new HttpClient(handler), Options.Create(new WebUntisOptions()));
await Assert.ThrowsAsync<WebUntisConfigurationException>(
() => client.GetSchoolYearsAsync(CancellationToken.None));
Assert.Empty(handler.Requests);
await client.DisposeAsync();
}
[Fact]
public async Task FehlzeitenUndKlassenbuch_WerdenNachSchuelerAbgerufenUndTypisiert()
{
var handler = new QueueHandler(
Json("{\"result\":{\"sessionId\":\"s\"}}"),
Json("{\"result\":{\"periodsWithAbsences\":[" +
"{\"studentId\":9001,\"date\":20260901,\"startTime\":800,\"endTime\":845," +
"\"absentTime\":45,\"checked\":true,\"absenceReason\":\"Krank\"," +
"\"excuseStatus\":\"entschuldigt\",\"subjectId\":12,\"teacherIds\":[\"7\"]}," +
"{\"studentId\":9999,\"date\":20260901,\"startTime\":800,\"endTime\":845," +
"\"absentTime\":45,\"checked\":false}]}}"),
Json("{\"result\":[{\"studentid\":9001,\"surname\":\"Müller\",\"forname\":\"Ada\"," +
"\"date\":20260902,\"subject\":\"MA\",\"categoryId\":3,\"reason\":\"Material\"," +
"\"text\":\"Buch vergessen\"}]}"),
Json("{\"result\":[{\"id\":3,\"name\":\"Material\",\"longName\":\"Material vergessen\"," +
"\"groupId\":2}]}"),
Json("{\"result\":[{\"id\":2,\"name\":\"Organisation\"}]}"),
Json("{\"result\":{}}"));
var client = CreateClient(handler);
var report = await client.GetStudentAbsencesAsync(9001, 20260801, 20270731,
CancellationToken.None);
var entries = await client.GetClassRegisterEntriesAsync(17, 20260801, 20270731,
CancellationToken.None);
var categories = await client.GetClassRegisterCategoriesAsync(CancellationToken.None);
var groups = await client.GetClassRegisterCategoryGroupsAsync(CancellationToken.None);
var absence = Assert.Single(report.Absences);
Assert.Equal(45, report.AbsentMinutes);
Assert.Equal("entschuldigt", absence.ExcuseStatus);
Assert.Equal(7, Assert.Single(absence.TeacherIds));
var entry = Assert.Single(entries);
Assert.Equal("Ada Müller", entry.DisplayName);
Assert.Equal("Buch vergessen", entry.Text);
Assert.Equal(3, Assert.Single(categories).Id);
Assert.Equal("Organisation", Assert.Single(groups).Name);
Assert.Contains("\"method\":\"getTimetableWithAbsences\"", handler.Requests[1].Body);
Assert.Contains("\"method\":\"getClassregEvents\"", handler.Requests[2].Body);
Assert.Contains("\"id\":17,\"type\":5", handler.Requests[2].Body);
Assert.Single(handler.Requests,
request => request.Body.Contains("\"method\":\"authenticate\""));
await client.DisposeAsync();
Assert.Contains("\"method\":\"logout\"", handler.Requests[^1].Body);
}
private static WebUntisClient CreateClient(HttpMessageHandler handler) => new(
new HttpClient(handler),
Options.Create(new WebUntisOptions
{
School = "meine-schule",
Host = "meine-schule.webuntis.com",
Username = "api-user",
Password = "secret",
Client = "tests",
}));
private static HttpResponseMessage Json(string json) => new(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
};
private sealed class QueueHandler(params HttpResponseMessage[] responses) : HttpMessageHandler
{
private readonly Queue<HttpResponseMessage> _responses = new(responses);
public List<CapturedRequest> Requests { get; } = [];
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken)
{
Requests.Add(new CapturedRequest(
request.RequestUri?.ToString() ?? "",
request.Content is null ? "" : await request.Content.ReadAsStringAsync(cancellationToken),
string.Join("; ", request.Headers.TryGetValues("Cookie", out var cookies) ? cookies : [])));
return _responses.Count > 0
? _responses.Dequeue()
: throw new InvalidOperationException("Keine Testantwort mehr vorhanden.");
}
}
private sealed record CapturedRequest(string Uri, string Body, string Cookie);
}
@@ -0,0 +1,48 @@
using Xunit;
namespace LehrerApp.Api.Tests;
public sealed class WebUntisStudentReportParserTests
{
[Fact]
public void Parse_UebernimmtAlleFelderUndDeutscheDatumswerte()
{
const string report = "\uFEFFid\texternKey\tklasse.name\tname\tlongName\tforeName\tgender\tbirthDate\tentryDate\texitDate\ttext\tmedicalReportDuty\tschulpflicht\tmajority\tadress.email\tadress.mobile\tadress.phone\tadress.city\tadress.postCode\tadress.street\tattribute.iL\r\n" +
"17\t9001\t10a\tMUST\tMustermann\tErika\tw\t03.02.2010\t01.08.2021\t\t\"Zeile 1\nZeile 2\"\tja\tja\tnein\terika@example.org\t0151\t030\tBerlin\t10115\tTestweg 1\tIL-A\r\n";
var student = Assert.Single(WebUntisStudentReportParser.Parse(report));
Assert.Equal(17, student.UntisId);
Assert.Equal(9001, student.ExternKey);
Assert.Equal("10a", student.ClassName);
Assert.Equal("Erika Mustermann", student.DisplayName);
Assert.Equal(20100203, student.BirthDate);
Assert.Equal(20210801, student.EntryDate);
Assert.Null(student.ExitDate);
Assert.Equal("Zeile 1\nZeile 2", student.Text);
Assert.Equal("erika@example.org", student.Address.Email);
Assert.Equal("IL-A", student.AttributeIL);
}
[Fact]
public void Parse_BehandeltEscapteAnfuehrungszeichenUndLeereZeilen()
{
const string report = "id\texternKey\tklasse.name\tname\r\n" +
"1\t2\t5b\t\"MUS\"\"T\"\r\n\t\t\t\r\n";
var student = Assert.Single(WebUntisStudentReportParser.Parse(report));
Assert.Equal("MUS\"T", student.Name);
Assert.Equal("MUS\"T", student.DisplayName);
}
[Fact]
public void Parse_LehntUngueltigePflichtIdAb()
{
const string report = "id\texternKey\tklasse.name\r\nkeine-zahl\t1\t5b\r\n";
var error = Assert.Throws<InvalidDataException>(() => WebUntisStudentReportParser.Parse(report));
Assert.Contains("id", error.Message);
}
}
+127
View File
@@ -1,6 +1,7 @@
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using System.Text; using System.Text;
using System.Globalization;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Sync.Models; using LehrerApp.Sync.Models;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -243,6 +244,132 @@ public static class Endpoints
}); });
} }
// ── WebUntis (granulare, zustandslose Abrufe) ─────────────────────────────
public static void MapWebUntisEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/webuntis").RequireAuthorization();
group.MapGet("/schoolyears", (WebUntisClient client, CancellationToken cancellationToken) =>
WebUntisResult(() => client.GetSchoolYearsAsync(cancellationToken)));
group.MapGet("/classes", ([FromQuery] int schoolyearId, WebUntisClient client,
CancellationToken cancellationToken) => schoolyearId <= 0
? Task.FromResult<IResult>(Results.BadRequest("schoolyearId muss größer als 0 sein."))
: WebUntisResult(() => client.GetClassesAsync(schoolyearId, cancellationToken)));
group.MapGet("/teachers", (WebUntisClient client, CancellationToken cancellationToken) =>
WebUntisResult(() => client.GetTeachersAsync(cancellationToken)));
group.MapGet("/student-report", ([FromQuery] string? className, WebUntisClient client,
CancellationToken cancellationToken) =>
WebUntisResult(() => client.GetStudentReportAsync(className, cancellationToken)));
group.MapGet("/holidays", (WebUntisClient client, CancellationToken cancellationToken) =>
WebUntisResult(() => client.GetHolidaysAsync(cancellationToken)));
group.MapGet("/timegrid", (WebUntisClient client, CancellationToken cancellationToken) =>
WebUntisResult(() => client.GetTimeGridAsync(cancellationToken)));
group.MapGet("/substitutions", ([FromQuery] int startDate, [FromQuery] int endDate,
[FromQuery] int? departmentId, WebUntisClient client, CancellationToken cancellationToken) =>
{
if (!ValidDateRange(startDate, endDate, 31, out var error))
return Task.FromResult<IResult>(Results.BadRequest(error));
return WebUntisResult(() =>
client.GetSubstitutionsAsync(startDate, endDate, departmentId, cancellationToken));
});
group.MapGet("/timetable", ([FromQuery] string elementType, [FromQuery] int elementId,
[FromQuery] int startDate, [FromQuery] int endDate, WebUntisClient client,
CancellationToken cancellationToken) =>
{
if (!Enum.TryParse<UntisTimetableElementType>(elementType, true, out var parsedType) ||
!Enum.IsDefined(parsedType))
return Task.FromResult<IResult>(Results.BadRequest(
"elementType muss class, teacher, subject, room oder student sein."));
if (elementId <= 0)
return Task.FromResult<IResult>(Results.BadRequest("elementId muss größer als 0 sein."));
if (!ValidDateRange(startDate, endDate, 62, out var error))
return Task.FromResult<IResult>(Results.BadRequest(error));
return WebUntisResult(() =>
client.GetTimetableAsync(parsedType, elementId, startDate, endDate, cancellationToken));
});
group.MapGet("/students/{studentKey:int}/absences", (int studentKey, [FromQuery] int startDate,
[FromQuery] int endDate, WebUntisClient client, CancellationToken cancellationToken) =>
{
if (studentKey <= 0)
return Task.FromResult<IResult>(Results.BadRequest("studentKey muss größer als 0 sein."));
if (!ValidDateRange(startDate, endDate, 400, out var error))
return Task.FromResult<IResult>(Results.BadRequest(error));
return WebUntisResult(() =>
client.GetStudentAbsencesAsync(studentKey, startDate, endDate, cancellationToken));
});
group.MapGet("/students/{studentId:int}/class-register-entries", (int studentId,
[FromQuery] int startDate, [FromQuery] int endDate, WebUntisClient client,
CancellationToken cancellationToken) =>
{
if (studentId <= 0)
return Task.FromResult<IResult>(Results.BadRequest("studentId muss größer als 0 sein."));
if (!ValidDateRange(startDate, endDate, 400, out var error))
return Task.FromResult<IResult>(Results.BadRequest(error));
return WebUntisResult(() =>
client.GetClassRegisterEntriesAsync(studentId, startDate, endDate, cancellationToken));
});
group.MapGet("/class-register/categories", (WebUntisClient client,
CancellationToken cancellationToken) =>
WebUntisResult(() => client.GetClassRegisterCategoriesAsync(cancellationToken)));
group.MapGet("/class-register/category-groups", (WebUntisClient client,
CancellationToken cancellationToken) =>
WebUntisResult(() => client.GetClassRegisterCategoryGroupsAsync(cancellationToken)));
}
private static async Task<IResult> WebUntisResult<T>(Func<Task<T>> operation)
{
try { return Results.Ok(await operation()); }
catch (WebUntisConfigurationException exception)
{
return Results.Problem(exception.Message, statusCode: StatusCodes.Status503ServiceUnavailable);
}
catch (WebUntisException exception)
{
return Results.Problem(exception.Message, statusCode: StatusCodes.Status502BadGateway);
}
catch (InvalidDataException exception)
{
return Results.Problem($"Der WebUntis-Report ist ungültig: {exception.Message}",
statusCode: StatusCodes.Status502BadGateway);
}
}
private static bool ValidDateRange(int startDate, int endDate, int maximumDays, out string error)
{
error = "";
if (!DateOnly.TryParseExact(startDate.ToString(CultureInfo.InvariantCulture), "yyyyMMdd",
CultureInfo.InvariantCulture, DateTimeStyles.None, out var start) ||
!DateOnly.TryParseExact(endDate.ToString(CultureInfo.InvariantCulture), "yyyyMMdd",
CultureInfo.InvariantCulture, DateTimeStyles.None, out var end))
{
error = "startDate und endDate müssen gültige Datumswerte im Format yyyyMMdd sein.";
return false;
}
if (end < start)
{
error = "endDate darf nicht vor startDate liegen.";
return false;
}
if (end.DayNumber - start.DayNumber + 1 > maximumDays)
{
error = $"Der Zeitraum darf höchstens {maximumDays} Tage umfassen.";
return false;
}
return true;
}
private static Dictionary<string, string[]> ValidateLocation(SchoolLocationRequest request) private static Dictionary<string, string[]> ValidateLocation(SchoolLocationRequest request)
{ {
var errors = new Dictionary<string, string[]>(); var errors = new Dictionary<string, string[]>();
+23
View File
@@ -97,6 +97,28 @@ builder.Services.AddHttpClient("dwd", client =>
}); });
builder.Services.AddSingleton(sp => new DwdWeatherService( builder.Services.AddSingleton(sp => new DwdWeatherService(
sp.GetRequiredService<IHttpClientFactory>().CreateClient("dwd"))); sp.GetRequiredService<IHttpClientFactory>().CreateClient("dwd")));
builder.Services.Configure<WebUntisOptions>(options =>
{
builder.Configuration.GetSection("WebUntis").Bind(options);
options.School = builder.Configuration["WEBUNTIS_SCHOOL"] ?? options.School;
options.Host = builder.Configuration["WEBUNTIS_HOST"] ?? options.Host;
options.Username = builder.Configuration["WEBUNTIS_USER"] ?? options.Username;
options.Password = builder.Configuration["WEBUNTIS_PASSWORD"] ?? options.Password;
options.Client = builder.Configuration["WEBUNTIS_CLIENT"] ?? options.Client;
if (int.TryParse(builder.Configuration["WEBUNTIS_SESSION_IDLE_MINUTES"], out var idleMinutes))
options.SessionIdleTimeoutMinutes = idleMinutes;
});
builder.Services.AddHttpClient("webuntis", client =>
{
// Die einzelnen WebUntis-Schritte besitzen eigene Timeouts; insbesondere ein asynchron
// erzeugter Schülerreport darf länger als der HttpClient-Standardtimeout pollen.
client.Timeout = Timeout.InfiniteTimeSpan;
});
// Der Client hält eine WebUntis-Session über mehrere API-Aufrufe hinweg. Deshalb muss seine
// Lebensdauer der Serveranwendung entsprechen und darf nicht pro HTTP-Anfrage neu beginnen.
builder.Services.AddSingleton(sp => new WebUntisClient(
sp.GetRequiredService<IHttpClientFactory>().CreateClient("webuntis"),
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<WebUntisOptions>>()));
var app = builder.Build(); var app = builder.Build();
app.UseForwardedHeaders(); app.UseForwardedHeaders();
@@ -112,5 +134,6 @@ app.MapSnapshotEndpoints();
app.MapReadableSnapshotEndpoints(); app.MapReadableSnapshotEndpoints();
app.MapPlainSyncEndpoints(); app.MapPlainSyncEndpoints();
app.MapSchoolWeatherEndpoints(); app.MapSchoolWeatherEndpoints();
app.MapWebUntisEndpoints();
app.Run(); app.Run();
return 0; return 0;
+630
View File
@@ -0,0 +1,630 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Options;
namespace LehrerApp.Api;
public sealed class WebUntisClient : IAsyncDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
private readonly HttpClient _http;
private readonly WebUntisOptions _options;
private readonly SemaphoreSlim _sessionGate = new(1, 1);
private readonly Timer _sessionExpiryTimer;
private string? _sessionId;
private DateTimeOffset _sessionExpiresAt;
private int _activeRequests;
private bool _disposed;
public WebUntisClient(HttpClient http, IOptions<WebUntisOptions> options)
{
_http = http;
_options = options.Value;
_sessionExpiryTimer = new Timer(
static state => _ = ((WebUntisClient)state!).CloseExpiredSessionAsync(),
this,
Timeout.InfiniteTimeSpan,
Timeout.InfiniteTimeSpan);
}
public Task<IReadOnlyList<UntisSchoolYear>> GetSchoolYearsAsync(CancellationToken cancellationToken) =>
WithSessionAsync(async sessionId =>
{
var entries = RequireArray(await RpcAsync("getSchoolyears", new { }, sessionId, cancellationToken),
"WebUntis hat keine gültige Schuljahr-Liste geliefert.");
return (IReadOnlyList<UntisSchoolYear>)entries.Select(entry =>
{
var id = RequiredInt(entry, "id");
var name = OptionalString(entry, "name")
?? throw new WebUntisException($"WebUntis-Schuljahr {id} hat keinen Namen.");
return new UntisSchoolYear(id, name, RequiredInt(entry, "startDate"), RequiredInt(entry, "endDate"));
}).ToList();
}, cancellationToken);
public Task<IReadOnlyList<UntisClass>> GetClassesAsync(int schoolYearId, CancellationToken cancellationToken) =>
WithSessionAsync(async sessionId =>
{
var entries = RequireArray(await RpcAsync("getKlassen", new { schoolyearId = schoolYearId }, sessionId,
cancellationToken), "WebUntis hat keine gültige Klassen-Liste geliefert.");
return (IReadOnlyList<UntisClass>)entries.Select(entry =>
{
var id = RequiredInt(entry, "id");
var name = OptionalString(entry, "name")
?? throw new WebUntisException($"WebUntis-Klasse {id} hat keinen Namen.");
return new UntisClass(id, name, OptionalString(entry, "longName"),
OptionalString(entry, "foreColor"), OptionalString(entry, "backColor"),
OptionalInt(entry, "did"), OptionalInt(entry, "teacher1"), OptionalInt(entry, "teacher2"));
}).ToList();
}, cancellationToken);
public Task<IReadOnlyList<UntisTeacher>> GetTeachersAsync(CancellationToken cancellationToken) =>
WithSessionAsync(async sessionId =>
{
var entries = RequireArray(await RpcAsync("getTeachers", new { }, sessionId, cancellationToken),
"WebUntis hat keine gültige Lehrer-Liste geliefert.");
return (IReadOnlyList<UntisTeacher>)entries.Select(entry =>
{
var id = RequiredInt(entry, "id");
var name = OptionalString(entry, "name")
?? throw new WebUntisException($"WebUntis-Lehrer {id} hat kein Kürzel.");
var departments = TryProperty(entry, "dids", out var dids) && dids.ValueKind == JsonValueKind.Array
? dids.EnumerateArray().Select(OptionalInt).Where(value => value is not null)
.Select(value => value!.Value).Distinct().Order().ToList()
: [];
return new UntisTeacher(id, name, OptionalString(entry, "foreName"),
OptionalString(entry, "longName"), OptionalString(entry, "title"),
OptionalBoolean(entry, "active"), departments);
}).ToList();
}, cancellationToken);
public Task<IReadOnlyList<UntisHoliday>> GetHolidaysAsync(CancellationToken cancellationToken) =>
WithSessionAsync(async sessionId =>
{
var entries = RequireArray(await RpcAsync("getHolidays", new { }, sessionId, cancellationToken),
"WebUntis hat keine gültige Ferien-Liste geliefert.");
return (IReadOnlyList<UntisHoliday>)entries.Select(entry =>
{
var id = RequiredInt(entry, "id");
var name = OptionalString(entry, "name")
?? throw new WebUntisException($"WebUntis-Ferien {id} haben keinen Namen.");
return new UntisHoliday(id, name, OptionalString(entry, "longName"),
RequiredInt(entry, "startDate"), RequiredInt(entry, "endDate"));
}).ToList();
}, cancellationToken);
public Task<IReadOnlyList<UntisTimeGridDay>> GetTimeGridAsync(CancellationToken cancellationToken) =>
WithSessionAsync(async sessionId =>
{
var entries = RequireArray(await RpcAsync("getTimegridUnits", new { }, sessionId, cancellationToken),
"WebUntis hat keine gültigen Zeitraster-Daten geliefert.");
return (IReadOnlyList<UntisTimeGridDay>)entries.Select(entry =>
{
var units = TryProperty(entry, "timeUnits", out var values) && values.ValueKind == JsonValueKind.Array
? values.EnumerateArray().Select(unit => new UntisTimeUnit(
OptionalString(unit, "name") ?? "", RequiredInt(unit, "startTime"), RequiredInt(unit, "endTime")))
.ToList()
: [];
return new UntisTimeGridDay(RequiredInt(entry, "day"), units, entry.Clone());
}).ToList();
}, cancellationToken);
public Task<UntisStudentReport> GetStudentReportAsync(string? classNameFilter,
CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
{
var reportData = await RequestReportAsync(sessionId, cancellationToken)
?? await PollReportAsync(sessionId, cancellationToken);
var reportText = await FetchReportTextAsync(sessionId, reportData, cancellationToken);
var allStudents = WebUntisStudentReportParser.Parse(reportText);
var normalizedFilter = string.IsNullOrWhiteSpace(classNameFilter) ? null : classNameFilter.Trim();
var students = normalizedFilter is null
? allStudents
: allStudents.Where(student => string.Equals(student.ClassName, normalizedFilter,
StringComparison.OrdinalIgnoreCase)).ToList();
return new UntisStudentReport(students.Count, normalizedFilter, students);
}, cancellationToken);
public Task<IReadOnlyList<UntisSubstitution>> GetSubstitutionsAsync(int startDate, int endDate,
int? departmentId, CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
{
var entries = RequireArray(await RpcAsync("getSubstitutions", new
{
startDate,
endDate,
departmentId = departmentId ?? 0,
}, sessionId, cancellationToken), "WebUntis hat keine gültigen Vertretungsplan-Daten geliefert.");
return (IReadOnlyList<UntisSubstitution>)entries.Select(entry => new UntisSubstitution(
OptionalString(entry, "type") ?? "unknown",
OptionalInt(entry, "lsid"),
OptionalString(entry, "lstype"),
RequiredInt(entry, "date"),
RequiredInt(entry, "startTime"),
RequiredInt(entry, "endTime"),
OptionalString(entry, "txt"),
Entities(entry, "kl"),
Entities(entry, "te"),
Entities(entry, "su"),
Entities(entry, "ro"),
ParseReschedule(entry),
entry.Clone())).ToList();
}, cancellationToken);
public Task<IReadOnlyList<UntisTimetablePeriod>> GetTimetableAsync(UntisTimetableElementType elementType,
int elementId, int startDate, int endDate, CancellationToken cancellationToken) =>
WithSessionAsync(async sessionId =>
{
var options = new
{
element = new { id = elementId, type = (int)elementType },
startDate,
endDate,
showBooking = false,
showInfo = true,
showSubstText = true,
showLsText = true,
showLsNumber = true,
showStudentgroup = true,
};
var entries = RequireArray(await RpcAsync("getTimetable", new { options }, sessionId, cancellationToken),
"WebUntis hat keine gültigen Stundenplan-Daten geliefert.");
return (IReadOnlyList<UntisTimetablePeriod>)entries.Select(entry => new UntisTimetablePeriod(
RequiredInt(entry, "id"), RequiredInt(entry, "date"), RequiredInt(entry, "startTime"),
RequiredInt(entry, "endTime"), OptionalString(entry, "code"), OptionalString(entry, "activityType"),
OptionalString(entry, "info"), OptionalString(entry, "lstext"), OptionalString(entry, "substText"),
OptionalString(entry, "sg"), Entities(entry, "kl"), Entities(entry, "te"), Entities(entry, "su"),
Entities(entry, "ro"), entry.Clone())).ToList();
}, cancellationToken);
public Task<UntisStudentAbsenceReport> GetStudentAbsencesAsync(int studentKey, int startDate, int endDate,
CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
{
var result = await RpcAsync("getTimetableWithAbsences", new
{
options = new { startDate, endDate },
}, sessionId, cancellationToken);
if (!TryProperty(result, "periodsWithAbsences", out var periods) || periods.ValueKind != JsonValueKind.Array)
throw new WebUntisException("WebUntis hat keine gültigen Fehlzeiten-Daten geliefert.");
var absences = periods.EnumerateArray()
.Where(entry => OptionalInt(entry, "studentId") == studentKey)
.Select(entry => new UntisStudentAbsence(
RequiredInt(entry, "studentId"),
RequiredInt(entry, "date"),
RequiredInt(entry, "startTime"),
RequiredInt(entry, "endTime"),
OptionalInt(entry, "absentTime") ?? 0,
OptionalBoolean(entry, "checked"),
OptionalString(entry, "absenceReason"),
OptionalString(entry, "excuseStatus"),
OptionalInt(entry, "subjectId"),
IntArray(entry, "teacherIds"),
OptionalString(entry, "studentGroup"),
entry.Clone()))
.OrderBy(entry => entry.Date)
.ThenBy(entry => entry.StartTime)
.ToList();
return new UntisStudentAbsenceReport(studentKey, startDate, endDate, absences.Count,
absences.Sum(entry => entry.AbsentMinutes), absences);
}, cancellationToken);
public Task<IReadOnlyList<UntisClassRegisterEntry>> GetClassRegisterEntriesAsync(int studentId,
int startDate, int endDate, CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
{
var entries = RequireArray(await RpcAsync("getClassregEvents", new
{
startDate,
endDate,
id = studentId,
type = (int)UntisTimetableElementType.Student,
}, sessionId, cancellationToken), "WebUntis hat keine gültigen Klassenbuch-Daten geliefert.");
return (IReadOnlyList<UntisClassRegisterEntry>)entries.Select(entry =>
{
var surname = OptionalString(entry, "surname");
var foreName = OptionalString(entry, "forname");
var displayName = string.Join(' ', new[] { foreName, surname }.Where(value => value is not null));
return new UntisClassRegisterEntry(OptionalInt(entry, "studentid"), surname, foreName,
displayName, RequiredInt(entry, "date"), OptionalString(entry, "subject"),
OptionalInt(entry, "categoryId"), OptionalString(entry, "reason"),
OptionalString(entry, "text"), entry.Clone());
}).OrderBy(entry => entry.Date).ThenBy(entry => entry.DisplayName).ToList();
}, cancellationToken);
public Task<IReadOnlyList<UntisClassRegisterCategory>> GetClassRegisterCategoriesAsync(
CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
{
var entries = RequireArray(await RpcAsync("getClassregCategories", new { }, sessionId, cancellationToken),
"WebUntis hat keine gültigen Klassenbuch-Kategorien geliefert.");
return (IReadOnlyList<UntisClassRegisterCategory>)entries.Select(entry =>
new UntisClassRegisterCategory(RequiredInt(entry, "id"), OptionalString(entry, "name") ?? "",
OptionalString(entry, "longName"), OptionalInt(entry, "groupId"), entry.Clone())).ToList();
}, cancellationToken);
public Task<IReadOnlyList<UntisClassRegisterCategoryGroup>> GetClassRegisterCategoryGroupsAsync(
CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
{
var entries = RequireArray(await RpcAsync("getClassregCategoryGroups", new { }, sessionId,
cancellationToken), "WebUntis hat keine gültigen Klassenbuch-Kategoriegruppen geliefert.");
return (IReadOnlyList<UntisClassRegisterCategoryGroup>)entries.Select(entry =>
new UntisClassRegisterCategoryGroup(RequiredInt(entry, "id"),
OptionalString(entry, "name") ?? "", entry.Clone())).ToList();
}, cancellationToken);
private async Task<T> WithSessionAsync<T>(Func<string, Task<T>> action, CancellationToken cancellationToken)
{
await using var lease = await AcquireSessionAsync(cancellationToken);
return await action(lease.SessionId);
}
private async Task<SessionLease> AcquireSessionAsync(CancellationToken cancellationToken)
{
await _sessionGate.WaitAsync(cancellationToken);
try
{
ObjectDisposedException.ThrowIf(_disposed, this);
_sessionExpiryTimer.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
// Falls der Timer durch Threadpool-Last verspätet ausgeführt wird, darf eine bereits
// abgelaufene Sitzung nicht noch einmal für einen neuen Abruf verwendet werden.
if (_sessionId is not null && _activeRequests == 0 && _sessionExpiresAt <= DateTimeOffset.UtcNow)
{
var expiredSessionId = _sessionId;
_sessionId = null;
await TryLogoutAsync(expiredSessionId);
}
if (_sessionId is null)
{
var configuration = GetConfiguration();
var result = await RpcAsync("authenticate", new
{
user = configuration.Username,
password = configuration.Password,
client = configuration.Client,
}, null, cancellationToken);
_sessionId = OptionalString(result, "sessionId")
?? throw new WebUntisException("WebUntis-Login fehlgeschlagen: Keine sessionId erhalten.");
}
_activeRequests++;
return new SessionLease(this, _sessionId);
}
finally
{
_sessionGate.Release();
}
}
private async ValueTask ReleaseSessionAsync()
{
await _sessionGate.WaitAsync();
try
{
if (_activeRequests > 0) _activeRequests--;
if (_disposed || _activeRequests != 0 || _sessionId is null) return;
var timeout = SessionIdleTimeout;
_sessionExpiresAt = DateTimeOffset.UtcNow.Add(timeout);
_sessionExpiryTimer.Change(timeout, Timeout.InfiniteTimeSpan);
}
finally
{
_sessionGate.Release();
}
}
private async Task CloseExpiredSessionAsync()
{
string? sessionId = null;
await _sessionGate.WaitAsync();
try
{
if (_disposed || _activeRequests != 0 || _sessionId is null) return;
var remaining = _sessionExpiresAt - DateTimeOffset.UtcNow;
if (remaining > TimeSpan.Zero)
{
_sessionExpiryTimer.Change(remaining, Timeout.InfiniteTimeSpan);
return;
}
sessionId = _sessionId;
_sessionId = null;
}
finally
{
_sessionGate.Release();
}
if (sessionId is not null) await TryLogoutAsync(sessionId);
}
private async Task TryLogoutAsync(string sessionId)
{
try { await RpcAsync("logout", new { }, sessionId, CancellationToken.None); }
catch { /* Ein fehlgeschlagener Logout darf Abrufe und Shutdown nicht fehlschlagen lassen. */ }
}
public async ValueTask DisposeAsync()
{
string? sessionId;
await _sessionGate.WaitAsync();
try
{
if (_disposed) return;
_disposed = true;
sessionId = _sessionId;
_sessionId = null;
}
finally
{
_sessionGate.Release();
}
// Außerhalb des Gates warten: ein bereits laufender Timer-Callback könnte selbst gerade
// auf dieses Gate warten und würde sonst den Shutdown blockieren.
await _sessionExpiryTimer.DisposeAsync();
if (sessionId is not null) await TryLogoutAsync(sessionId);
}
private TimeSpan SessionIdleTimeout =>
TimeSpan.FromMinutes(Math.Clamp(_options.SessionIdleTimeoutMinutes, 1, 30));
private async Task<JsonElement> RpcAsync(string method, object parameters, string? sessionId,
CancellationToken cancellationToken)
{
var configuration = GetConfiguration();
var uri = $"https://{configuration.Host}/WebUntis/jsonrpc.do?school={Uri.EscapeDataString(configuration.School)}";
using var request = new HttpRequestMessage(HttpMethod.Post, uri)
{
Content = JsonContent.Create(new { id = "lehrerapp-webuntis", method, @params = parameters, jsonrpc = "2.0" },
options: JsonOptions),
};
if (sessionId is not null) request.Headers.Add("Cookie", $"JSESSIONID={sessionId}");
using var response = await SendAsync(request, TimeSpan.FromSeconds(20), cancellationToken);
var payload = await ReadJsonAsync(response, cancellationToken);
if (!response.IsSuccessStatusCode)
throw new WebUntisException($"WebUntis RPC ({method}) fehlgeschlagen: {ErrorMessage(payload, response)}");
if (payload.ValueKind != JsonValueKind.Object)
throw new WebUntisException($"Leere Antwort von WebUntis RPC ({method}).");
if (TryProperty(payload, "error", out var error) && error.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined)
throw new WebUntisException($"WebUntis RPC-Fehler ({method}): {ErrorMessage(error, response)}");
if (!TryProperty(payload, "result", out var result))
throw new WebUntisException($"WebUntis RPC ({method}) ohne Ergebnis.");
return result.Clone();
}
private async Task<ReportData?> RequestReportAsync(string sessionId, CancellationToken cancellationToken)
{
var query = new Dictionary<string, string?>
{
["name"] = "Student", ["format"] = "csv", ["klasseId"] = "-1",
["studentsForDate"] = "true", ["context"] = "klasseId",
};
var uri = QueryHelpers.AddQueryString($"https://{GetConfiguration().Host}/WebUntis/reports.do", query);
using var request = ReportRequest(uri, sessionId, acceptJson: true);
using var response = await SendAsync(request, TimeSpan.FromSeconds(20), cancellationToken);
var payload = await ReadJsonAsync(response, cancellationToken);
if (!response.IsSuccessStatusCode)
{
if (TryProperty(payload, "errors", out var errors) && errors.ValueKind == JsonValueKind.Array &&
errors.EnumerateArray().Any(error => OptionalString(error, "code") == "4" || OptionalInt(error, "code") == 4))
return null;
throw new WebUntisException($"Report-Anfrage fehlgeschlagen: {ErrorMessage(payload, response)}");
}
if (!TryProperty(payload, "data", out var data) || data.ValueKind != JsonValueKind.Object ||
(TryProperty(data, "error", out var reportError) && reportError.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined))
throw new WebUntisException($"Report-Anfrage fehlgeschlagen: {ErrorMessage(payload, response)}");
if (!OptionalBoolean(data, "finished")) return null;
return ReportData.From(data);
}
private async Task<ReportData> PollReportAsync(string sessionId, CancellationToken cancellationToken)
{
var uri = $"https://{GetConfiguration().Host}/WebUntis/api/polling/REPORT";
for (var attempt = 0; attempt < 60; attempt++)
{
using var request = ReportRequest(uri, sessionId, acceptJson: true);
using var response = await SendAsync(request, TimeSpan.FromSeconds(20), cancellationToken);
var payload = await ReadJsonAsync(response, cancellationToken);
if (!response.IsSuccessStatusCode)
throw new WebUntisException($"Report-Polling fehlgeschlagen: HTTP {(int)response.StatusCode}.");
if (TryProperty(payload, "data", out var data) && TryProperty(data, "pollingJobs", out var jobs) &&
jobs.ValueKind == JsonValueKind.Array)
{
foreach (var job in jobs.EnumerateArray().Where(job => OptionalBoolean(job, "isJobFinished")))
{
if (OptionalBoolean(job, "hasJobError"))
throw new WebUntisException("Report-Polling enthält einen Job-Fehler.");
if (TryProperty(job, "data", out var jobData)) return ReportData.From(jobData);
}
}
await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken);
}
throw new WebUntisException("Report wurde nicht rechtzeitig fertiggestellt.");
}
private async Task<string> FetchReportTextAsync(string sessionId, ReportData reportData,
CancellationToken cancellationToken)
{
var uri = reportData.ReportParams is not null
? $"https://{GetConfiguration().Host}/WebUntis/reports.do?{reportData.ReportParams}"
: $"https://{GetConfiguration().Host}/WebUntis/reports.do?msgId={Uri.EscapeDataString(reportData.MessageId!)}";
using var request = ReportRequest(uri, sessionId, acceptJson: false);
using var response = await SendAsync(request, TimeSpan.FromSeconds(60), cancellationToken);
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken);
if (response.Content.Headers.ContentType?.MediaType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
{
var payload = ParseJson(bytes);
throw new WebUntisException($"Report-Abruf fehlgeschlagen: {ErrorMessage(payload, response)}");
}
if (!response.IsSuccessStatusCode)
throw new WebUntisException($"Report-Abruf fehlgeschlagen: HTTP {(int)response.StatusCode}.");
try { return StrictUtf8.GetString(bytes); }
catch (DecoderFallbackException) { return Encoding.Latin1.GetString(bytes); }
}
private HttpRequestMessage ReportRequest(string uri, string sessionId, bool acceptJson)
{
var request = new HttpRequestMessage(HttpMethod.Get, uri);
if (acceptJson) request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
request.Headers.Add("Cookie", $"JSESSIONID={sessionId}; schoolname=\"_{SchoolCookie()}\"");
return request;
}
private async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, TimeSpan timeout,
CancellationToken cancellationToken)
{
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutSource.CancelAfter(timeout);
try { return await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeoutSource.Token); }
catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested)
{
throw new WebUntisException($"Timeout nach {timeout.TotalSeconds:0} Sekunden.", exception);
}
catch (HttpRequestException exception)
{
throw new WebUntisException("WebUntis ist momentan nicht erreichbar.", exception);
}
}
private Config GetConfiguration()
{
var schoolValue = _options.School.Trim();
var username = _options.Username.Trim();
var password = _options.Password.Trim();
if (schoolValue.Length == 0) throw new WebUntisConfigurationException("WEBUNTIS_SCHOOL fehlt.");
if (username.Length == 0) throw new WebUntisConfigurationException("WEBUNTIS_USER fehlt.");
if (password.Length == 0) throw new WebUntisConfigurationException("WEBUNTIS_PASSWORD fehlt.");
var cleaned = schoolValue.Replace("https://", "", StringComparison.OrdinalIgnoreCase)
.Replace("http://", "", StringComparison.OrdinalIgnoreCase).Split('/')[0];
var school = cleaned.Contains('.') ? cleaned.Split('.')[0] : cleaned;
var host = string.IsNullOrWhiteSpace(_options.Host)
? (cleaned.Contains('.') ? cleaned : $"{school}.webuntis.com")
: _options.Host.Trim().Replace("https://", "", StringComparison.OrdinalIgnoreCase)
.Replace("http://", "", StringComparison.OrdinalIgnoreCase).TrimEnd('/');
if (host.Contains('/') || !Uri.CheckHostName(host).Equals(UriHostNameType.Dns))
throw new WebUntisConfigurationException("WEBUNTIS_HOST ist ungültig.");
return new Config(school, host, username, password,
string.IsNullOrWhiteSpace(_options.Client) ? "LehrerApp" : _options.Client.Trim());
}
private string SchoolCookie() => Convert.ToBase64String(Encoding.UTF8.GetBytes(GetConfiguration().School));
private static async Task<JsonElement> ReadJsonAsync(HttpResponseMessage response, CancellationToken token) =>
ParseJson(await response.Content.ReadAsByteArrayAsync(token));
private static JsonElement ParseJson(byte[] bytes)
{
try { return JsonSerializer.Deserialize<JsonElement>(bytes, JsonOptions); }
catch (JsonException) { return default; }
}
private static IReadOnlyList<JsonElement> RequireArray(JsonElement value, string error) =>
value.ValueKind == JsonValueKind.Array ? value.EnumerateArray().Select(item => item.Clone()).ToList()
: throw new WebUntisException(error);
private static IReadOnlyList<UntisEntity> Entities(JsonElement parent, string property) =>
TryProperty(parent, property, out var entries) && entries.ValueKind == JsonValueKind.Array
? entries.EnumerateArray().Select(entry => new UntisEntity(OptionalInt(entry, "id") ?? 0,
OptionalString(entry, "name") ?? "", OptionalInt(entry, "orgid"),
OptionalString(entry, "orgname"), OptionalString(entry, "externalkey"))).ToList()
: [];
private static IReadOnlyList<int> IntArray(JsonElement parent, string property) =>
TryProperty(parent, property, out var entries) && entries.ValueKind == JsonValueKind.Array
? entries.EnumerateArray().Select(OptionalInt).Where(value => value is not null)
.Select(value => value!.Value).ToList()
: [];
private static UntisReschedule? ParseReschedule(JsonElement parent)
{
if (!TryProperty(parent, "reschedule", out var value) || value.ValueKind != JsonValueKind.Object) return null;
var date = OptionalInt(value, "date");
var start = OptionalInt(value, "startTime");
var end = OptionalInt(value, "endTime");
return date is not null && start is not null && end is not null
? new UntisReschedule(date.Value, start.Value, end.Value)
: null;
}
private static bool TryProperty(JsonElement value, string name, out JsonElement property)
{
property = default;
return value.ValueKind == JsonValueKind.Object && value.TryGetProperty(name, out property);
}
private static int RequiredInt(JsonElement value, string property) => OptionalInt(value, property)
?? throw new WebUntisException($"WebUntis-Feld \"{property}\" ist ungültig.");
private static int? OptionalInt(JsonElement parent, string property) =>
TryProperty(parent, property, out var value) ? OptionalInt(value) : null;
private static int? OptionalInt(JsonElement value)
{
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number)) return number;
return value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), out number) ? number : null;
}
private static string? OptionalString(JsonElement parent, string property)
{
if (!TryProperty(parent, property, out var value)) return null;
var text = value.ValueKind == JsonValueKind.String ? value.GetString() :
value.ValueKind is JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False ? value.ToString() : null;
return string.IsNullOrWhiteSpace(text) ? null : text.Trim();
}
private static bool OptionalBoolean(JsonElement parent, string property)
{
if (!TryProperty(parent, property, out var value)) return false;
if (value.ValueKind is JsonValueKind.True or JsonValueKind.False) return value.GetBoolean();
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number)) return number != 0;
return value.ValueKind == JsonValueKind.String &&
(string.Equals(value.GetString(), "true", StringComparison.OrdinalIgnoreCase) || value.GetString() == "1");
}
private static string ErrorMessage(JsonElement payload, HttpResponseMessage response)
{
if (payload.ValueKind == JsonValueKind.String) return payload.GetString() ?? "Unerwartete Antwort.";
if (payload.ValueKind == JsonValueKind.Object)
{
if (TryProperty(payload, "error", out var error) && OptionalString(error, "message") is { } errorMessage)
return errorMessage;
if (OptionalString(payload, "message") is { } message) return message;
if (TryProperty(payload, "data", out var data) && OptionalString(data, "message") is { } dataMessage)
return dataMessage;
}
return response.IsSuccessStatusCode ? "Unerwartete JSON-Antwort." : $"HTTP {(int)response.StatusCode}";
}
private sealed record Config(string School, string Host, string Username, string Password, string Client);
private sealed class SessionLease(WebUntisClient owner, string sessionId) : IAsyncDisposable
{
private int _released;
public string SessionId { get; } = sessionId;
public ValueTask DisposeAsync() => Interlocked.Exchange(ref _released, 1) == 0
? owner.ReleaseSessionAsync()
: ValueTask.CompletedTask;
}
private sealed record ReportData(string? ReportParams, string? MessageId)
{
public static ReportData From(JsonElement data)
{
var result = new ReportData(OptionalString(data, "reportParams"), OptionalString(data, "messageId"));
return result.ReportParams is null && result.MessageId is null
? throw new WebUntisException("Report-Antwort enthält weder reportParams noch messageId.")
: result;
}
}
}
+184
View File
@@ -0,0 +1,184 @@
using System.Text.Json;
namespace LehrerApp.Api;
public sealed class WebUntisOptions
{
public string School { get; set; } = "";
public string Host { get; set; } = "";
public string Username { get; set; } = "";
public string Password { get; set; } = "";
public string Client { get; set; } = "LehrerApp";
public int SessionIdleTimeoutMinutes { get; set; } = 10;
}
public sealed record UntisSchoolYear(int UntisId, string Name, int StartDate, int EndDate);
public sealed record UntisClass(
int UntisId,
string Name,
string? LongName,
string? ForeColor,
string? BackColor,
int? DepartmentUntisId,
int? Teacher1UntisId,
int? Teacher2UntisId);
public sealed record UntisTeacher(
int UntisId,
string Name,
string? ForeName,
string? LongName,
string? Title,
bool Active,
IReadOnlyList<int> DepartmentUntisIds);
public sealed record UntisStudentAddress(
string? Email,
string? Mobile,
string? Phone,
string? City,
string? PostCode,
string? Street);
public sealed record UntisStudent(
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,
UntisStudentAddress Address,
string? AttributeIL);
public sealed record UntisStudentReport(
int Count,
string? ClassNameFilter,
IReadOnlyList<UntisStudent> Students);
public sealed record UntisTimeUnit(string Name, int StartTime, int EndTime);
public sealed record UntisTimeGridDay(
int Day,
IReadOnlyList<UntisTimeUnit> TimeUnits,
JsonElement Raw);
public sealed record UntisHoliday(
int UntisId,
string Name,
string? LongName,
int StartDate,
int EndDate);
public sealed record UntisEntity(
int Id,
string Name,
int? OriginalId,
string? OriginalName,
string? ExternalKey);
public sealed record UntisReschedule(int Date, int StartTime, int EndTime);
public sealed record UntisSubstitution(
string Type,
int? LessonId,
string? LessonType,
int Date,
int StartTime,
int EndTime,
string? Text,
IReadOnlyList<UntisEntity> Classes,
IReadOnlyList<UntisEntity> Teachers,
IReadOnlyList<UntisEntity> Subjects,
IReadOnlyList<UntisEntity> Rooms,
UntisReschedule? Reschedule,
JsonElement Raw);
public enum UntisTimetableElementType
{
Class = 1,
Teacher = 2,
Subject = 3,
Room = 4,
Student = 5,
}
public sealed record UntisTimetablePeriod(
int Id,
int Date,
int StartTime,
int EndTime,
string? Code,
string? ActivityType,
string? Info,
string? LessonText,
string? SubstitutionText,
string? StudentGroup,
IReadOnlyList<UntisEntity> Classes,
IReadOnlyList<UntisEntity> Teachers,
IReadOnlyList<UntisEntity> Subjects,
IReadOnlyList<UntisEntity> Rooms,
JsonElement Raw);
public sealed record UntisStudentAbsence(
int StudentKey,
int Date,
int StartTime,
int EndTime,
int AbsentMinutes,
bool Checked,
string? AbsenceReason,
string? ExcuseStatus,
int? SubjectId,
IReadOnlyList<int> TeacherIds,
string? StudentGroup,
JsonElement Raw);
public sealed record UntisStudentAbsenceReport(
int StudentKey,
int StartDate,
int EndDate,
int EntryCount,
int AbsentMinutes,
IReadOnlyList<UntisStudentAbsence> Absences);
public sealed record UntisClassRegisterEntry(
int? StudentKey,
string? Surname,
string? ForeName,
string DisplayName,
int Date,
string? Subject,
int? CategoryId,
string? Reason,
string? Text,
JsonElement Raw);
public sealed record UntisClassRegisterCategory(
int Id,
string Name,
string? LongName,
int? GroupId,
JsonElement Raw);
public sealed record UntisClassRegisterCategoryGroup(
int Id,
string Name,
JsonElement Raw);
public sealed class WebUntisException(string message, Exception? innerException = null)
: Exception(message, innerException);
public sealed class WebUntisConfigurationException(string message) : Exception(message);
@@ -0,0 +1,133 @@
using System.Globalization;
using System.Text;
namespace LehrerApp.Api;
public static class WebUntisStudentReportParser
{
public static IReadOnlyList<UntisStudent> Parse(string content)
{
var rows = ParseSeparatedRows(content, '\t');
if (rows.Count == 0) return [];
var headers = rows[0]
.Select((header, index) => (index == 0 ? header.TrimStart('\uFEFF') : header).Trim())
.ToArray();
var result = new List<UntisStudent>();
foreach (var row in rows.Skip(1))
{
if (row.All(string.IsNullOrWhiteSpace)) continue;
var values = new Dictionary<string, string?>(StringComparer.Ordinal);
for (var index = 0; index < headers.Length; index++)
values[headers[index]] = index < row.Count ? row[index].Trim() : null;
var untisId = RequiredInt(Get(values, "id"), "id");
var externalKey = RequiredInt(Get(values, "externKey"), "externKey");
var name = Optional(Get(values, "name"));
var lastName = Optional(Get(values, "longName"));
var firstName = Optional(Get(values, "foreName"));
var displayName = string.Join(' ', new[] { firstName, lastName }.Where(value => value is not null));
if (string.IsNullOrWhiteSpace(displayName)) displayName = name ?? $"Schüler {untisId}";
result.Add(new UntisStudent(
untisId,
externalKey,
Get(values, "klasse.name")?.Trim() ?? "",
name,
lastName,
firstName,
displayName,
Optional(Get(values, "gender")),
GermanDate(Get(values, "birthDate")),
Optional(Get(values, "birthDate")),
GermanDate(Get(values, "entryDate")),
Optional(Get(values, "entryDate")),
GermanDate(Get(values, "exitDate")),
Optional(Get(values, "exitDate")),
Optional(Get(values, "text")),
Optional(Get(values, "medicalReportDuty")),
Optional(Get(values, "schulpflicht")),
Optional(Get(values, "majority")),
new UntisStudentAddress(
Optional(Get(values, "adress.email")),
Optional(Get(values, "adress.mobile")),
Optional(Get(values, "adress.phone")),
Optional(Get(values, "adress.city")),
Optional(Get(values, "adress.postCode")),
Optional(Get(values, "adress.street"))),
Optional(Get(values, "attribute.iL"))));
}
return result;
}
private static List<List<string>> ParseSeparatedRows(string content, char separator)
{
var rows = new List<List<string>>();
var row = new List<string>();
var field = new StringBuilder();
var inQuotes = false;
for (var index = 0; index < content.Length; index++)
{
var character = content[index];
if (character == '"')
{
if (inQuotes && index + 1 < content.Length && content[index + 1] == '"')
{
field.Append('"');
index++;
}
else
{
inQuotes = !inQuotes;
}
continue;
}
if (!inQuotes && character == separator)
{
row.Add(field.ToString());
field.Clear();
continue;
}
if (!inQuotes && character == '\n')
{
row.Add(field.ToString());
rows.Add(row);
row = [];
field.Clear();
continue;
}
if (!inQuotes && character == '\r') continue;
field.Append(character);
}
row.Add(field.ToString());
if (row.Count > 1 || !string.IsNullOrWhiteSpace(row[0])) rows.Add(row);
return rows;
}
private static string? Get(IReadOnlyDictionary<string, string?> values, string key) =>
values.TryGetValue(key, out var value) ? value : null;
private static string? Optional(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static int RequiredInt(string? value, string field) =>
int.TryParse(value?.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
? parsed
: throw new InvalidDataException($"Ungültige Zahl in Spalte \"{field}\".");
private static int? GermanDate(string? value)
{
if (!DateOnly.TryParseExact(value?.Trim(), "dd.MM.yyyy", CultureInfo.InvariantCulture,
DateTimeStyles.None, out var date))
return null;
return date.Year * 10_000 + date.Month * 100 + date.Day;
}
}
+48
View File
@@ -85,6 +85,54 @@ DWD-Ausfall liefert der Server den letzten erfolgreichen Stand. Für eine eigene
`LehrerApp-Server/1.0 (+https://schule.example)`. Der Container benötigt ausgehenden HTTPS-Zugriff `LehrerApp-Server/1.0 (+https://schule.example)`. Der Container benötigt ausgehenden HTTPS-Zugriff
auf `nominatim.openstreetmap.org`, `www.dwd.de` und `opendata.dwd.de`. auf `nominatim.openstreetmap.org`, `www.dwd.de` und `opendata.dwd.de`.
## WebUntis
Die WebUntis-Zugangsdaten werden ausschließlich im API-Container konfiguriert und nie an den
Desktop-Client ausgegeben:
```dotenv
WEBUNTIS_SCHOOL=meine-schule
WEBUNTIS_HOST=meine-schule.webuntis.com
WEBUNTIS_USER=technischer-benutzer
WEBUNTIS_PASSWORD=geheimes-passwort
WEBUNTIS_CLIENT=LehrerApp
WEBUNTIS_SESSION_IDLE_MINUTES=10
```
`WEBUNTIS_HOST` kann entfallen, wenn der Host `<WEBUNTIS_SCHOOL>.webuntis.com` entspricht. Der
technische Benutzer benötigt die jeweiligen WebUntis-Leserechte und darf für die alte JSON-RPC-
Schnittstelle keine aktivierte Zwei-Faktor-Authentifizierung haben.
Der API-Server meldet sich beim ersten Abruf an und verwendet diese Sitzung für weitere Abrufe.
Erst wenn zehn Minuten lang kein WebUntis-Abruf mehr aktiv war, meldet er sich automatisch ab.
`WEBUNTIS_SESSION_IDLE_MINUTES` kann bei Bedarf auf einen Wert zwischen 1 und 30 Minuten geändert
werden.
Alle Endpunkte benötigen dasselbe Bearer-Token wie die Sync-API und führen genau einen Abruf aus;
sie speichern das Ergebnis nicht serverseitig:
| Endpunkt | Zweck |
| --- | --- |
| `GET /api/webuntis/student-report?className=7a` | Schülerreport, optional nach Klasse gefiltert |
| `GET /api/webuntis/schoolyears` | Schuljahre |
| `GET /api/webuntis/classes?schoolyearId=123` | Klassen eines Schuljahres |
| `GET /api/webuntis/teachers` | Lehrkräfte und WebUntis-IDs |
| `GET /api/webuntis/holidays` | Ferienzeiträume |
| `GET /api/webuntis/timegrid` | Stunden-/Zeitraster |
| `GET /api/webuntis/substitutions?startDate=20260824&endDate=20260828` | Vertretungen im Zeitraum |
| `GET /api/webuntis/timetable?elementType=teacher&elementId=42&startDate=20260824&endDate=20260828` | Stundenplan für Klasse, Lehrkraft, Fach, Raum oder Schüler |
| `GET /api/webuntis/students/9001/absences?startDate=20260801&endDate=20270731` | Fehlzeiten eines Schülers; `9001` ist der `externKey` aus dem Schülerreport |
| `GET /api/webuntis/students/17/class-register-entries?startDate=20260801&endDate=20270731` | Klassenbucheinträge eines Schülers; `17` ist dessen `untisId` |
| `GET /api/webuntis/class-register/categories` | Kategorien der Klassenbucheinträge |
| `GET /api/webuntis/class-register/category-groups` | Kategoriegruppen der Klassenbucheinträge |
Datumsparameter verwenden das WebUntis-Format `yyyyMMdd`. Vertretungen sind auf 31 Tage und
Stundenpläne auf 62 Tage pro Anfrage begrenzt, damit ein einzelner Client keine unkontrolliert
großen WebUntis-Abfragen auslösen kann. Fehlzeiten und Klassenbucheinträge dürfen für einen
kompletten Schuljahreszeitraum von bis zu 400 Tagen geladen werden. Diese Klassenbuchfunktionen
sind nur verfügbar, wenn das Modul an der Schule aktiv ist und der technische Benutzer die
benötigten Leserechte besitzt.
## Deployment über Dokploy ## Deployment über Dokploy
Kein manuelles Bauen/Hochladen nötig Dokploy zieht das Repo direkt per Git und baut das Image Kein manuelles Bauen/Hochladen nötig Dokploy zieht das Repo direkt per Git und baut das Image
+6
View File
@@ -13,6 +13,12 @@ services:
environment: environment:
- JWT_SECRET=${JWT_SECRET} - JWT_SECRET=${JWT_SECRET}
- Geocoding__UserAgent=${GEOCODING_USER_AGENT:-LehrerApp-Server/1.0 (+https://science-teaching.de)} - Geocoding__UserAgent=${GEOCODING_USER_AGENT:-LehrerApp-Server/1.0 (+https://science-teaching.de)}
- WEBUNTIS_SCHOOL=${WEBUNTIS_SCHOOL:-}
- WEBUNTIS_HOST=${WEBUNTIS_HOST:-}
- WEBUNTIS_USER=${WEBUNTIS_USER:-}
- WEBUNTIS_PASSWORD=${WEBUNTIS_PASSWORD:-}
- WEBUNTIS_CLIENT=${WEBUNTIS_CLIENT:-LehrerApp}
- WEBUNTIS_SESSION_IDLE_MINUTES=${WEBUNTIS_SESSION_IDLE_MINUTES:-10}
- ASPNETCORE_ENVIRONMENT=Production - ASPNETCORE_ENVIRONMENT=Production
restart: unless-stopped restart: unless-stopped