Untis-API von Server zu Client
This commit is contained in:
@@ -7,13 +7,3 @@ JWT_SECRET=hier-einen-langen-zufaelligen-wert-eintragen
|
||||
|
||||
# Identifiziert die Installation gegenüber Nominatim/DWD. Bei eigener Domain bitte anpassen.
|
||||
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
|
||||
|
||||
@@ -60,12 +60,16 @@ The API can also run via `docker/docker-compose.yml` (reads `JWT_SECRET` from th
|
||||
orchestration, timer-driven), `SnapshotService`, `Crypto/SyncCrypto` (AES-256-GCM payload
|
||||
encryption — desktop events are encrypted at rest and in transit; Companion/WebApp events are
|
||||
plaintext, see `PlainSyncEvent` vs `SyncEvent`).
|
||||
- **LehrerApp.WebUntis** — direkter, serverunabhängiger WebUntis-Client für den Desktop. Hält die
|
||||
persönliche JSON-RPC-Sitzung lokal und parst den Schülerreport lokal; WebUntis-Zugangsdaten und
|
||||
personenbezogene Antworten dürfen nicht über `LehrerApp.Api` geleitet werden.
|
||||
- **LehrerApp.Api** — minimal ASP.NET Core server: JWT auth, an append-only `EventStore` plus
|
||||
`SnapshotStore`/`ReadableSnapshotStore` per device, mapped in `Endpoints/Endpoints.cs`. Sync is
|
||||
optional — Desktop only registers `SyncEngine`/`SnapshotService` in DI when a server URL is
|
||||
configured (`AppBootstrapper.LoadServerUrl`).
|
||||
- Each library has a matching `*.Tests` project (`LehrerApp.Tests` → Core, `LehrerApp.Data.Tests` →
|
||||
Data, `LehrerApp.Desktop.Tests` → Desktop, `LehrerApp.Sync.Tests` → Sync), all xUnit.
|
||||
`LehrerApp.WebUntis.Tests` covers the direct WebUntis client and report parser.
|
||||
|
||||
### MVVM conventions (Desktop)
|
||||
|
||||
|
||||
@@ -244,177 +244,6 @@ public static class Endpoints
|
||||
});
|
||||
}
|
||||
|
||||
// ── WebUntis (granulare Abrufe über benutzergebundene RAM-Sitzungen) ──────
|
||||
|
||||
public static void MapWebUntisEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/webuntis").RequireAuthorization();
|
||||
|
||||
group.MapGet("/connection", (ClaimsPrincipal user, WebUntisConnectionStore connections) =>
|
||||
{
|
||||
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
return uid is null ? Results.Unauthorized() : Results.Ok(connections.GetStatus(uid));
|
||||
});
|
||||
|
||||
group.MapPost("/connection", async (WebUntisConnectRequest request, ClaimsPrincipal user,
|
||||
WebUntisConnectionStore connections, CancellationToken cancellationToken) =>
|
||||
{
|
||||
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (uid is null) return Results.Unauthorized();
|
||||
if (string.IsNullOrWhiteSpace(request.School) || string.IsNullOrWhiteSpace(request.Username) ||
|
||||
string.IsNullOrWhiteSpace(request.Password))
|
||||
return Results.BadRequest("Schule, Benutzername und Passwort sind erforderlich.");
|
||||
return await WebUntisResult(() => connections.ConnectAsync(uid, request, cancellationToken));
|
||||
});
|
||||
|
||||
group.MapDelete("/connection", async (ClaimsPrincipal user, WebUntisConnectionStore connections) =>
|
||||
{
|
||||
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (uid is null) return Results.Unauthorized();
|
||||
await connections.DisconnectAsync(uid);
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
group.MapGet("/schoolyears", (ClaimsPrincipal user, WebUntisConnectionStore connections,
|
||||
WebUntisClient fallback, CancellationToken cancellationToken) =>
|
||||
WithWebUntisClient(user, connections, fallback,
|
||||
client => client.GetSchoolYearsAsync(cancellationToken)));
|
||||
|
||||
group.MapGet("/classes", ([FromQuery] int schoolyearId, ClaimsPrincipal user,
|
||||
WebUntisConnectionStore connections, WebUntisClient fallback,
|
||||
CancellationToken cancellationToken) => schoolyearId <= 0
|
||||
? Task.FromResult<IResult>(Results.BadRequest("schoolyearId muss größer als 0 sein."))
|
||||
: WithWebUntisClient(user, connections, fallback,
|
||||
client => client.GetClassesAsync(schoolyearId, cancellationToken)));
|
||||
|
||||
group.MapGet("/teachers", (ClaimsPrincipal user, WebUntisConnectionStore connections,
|
||||
WebUntisClient fallback, CancellationToken cancellationToken) =>
|
||||
WithWebUntisClient(user, connections, fallback, client => client.GetTeachersAsync(cancellationToken)));
|
||||
|
||||
group.MapGet("/student-report", ([FromQuery] string? className, ClaimsPrincipal user,
|
||||
WebUntisConnectionStore connections, WebUntisClient fallback, CancellationToken cancellationToken) =>
|
||||
WithWebUntisClient(user, connections, fallback,
|
||||
client => client.GetStudentReportAsync(className, cancellationToken)));
|
||||
|
||||
group.MapGet("/holidays", (ClaimsPrincipal user, WebUntisConnectionStore connections,
|
||||
WebUntisClient fallback, CancellationToken cancellationToken) =>
|
||||
WithWebUntisClient(user, connections, fallback, client => client.GetHolidaysAsync(cancellationToken)));
|
||||
|
||||
group.MapGet("/timegrid", (ClaimsPrincipal user, WebUntisConnectionStore connections,
|
||||
WebUntisClient fallback, CancellationToken cancellationToken) =>
|
||||
WithWebUntisClient(user, connections, fallback, client => client.GetTimeGridAsync(cancellationToken)));
|
||||
|
||||
group.MapGet("/substitutions", ([FromQuery] int startDate, [FromQuery] int endDate,
|
||||
[FromQuery] int? departmentId, ClaimsPrincipal user, WebUntisConnectionStore connections,
|
||||
WebUntisClient fallback, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!ValidDateRange(startDate, endDate, 31, out var error))
|
||||
return Task.FromResult<IResult>(Results.BadRequest(error));
|
||||
return WithWebUntisClient(user, connections, fallback,
|
||||
client => client.GetSubstitutionsAsync(startDate, endDate, departmentId, cancellationToken));
|
||||
});
|
||||
|
||||
group.MapGet("/timetable", ([FromQuery] string elementType, [FromQuery] int elementId,
|
||||
[FromQuery] int startDate, [FromQuery] int endDate, ClaimsPrincipal user,
|
||||
WebUntisConnectionStore connections, WebUntisClient fallback, 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 WithWebUntisClient(user, connections, fallback,
|
||||
client => client.GetTimetableAsync(parsedType, elementId, startDate, endDate, cancellationToken));
|
||||
});
|
||||
|
||||
group.MapGet("/students/{studentKey:int}/absences", (int studentKey, [FromQuery] int startDate,
|
||||
[FromQuery] int endDate, ClaimsPrincipal user, WebUntisConnectionStore connections,
|
||||
WebUntisClient fallback, 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 WithWebUntisClient(user, connections, fallback,
|
||||
client => client.GetStudentAbsencesAsync(studentKey, startDate, endDate, cancellationToken));
|
||||
});
|
||||
|
||||
group.MapGet("/students/{studentId:int}/class-register-entries", (int studentId,
|
||||
[FromQuery] int startDate, [FromQuery] int endDate, ClaimsPrincipal user,
|
||||
WebUntisConnectionStore connections, WebUntisClient fallback, 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 WithWebUntisClient(user, connections, fallback,
|
||||
client => client.GetClassRegisterEntriesAsync(studentId, startDate, endDate, cancellationToken));
|
||||
});
|
||||
|
||||
group.MapGet("/class-register/categories", (ClaimsPrincipal user,
|
||||
WebUntisConnectionStore connections, WebUntisClient fallback, CancellationToken cancellationToken) =>
|
||||
WithWebUntisClient(user, connections, fallback,
|
||||
client => client.GetClassRegisterCategoriesAsync(cancellationToken)));
|
||||
|
||||
group.MapGet("/class-register/category-groups", (ClaimsPrincipal user,
|
||||
WebUntisConnectionStore connections, WebUntisClient fallback, CancellationToken cancellationToken) =>
|
||||
WithWebUntisClient(user, connections, fallback,
|
||||
client => client.GetClassRegisterCategoryGroupsAsync(cancellationToken)));
|
||||
}
|
||||
|
||||
private static Task<IResult> WithWebUntisClient<T>(ClaimsPrincipal user,
|
||||
WebUntisConnectionStore connections, WebUntisClient fallback, Func<WebUntisClient, Task<T>> operation)
|
||||
{
|
||||
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (uid is null) return Task.FromResult<IResult>(Results.Unauthorized());
|
||||
return WebUntisResult(() => operation(connections.GetClient(uid) ?? fallback));
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
var errors = new Dictionary<string, string[]>();
|
||||
|
||||
@@ -97,29 +97,6 @@ builder.Services.AddHttpClient("dwd", client =>
|
||||
});
|
||||
builder.Services.AddSingleton(sp => new DwdWeatherService(
|
||||
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>>()));
|
||||
builder.Services.AddSingleton<WebUntisConnectionStore>();
|
||||
|
||||
var app = builder.Build();
|
||||
app.UseForwardedHeaders();
|
||||
@@ -135,6 +112,5 @@ app.MapSnapshotEndpoints();
|
||||
app.MapReadableSnapshotEndpoints();
|
||||
app.MapPlainSyncEndpoints();
|
||||
app.MapSchoolWeatherEndpoints();
|
||||
app.MapWebUntisEndpoints();
|
||||
app.Run();
|
||||
return 0;
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace LehrerApp.Api;
|
||||
|
||||
public sealed record WebUntisConnectRequest(
|
||||
string School,
|
||||
string? Host,
|
||||
string Username,
|
||||
string Password);
|
||||
|
||||
public sealed record WebUntisConnectionStatus(bool Connected, string? School, string? Username);
|
||||
|
||||
/// <summary>
|
||||
/// Hält persönliche WebUntis-Verbindungen ausschließlich im Arbeitsspeicher des Servers.
|
||||
/// Passwörter werden weder protokolliert noch serverseitig persistiert. Der enthaltene Client
|
||||
/// verwaltet die eigentliche WebUntis-Session und meldet sie nach Inaktivität wieder ab.
|
||||
/// </summary>
|
||||
public sealed class WebUntisConnectionStore(IHttpClientFactory httpClientFactory) : IAsyncDisposable
|
||||
{
|
||||
private sealed record Entry(WebUntisClient Client, string School, string Username);
|
||||
|
||||
private readonly ConcurrentDictionary<string, Entry> _connections = new();
|
||||
|
||||
public WebUntisClient? GetClient(string userId) =>
|
||||
_connections.TryGetValue(userId, out var entry) ? entry.Client : null;
|
||||
|
||||
public WebUntisConnectionStatus GetStatus(string userId) =>
|
||||
_connections.TryGetValue(userId, out var entry)
|
||||
? new(true, entry.School, entry.Username)
|
||||
: new(false, null, null);
|
||||
|
||||
public async Task<WebUntisConnectionStatus> ConnectAsync(string userId, WebUntisConnectRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var options = new WebUntisOptions
|
||||
{
|
||||
School = request.School,
|
||||
Host = request.Host ?? "",
|
||||
Username = request.Username,
|
||||
Password = request.Password,
|
||||
Client = "LehrerApp",
|
||||
SessionIdleTimeoutMinutes = 10,
|
||||
};
|
||||
var client = new WebUntisClient(httpClientFactory.CreateClient("webuntis"), Options.Create(options));
|
||||
try
|
||||
{
|
||||
// Authentifiziert wirklich gegen WebUntis; ungültige Daten werden nicht gespeichert.
|
||||
await client.GetSchoolYearsAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await client.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
var entry = new Entry(client, request.School.Trim(), request.Username.Trim());
|
||||
if (_connections.TryGetValue(userId, out var previous))
|
||||
await previous.Client.DisposeAsync();
|
||||
_connections[userId] = entry;
|
||||
return new(true, entry.School, entry.Username);
|
||||
}
|
||||
|
||||
public async Task DisconnectAsync(string userId)
|
||||
{
|
||||
if (_connections.TryRemove(userId, out var entry)) await entry.Client.DisposeAsync();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
var entries = _connections.Values.ToList();
|
||||
_connections.Clear();
|
||||
foreach (var entry in entries) await entry.Client.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class WebUntisIntegrationServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ConnectUndAbruf_GehenDirektAnWebUntisUndVerwendenDieselbeSession()
|
||||
{
|
||||
var handler = new QueueHandler(
|
||||
Json("{\"result\":{\"sessionId\":\"desktop-session\"}}"),
|
||||
Json("{\"result\":[{\"id\":1,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"),
|
||||
Json("{\"result\":[{\"id\":42,\"name\":\"KUE\",\"longName\":\"Kürbis\",\"active\":true}]}"),
|
||||
Json("{\"result\":{}}"));
|
||||
var settings = TestSupport.BuildWebUntisSettingsService();
|
||||
await using var service = new WebUntisIntegrationService(new HttpClient(handler), settings);
|
||||
|
||||
await service.ConnectAsync(new WebUntisCredentials(
|
||||
"bk-ostvest", "arche.webuntis.com", "lehrkraft", "geheim"));
|
||||
var teachers = await service.GetTeachersAsync();
|
||||
|
||||
Assert.Equal("KUE", Assert.Single(teachers).Name);
|
||||
Assert.Equal(3, handler.Requests.Count);
|
||||
Assert.All(handler.Requests, request =>
|
||||
Assert.StartsWith("https://arche.webuntis.com/WebUntis/", request.Uri));
|
||||
Assert.DoesNotContain(handler.Requests, request => request.Uri.Contains("/api/webuntis"));
|
||||
Assert.Single(handler.Requests, request => request.Body.Contains("\"method\":\"authenticate\""));
|
||||
}
|
||||
|
||||
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(request.RequestUri?.ToString() ?? "",
|
||||
request.Content is null ? "" : await request.Content.ReadAsStringAsync(cancellationToken)));
|
||||
return _responses.Dequeue();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record CapturedRequest(string Uri, string Body);
|
||||
}
|
||||
@@ -223,7 +223,7 @@ public static class AppBootstrapper
|
||||
services.AddSingleton(syncSettings);
|
||||
services.AddSingleton(_ => new SyncAuthService(new HttpClient()));
|
||||
services.AddSingleton(sp => new SchoolWeatherService(new HttpClient(), syncSettings));
|
||||
services.AddSingleton(sp => new WebUntisIntegrationService(new HttpClient(), syncSettings, untisSettings));
|
||||
services.AddSingleton(sp => new WebUntisIntegrationService(new HttpClient(), untisSettings));
|
||||
// War dieses Gerät schon eingeloggt, aber sync.key fehlt(e), wurde gerade eben (unten)
|
||||
// stillschweigend ein neuer, unabhängiger Schlüssel erzeugt - bisher unter dem ALTEN
|
||||
// Schlüssel synchronisierte Server-Daten sind für dieses Gerät dann nicht mehr lesbar.
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<ProjectReference Include="..\LehrerApp.Core\LehrerApp.Core.csproj" />
|
||||
<ProjectReference Include="..\LehrerApp.Data\LehrerApp.Data.csproj" />
|
||||
<ProjectReference Include="..\LehrerApp.Sync\LehrerApp.Sync.csproj" />
|
||||
<ProjectReference Include="..\LehrerApp.WebUntis\LehrerApp.WebUntis.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" />
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using LehrerApp.WebUntis;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
@@ -35,106 +33,125 @@ public sealed record UntisStudentAbsenceDto(int StudentKey, int Date, int StartT
|
||||
public sealed record UntisStudentAbsenceReportDto(int StudentKey, int StartDate, int EndDate, int EntryCount,
|
||||
int AbsentMinutes, IReadOnlyList<UntisStudentAbsenceDto> Absences);
|
||||
|
||||
/// <summary>Authentifizierter Desktop-Client für die LehrerApp-API. Falls der Server neu gestartet
|
||||
/// wurde, baut er die nur im Server-RAM gehaltene WebUntis-Verbindung automatisch erneut auf.</summary>
|
||||
public sealed class WebUntisIntegrationService(HttpClient http, SyncSettingsService syncSettings,
|
||||
WebUntisSettingsService settings)
|
||||
/// <summary>Direkter WebUntis-Client des Desktops. Personenbezogene Antworten und der
|
||||
/// unverschlüsselte CSV-Report passieren zu keinem Zeitpunkt den LehrerApp-Server.</summary>
|
||||
public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettingsService settings) : IAsyncDisposable
|
||||
{
|
||||
public bool IsAvailable => syncSettings.IsLoggedIn && !string.IsNullOrWhiteSpace(syncSettings.ServerUrl)
|
||||
&& settings.ApiIsConfigured;
|
||||
private readonly SemaphoreSlim _clientGate = new(1, 1);
|
||||
private WebUntisClient? _client;
|
||||
|
||||
public bool IsAvailable => settings.ApiIsConfigured;
|
||||
|
||||
public async Task ConnectAsync(WebUntisCredentials credentials, CancellationToken token = default)
|
||||
{
|
||||
using var response = await SendAsync(HttpMethod.Post, "/api/webuntis/connection", new
|
||||
var candidate = CreateClient(credentials);
|
||||
try
|
||||
{
|
||||
credentials.School,
|
||||
Host = string.IsNullOrWhiteSpace(credentials.Host) ? null : credentials.Host,
|
||||
credentials.Username,
|
||||
credentials.Password,
|
||||
}, token);
|
||||
await EnsureSuccessAsync(response);
|
||||
await candidate.GetSchoolYearsAsync(token);
|
||||
}
|
||||
catch (Exception exception) when (IsUntisError(exception))
|
||||
{
|
||||
await candidate.DisposeAsync();
|
||||
throw Translate(exception);
|
||||
}
|
||||
|
||||
WebUntisClient? previous;
|
||||
await _clientGate.WaitAsync(token);
|
||||
try { previous = _client; _client = candidate; }
|
||||
finally { _clientGate.Release(); }
|
||||
if (previous is not null) await previous.DisposeAsync();
|
||||
}
|
||||
|
||||
public async Task DisconnectAsync(CancellationToken token = default)
|
||||
{
|
||||
using var response = await SendAsync(HttpMethod.Delete, "/api/webuntis/connection", null, token);
|
||||
await EnsureSuccessAsync(response);
|
||||
WebUntisClient? previous;
|
||||
await _clientGate.WaitAsync(token);
|
||||
try { previous = _client; _client = null; }
|
||||
finally { _clientGate.Release(); }
|
||||
if (previous is not null) await previous.DisposeAsync();
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<UntisSchoolYearDto>> GetSchoolYearsAsync(CancellationToken token = default) =>
|
||||
GetAsync<IReadOnlyList<UntisSchoolYearDto>>("/api/webuntis/schoolyears", token);
|
||||
public Task<IReadOnlyList<UntisSchoolYearDto>> GetSchoolYearsAsync(CancellationToken token = default) => ExecuteAsync(
|
||||
async client => (IReadOnlyList<UntisSchoolYearDto>)(await client.GetSchoolYearsAsync(token))
|
||||
.Select(x => new UntisSchoolYearDto(x.UntisId, x.Name, x.StartDate, x.EndDate)).ToList(), token);
|
||||
|
||||
public Task<IReadOnlyList<UntisClassDto>> GetClassesAsync(int schoolYearId, CancellationToken token = default) =>
|
||||
GetAsync<IReadOnlyList<UntisClassDto>>($"/api/webuntis/classes?schoolyearId={schoolYearId}", token);
|
||||
public Task<IReadOnlyList<UntisClassDto>> GetClassesAsync(int schoolYearId, CancellationToken token = default) => ExecuteAsync(
|
||||
async client => (IReadOnlyList<UntisClassDto>)(await client.GetClassesAsync(schoolYearId, token))
|
||||
.Select(x => new UntisClassDto(x.UntisId, x.Name, x.LongName)).ToList(), token);
|
||||
|
||||
public Task<IReadOnlyList<UntisTeacherDto>> GetTeachersAsync(CancellationToken token = default) =>
|
||||
GetAsync<IReadOnlyList<UntisTeacherDto>>("/api/webuntis/teachers", token);
|
||||
public Task<IReadOnlyList<UntisTeacherDto>> GetTeachersAsync(CancellationToken token = default) => ExecuteAsync(
|
||||
async client => (IReadOnlyList<UntisTeacherDto>)(await client.GetTeachersAsync(token))
|
||||
.Select(x => new UntisTeacherDto(x.UntisId, x.Name, x.ForeName, x.LongName, x.Title, x.Active,
|
||||
x.DepartmentUntisIds)).ToList(), token);
|
||||
|
||||
public Task<IReadOnlyList<UntisTimeGridDayDto>> GetTimeGridAsync(CancellationToken token = default) =>
|
||||
GetAsync<IReadOnlyList<UntisTimeGridDayDto>>("/api/webuntis/timegrid", token);
|
||||
public Task<IReadOnlyList<UntisTimeGridDayDto>> GetTimeGridAsync(CancellationToken token = default) => ExecuteAsync(
|
||||
async client => (IReadOnlyList<UntisTimeGridDayDto>)(await client.GetTimeGridAsync(token))
|
||||
.Select(x => new UntisTimeGridDayDto(x.Day,
|
||||
x.TimeUnits.Select(t => new UntisTimeUnitDto(t.Name, t.StartTime, t.EndTime)).ToList())).ToList(), token);
|
||||
|
||||
public Task<IReadOnlyList<UntisTimetablePeriodDto>> GetTimetableAsync(int teacherId, DateOnly start,
|
||||
DateOnly end, CancellationToken token = default) => GetAsync<IReadOnlyList<UntisTimetablePeriodDto>>(
|
||||
$"/api/webuntis/timetable?elementType=teacher&elementId={teacherId}&startDate={Date(start)}&endDate={Date(end)}", token);
|
||||
DateOnly end, CancellationToken token = default) => ExecuteAsync(async client =>
|
||||
(IReadOnlyList<UntisTimetablePeriodDto>)(await client.GetTimetableAsync(
|
||||
UntisTimetableElementType.Teacher, teacherId, Date(start), Date(end), token))
|
||||
.Select(x => new UntisTimetablePeriodDto(x.Id, x.Date, x.StartTime, x.EndTime, x.Code,
|
||||
x.ActivityType, x.Info, x.LessonText, x.SubstitutionText, x.StudentGroup,
|
||||
Entities(x.Classes), Entities(x.Teachers), Entities(x.Subjects), Entities(x.Rooms))).ToList(), token);
|
||||
|
||||
public Task<UntisStudentReportDto> GetStudentsAsync(string className, CancellationToken token = default) =>
|
||||
GetAsync<UntisStudentReportDto>($"/api/webuntis/student-report?className={Uri.EscapeDataString(className)}", token);
|
||||
public Task<UntisStudentReportDto> GetStudentsAsync(string className, CancellationToken token = default) => ExecuteAsync(
|
||||
async client =>
|
||||
{
|
||||
var report = await client.GetStudentReportAsync(className, token);
|
||||
return new UntisStudentReportDto(report.Count, report.ClassNameFilter, report.Students.Select(x =>
|
||||
new UntisStudentDto(x.UntisId, x.ExternKey, x.ClassName, x.Name, x.LongName, x.ForeName,
|
||||
x.DisplayName, x.Gender, x.BirthDate, x.BirthDateRaw, x.EntryDate, x.EntryDateRaw,
|
||||
x.ExitDate, x.ExitDateRaw, x.Text, x.MedicalReportDuty, x.Schulpflicht, x.Majority,
|
||||
new UntisStudentAddressDto(x.Address.Email, x.Address.Mobile, x.Address.Phone, x.Address.City,
|
||||
x.Address.PostCode, x.Address.Street), x.AttributeIL)).ToList());
|
||||
}, token);
|
||||
|
||||
public Task<UntisStudentAbsenceReportDto> GetAbsencesAsync(int studentKey, DateOnly start, DateOnly end,
|
||||
CancellationToken token = default) => GetAsync<UntisStudentAbsenceReportDto>(
|
||||
$"/api/webuntis/students/{studentKey}/absences?startDate={Date(start)}&endDate={Date(end)}", token);
|
||||
|
||||
private async Task<T> GetAsync<T>(string path, CancellationToken token)
|
||||
CancellationToken token = default) => ExecuteAsync(async client =>
|
||||
{
|
||||
await EnsureConnectedAsync(token);
|
||||
using var response = await SendAsync(HttpMethod.Get, path, null, token);
|
||||
await EnsureSuccessAsync(response);
|
||||
return await response.Content.ReadFromJsonAsync<T>(cancellationToken: token)
|
||||
?? throw new WebUntisIntegrationException("Der Server hat keine WebUntis-Daten zurückgegeben.");
|
||||
var report = await client.GetStudentAbsencesAsync(studentKey, Date(start), Date(end), token);
|
||||
return new UntisStudentAbsenceReportDto(report.StudentKey, report.StartDate, report.EndDate,
|
||||
report.EntryCount, report.AbsentMinutes, report.Absences.Select(x => new UntisStudentAbsenceDto(
|
||||
x.StudentKey, x.Date, x.StartTime, x.EndTime, x.AbsentMinutes, x.Checked, x.AbsenceReason,
|
||||
x.ExcuseStatus, x.SubjectId, x.TeacherIds, x.StudentGroup)).ToList());
|
||||
}, token);
|
||||
|
||||
private async Task<T> ExecuteAsync<T>(Func<WebUntisClient, Task<T>> operation, CancellationToken token)
|
||||
{
|
||||
try { return await operation(await GetClientAsync(token)); }
|
||||
catch (Exception exception) when (IsUntisError(exception)) { throw Translate(exception); }
|
||||
}
|
||||
|
||||
private async Task EnsureConnectedAsync(CancellationToken token)
|
||||
private async Task<WebUntisClient> GetClientAsync(CancellationToken token)
|
||||
{
|
||||
var credentials = settings.GetApiCredentials()
|
||||
?? throw new WebUntisIntegrationException("Bitte zuerst die WebUntis-Anmeldedaten einrichten.");
|
||||
using var status = await SendAsync(HttpMethod.Get, "/api/webuntis/connection", null, token);
|
||||
await EnsureSuccessAsync(status);
|
||||
using var json = JsonDocument.Parse(await status.Content.ReadAsStringAsync(token));
|
||||
if (!json.RootElement.GetProperty("connected").GetBoolean()) await ConnectAsync(credentials, token);
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> SendAsync(HttpMethod method, string path, object? body,
|
||||
CancellationToken token)
|
||||
{
|
||||
if (!syncSettings.IsLoggedIn || string.IsNullOrWhiteSpace(syncSettings.ServerUrl))
|
||||
throw new WebUntisIntegrationException("Bitte zuerst unter „Synchronisation“ am LehrerApp-Server anmelden.");
|
||||
using var request = new HttpRequestMessage(method, new Uri(new Uri(syncSettings.ServerUrl), path));
|
||||
request.Headers.Authorization = new("Bearer", syncSettings.GetToken());
|
||||
if (body is not null) request.Content = JsonContent.Create(body);
|
||||
try { return await http.SendAsync(request, token); }
|
||||
catch (HttpRequestException) { throw new WebUntisIntegrationException("Der LehrerApp-Server ist nicht erreichbar."); }
|
||||
}
|
||||
|
||||
private static async Task EnsureSuccessAsync(HttpResponseMessage response)
|
||||
{
|
||||
if (response.IsSuccessStatusCode) return;
|
||||
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||
throw new WebUntisIntegrationException("Die Server-Anmeldung ist abgelaufen. Bitte erneut anmelden.");
|
||||
if (response.StatusCode == HttpStatusCode.NotFound &&
|
||||
response.RequestMessage?.RequestUri?.AbsolutePath.StartsWith("/api/webuntis",
|
||||
StringComparison.OrdinalIgnoreCase) == true)
|
||||
throw new WebUntisIntegrationException(
|
||||
"Der konfigurierte LehrerApp-Server enthält die WebUntis-Integration noch nicht. " +
|
||||
"Bitte zuerst die aktuelle LehrerApp.Api auf dem Server bereitstellen.");
|
||||
if (_client is not null) return _client;
|
||||
await _clientGate.WaitAsync(token);
|
||||
try
|
||||
{
|
||||
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
if (json.RootElement.TryGetProperty("detail", out var detail) && !string.IsNullOrWhiteSpace(detail.GetString()))
|
||||
throw new WebUntisIntegrationException(detail.GetString()!);
|
||||
if (_client is not null) return _client;
|
||||
var credentials = settings.GetApiCredentials()
|
||||
?? throw new WebUntisIntegrationException("Bitte zuerst die WebUntis-Anmeldedaten einrichten.");
|
||||
return _client = CreateClient(credentials);
|
||||
}
|
||||
catch (JsonException) { }
|
||||
throw new WebUntisIntegrationException("Der WebUntis-Abruf ist fehlgeschlagen.");
|
||||
finally { _clientGate.Release(); }
|
||||
}
|
||||
|
||||
private WebUntisClient CreateClient(WebUntisCredentials credentials) => new(http, new WebUntisOptions
|
||||
{
|
||||
School = credentials.School, Host = credentials.Host, Username = credentials.Username,
|
||||
Password = credentials.Password, Client = "LehrerApp-Desktop", SessionIdleTimeoutMinutes = 10,
|
||||
});
|
||||
|
||||
private static int Date(DateOnly date) => date.Year * 10000 + date.Month * 100 + date.Day;
|
||||
private static IReadOnlyList<UntisEntityDto> Entities(IReadOnlyList<UntisEntity> values) => values
|
||||
.Select(x => new UntisEntityDto(x.Id, x.Name, x.OriginalId, x.OriginalName, x.ExternalKey)).ToList();
|
||||
private static bool IsUntisError(Exception exception) => exception is WebUntisException
|
||||
or WebUntisConfigurationException or InvalidDataException;
|
||||
private static WebUntisIntegrationException Translate(Exception exception) =>
|
||||
new(exception.Message);
|
||||
|
||||
public async ValueTask DisposeAsync() => await DisconnectAsync();
|
||||
}
|
||||
|
||||
@@ -971,7 +971,7 @@
|
||||
|
||||
<TextBlock Text="WebUntis-API" FontSize="16" FontWeight="SemiBold"/>
|
||||
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
|
||||
Text="Ermöglicht Stundenplan-, Schüler- und Fehlzeitenabgleich. Die Zugangsdaten werden nur auf diesem Gerät verschlüsselt gespeichert; der LehrerApp-Server hält sie und die WebUntis-Sitzung nur im Arbeitsspeicher."/>
|
||||
Text="Ermöglicht Stundenplan-, Schüler- und Fehlzeitenabgleich direkt zwischen diesem Gerät und WebUntis. Zugangsdaten werden lokal verschlüsselt gespeichert; weder sie noch Schülerdaten oder CSV-Reports passieren den LehrerApp-Server."/>
|
||||
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto" ColumnSpacing="8" RowSpacing="8">
|
||||
<StackPanel Grid.Row="0" Grid.Column="0" Spacing="4">
|
||||
<TextBlock Text="Schule" FontSize="12" Opacity="0.7"/>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LehrerApp.WebUntis\LehrerApp.WebUntis.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
+9
-9
@@ -1,9 +1,9 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
using LehrerApp.WebUntis;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Api.Tests;
|
||||
namespace LehrerApp.WebUntis.Tests;
|
||||
|
||||
public sealed class WebUntisClientTests
|
||||
{
|
||||
@@ -75,7 +75,7 @@ public sealed class WebUntisClientTests
|
||||
public async Task FehlendeKonfiguration_BrichtVorHttpRequestAb()
|
||||
{
|
||||
var handler = new QueueHandler();
|
||||
var client = new WebUntisClient(new HttpClient(handler), Options.Create(new WebUntisOptions()));
|
||||
var client = new WebUntisClient(new HttpClient(handler), new WebUntisOptions());
|
||||
|
||||
await Assert.ThrowsAsync<WebUntisConfigurationException>(
|
||||
() => client.GetSchoolYearsAsync(CancellationToken.None));
|
||||
@@ -91,12 +91,12 @@ public sealed class WebUntisClientTests
|
||||
Json("{\"result\":{\"sessionId\":\"s\"}}"),
|
||||
Json("{\"result\":[{\"id\":1,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"),
|
||||
Json("{\"result\":{}}"));
|
||||
var client = new WebUntisClient(new HttpClient(handler), Options.Create(new WebUntisOptions
|
||||
var client = new WebUntisClient(new HttpClient(handler), new WebUntisOptions
|
||||
{
|
||||
School = "https://arche.webuntis.com/WebUntis/?school=bk-ostvest#/basic/login",
|
||||
Username = "api-user",
|
||||
Password = "secret",
|
||||
}));
|
||||
});
|
||||
|
||||
await client.GetSchoolYearsAsync(CancellationToken.None);
|
||||
|
||||
@@ -112,13 +112,13 @@ public sealed class WebUntisClientTests
|
||||
Json("{\"result\":{\"sessionId\":\"s\"}}"),
|
||||
Json("{\"result\":[{\"id\":1,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"),
|
||||
Json("{\"result\":{}}"));
|
||||
var client = new WebUntisClient(new HttpClient(handler), Options.Create(new WebUntisOptions
|
||||
var client = new WebUntisClient(new HttpClient(handler), new WebUntisOptions
|
||||
{
|
||||
School = "bk-ostvest",
|
||||
Host = "https://arche.webuntis.com/WebUntis/?school=bk-ostvest#/basic/login",
|
||||
Username = "api-user",
|
||||
Password = "secret",
|
||||
}));
|
||||
});
|
||||
|
||||
await client.GetSchoolYearsAsync(CancellationToken.None);
|
||||
|
||||
@@ -175,14 +175,14 @@ public sealed class WebUntisClientTests
|
||||
|
||||
private static WebUntisClient CreateClient(HttpMessageHandler handler) => new(
|
||||
new HttpClient(handler),
|
||||
Options.Create(new WebUntisOptions
|
||||
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)
|
||||
{
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
using Xunit;
|
||||
using LehrerApp.WebUntis;
|
||||
|
||||
namespace LehrerApp.Api.Tests;
|
||||
namespace LehrerApp.WebUntis.Tests;
|
||||
|
||||
public sealed class WebUntisStudentReportParserTests
|
||||
{
|
||||
@@ -0,0 +1,5 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -1,10 +1,9 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace LehrerApp.Api;
|
||||
namespace LehrerApp.WebUntis;
|
||||
|
||||
public sealed class WebUntisClient : IAsyncDisposable
|
||||
{
|
||||
@@ -19,10 +18,10 @@ public sealed class WebUntisClient : IAsyncDisposable
|
||||
private int _activeRequests;
|
||||
private bool _disposed;
|
||||
|
||||
public WebUntisClient(HttpClient http, IOptions<WebUntisOptions> options)
|
||||
public WebUntisClient(HttpClient http, WebUntisOptions options)
|
||||
{
|
||||
_http = http;
|
||||
_options = options.Value;
|
||||
_options = options;
|
||||
_sessionExpiryTimer = new Timer(
|
||||
static state => _ = ((WebUntisClient)state!).CloseExpiredSessionAsync(),
|
||||
this,
|
||||
@@ -403,12 +402,8 @@ public sealed class WebUntisClient : IAsyncDisposable
|
||||
|
||||
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);
|
||||
var uri = $"https://{GetConfiguration().Host}/WebUntis/reports.do" +
|
||||
"?name=Student&format=csv&klasseId=-1&studentsForDate=true&context=klasseId";
|
||||
using var request = ReportRequest(uri, sessionId, acceptJson: true);
|
||||
using var response = await SendAsync(request, TimeSpan.FromSeconds(20), cancellationToken);
|
||||
var payload = await ReadJsonAsync(response, cancellationToken);
|
||||
@@ -525,9 +520,8 @@ public sealed class WebUntisClient : IAsyncDisposable
|
||||
if (LooksLikeLocation(school) && TryParseLocation(school, out var schoolLocation))
|
||||
{
|
||||
hostFromSchool = schoolLocation.Host;
|
||||
school = QueryHelpers.ParseQuery(schoolLocation.Query).TryGetValue("school", out var querySchool) &&
|
||||
!string.IsNullOrWhiteSpace(querySchool)
|
||||
? querySchool.ToString().Trim()
|
||||
school = QueryValue(schoolLocation, "school") is { Length: > 0 } querySchool
|
||||
? querySchool
|
||||
: schoolLocation.Host.Split('.')[0];
|
||||
}
|
||||
|
||||
@@ -539,10 +533,8 @@ public sealed class WebUntisClient : IAsyncDisposable
|
||||
host = hostLocation.Host;
|
||||
// Komfortfall: In das Serverfeld wurde die vollständige Login-URL kopiert. Eine
|
||||
// explizit im Schulfeld angegebene Kennung behält trotzdem Vorrang.
|
||||
if (LooksLikeLocation(schoolValue) &&
|
||||
QueryHelpers.ParseQuery(hostLocation.Query).TryGetValue("school", out var querySchool) &&
|
||||
!string.IsNullOrWhiteSpace(querySchool))
|
||||
school = querySchool.ToString().Trim();
|
||||
if (LooksLikeLocation(schoolValue) && QueryValue(hostLocation, "school") is { Length: > 0 } querySchool)
|
||||
school = querySchool;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -567,6 +559,19 @@ public sealed class WebUntisClient : IAsyncDisposable
|
||||
return Uri.TryCreate(candidate, UriKind.Absolute, out location!) && !string.IsNullOrWhiteSpace(location.Host);
|
||||
}
|
||||
|
||||
private static string? QueryValue(Uri uri, string key)
|
||||
{
|
||||
foreach (var part in uri.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var pair = part.Split('=', 2);
|
||||
if (!Uri.UnescapeDataString(pair[0]).Equals(key, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
return pair.Length == 2
|
||||
? Uri.UnescapeDataString(pair[1].Replace('+', ' ')).Trim()
|
||||
: "";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private string SchoolCookie() => Convert.ToBase64String(Encoding.UTF8.GetBytes(GetConfiguration().School));
|
||||
|
||||
private static async Task<JsonElement> ReadJsonAsync(HttpResponseMessage response, CancellationToken token) =>
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LehrerApp.Api;
|
||||
namespace LehrerApp.WebUntis;
|
||||
|
||||
public sealed class WebUntisOptions
|
||||
{
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace LehrerApp.Api;
|
||||
namespace LehrerApp.WebUntis;
|
||||
|
||||
public static class WebUntisStudentReportParser
|
||||
{
|
||||
@@ -6,6 +6,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Data", "LehrerApp
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Sync", "LehrerApp.Sync\LehrerApp.Sync.csproj", "{A1000003-0000-0000-0000-000000000003}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.WebUntis", "LehrerApp.WebUntis\LehrerApp.WebUntis.csproj", "{A1000007-0000-0000-0000-000000000007}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.WebUntis.Tests", "LehrerApp.WebUntis.Tests\LehrerApp.WebUntis.Tests.csproj", "{A1000008-0000-0000-0000-000000000008}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Api", "LehrerApp.Api\LehrerApp.Api.csproj", "{A1000004-0000-0000-0000-000000000004}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Desktop", "LehrerApp.Desktop\LehrerApp.Desktop.csproj", "{A1000005-0000-0000-0000-000000000005}"
|
||||
@@ -138,6 +142,30 @@ Global
|
||||
{E8152216-11F1-427E-B189-D8CEC9A71C33}.Release|x64.Build.0 = Release|Any CPU
|
||||
{E8152216-11F1-427E-B189-D8CEC9A71C33}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{E8152216-11F1-427E-B189-D8CEC9A71C33}.Release|x86.Build.0 = Release|Any CPU
|
||||
{A1000007-0000-0000-0000-000000000007}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A1000007-0000-0000-0000-000000000007}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A1000007-0000-0000-0000-000000000007}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{A1000007-0000-0000-0000-000000000007}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A1000007-0000-0000-0000-000000000007}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A1000007-0000-0000-0000-000000000007}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A1000007-0000-0000-0000-000000000007}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A1000007-0000-0000-0000-000000000007}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A1000007-0000-0000-0000-000000000007}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A1000007-0000-0000-0000-000000000007}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A1000007-0000-0000-0000-000000000007}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A1000007-0000-0000-0000-000000000007}.Release|x86.Build.0 = Release|Any CPU
|
||||
{A1000008-0000-0000-0000-000000000008}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A1000008-0000-0000-0000-000000000008}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A1000008-0000-0000-0000-000000000008}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{A1000008-0000-0000-0000-000000000008}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A1000008-0000-0000-0000-000000000008}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A1000008-0000-0000-0000-000000000008}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A1000008-0000-0000-0000-000000000008}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A1000008-0000-0000-0000-000000000008}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A1000008-0000-0000-0000-000000000008}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A1000008-0000-0000-0000-000000000008}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A1000008-0000-0000-0000-000000000008}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A1000008-0000-0000-0000-000000000008}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@@ -1223,9 +1223,9 @@ echte DATE-Ganztags-/Mehrtagstermine.
|
||||
**Nachtrag zu 4.3, WebUntis-API-Integration (August 2026):** Zusätzlich zu den unverändert
|
||||
weiterlaufenden iCal-Quellen kann ein persönlicher WebUntis-Zugang im selben Einstellungsreiter
|
||||
hinterlegt werden. Schule, Host, Benutzername und Passwort liegen auf dem Desktop AES-256-GCM-
|
||||
verschlüsselt; der API-Server persistiert sie nicht, sondern hält je LehrerApp-Benutzer einen
|
||||
eigenen Client im Arbeitsspeicher. Dessen WebUntis-Sitzung bleibt nach einem Abruf zehn Minuten
|
||||
offen und wird bei weiterer Nutzung wiederverwendet.
|
||||
verschlüsselt. Der Desktop verbindet sich direkt mit WebUntis und hält dessen Sitzung nach einem
|
||||
Abruf zehn Minuten offen. Der LehrerApp-Server besitzt bewusst weder WebUntis-Endpunkte noch
|
||||
Zugangsdaten; Schülerdaten, Fehlzeiten und der unverschlüsselte CSV-Report passieren ihn nie.
|
||||
|
||||
- Der Bearbeiten-Tab des Stundenplans lädt eine wählbare Lehrer-/Kalenderwoche, ordnet die
|
||||
WebUntis-Zeitraster auf lokale Stundennummern ab und zeigt vor dem Speichern jede Zuordnung.
|
||||
|
||||
@@ -85,54 +85,6 @@ 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
|
||||
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
|
||||
|
||||
Kein manuelles Bauen/Hochladen nötig – Dokploy zieht das Repo direkt per Git und baut das Image
|
||||
|
||||
@@ -13,12 +13,6 @@ services:
|
||||
environment:
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- 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
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
Reference in New Issue
Block a user