Vorarbeit: WebUntis-API
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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[]>();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user