Untis-API von Server zu Client

This commit is contained in:
2026-08-24 22:00:26 +02:00
parent 0f10f754d0
commit 34a9fdf73b
21 changed files with 243 additions and 445 deletions
-10
View File
@@ -7,13 +7,3 @@ 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
+4
View File
@@ -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 orchestration, timer-driven), `SnapshotService`, `Crypto/SyncCrypto` (AES-256-GCM payload
encryption — desktop events are encrypted at rest and in transit; Companion/WebApp events are encryption — desktop events are encrypted at rest and in transit; Companion/WebApp events are
plaintext, see `PlainSyncEvent` vs `SyncEvent`). 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 - **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 `SnapshotStore`/`ReadableSnapshotStore` per device, mapped in `Endpoints/Endpoints.cs`. Sync is
optional — Desktop only registers `SyncEngine`/`SnapshotService` in DI when a server URL is optional — Desktop only registers `SyncEngine`/`SnapshotService` in DI when a server URL is
configured (`AppBootstrapper.LoadServerUrl`). configured (`AppBootstrapper.LoadServerUrl`).
- Each library has a matching `*.Tests` project (`LehrerApp.Tests` → Core, `LehrerApp.Data.Tests` - Each library has a matching `*.Tests` project (`LehrerApp.Tests` → Core, `LehrerApp.Data.Tests`
Data, `LehrerApp.Desktop.Tests` → Desktop, `LehrerApp.Sync.Tests` → Sync), all xUnit. 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) ### MVVM conventions (Desktop)
-171
View File
@@ -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) private static Dictionary<string, string[]> ValidateLocation(SchoolLocationRequest request)
{ {
var errors = new Dictionary<string, string[]>(); var errors = new Dictionary<string, string[]>();
-24
View File
@@ -97,29 +97,6 @@ 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>>()));
builder.Services.AddSingleton<WebUntisConnectionStore>();
var app = builder.Build(); var app = builder.Build();
app.UseForwardedHeaders(); app.UseForwardedHeaders();
@@ -135,6 +112,5 @@ app.MapSnapshotEndpoints();
app.MapReadableSnapshotEndpoints(); app.MapReadableSnapshotEndpoints();
app.MapPlainSyncEndpoints(); app.MapPlainSyncEndpoints();
app.MapSchoolWeatherEndpoints(); app.MapSchoolWeatherEndpoints();
app.MapWebUntisEndpoints();
app.Run(); app.Run();
return 0; return 0;
-75
View File
@@ -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);
}
+1 -1
View File
@@ -223,7 +223,7 @@ public static class AppBootstrapper
services.AddSingleton(syncSettings); services.AddSingleton(syncSettings);
services.AddSingleton(_ => new SyncAuthService(new HttpClient())); services.AddSingleton(_ => new SyncAuthService(new HttpClient()));
services.AddSingleton(sp => new SchoolWeatherService(new HttpClient(), syncSettings)); 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) // 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 // 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. // 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.Core\LehrerApp.Core.csproj" />
<ProjectReference Include="..\LehrerApp.Data\LehrerApp.Data.csproj" /> <ProjectReference Include="..\LehrerApp.Data\LehrerApp.Data.csproj" />
<ProjectReference Include="..\LehrerApp.Sync\LehrerApp.Sync.csproj" /> <ProjectReference Include="..\LehrerApp.Sync\LehrerApp.Sync.csproj" />
<ProjectReference Include="..\LehrerApp.WebUntis\LehrerApp.WebUntis.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia" /> <PackageReference Include="Avalonia" />
@@ -1,6 +1,4 @@
using System.Net; using LehrerApp.WebUntis;
using System.Net.Http.Json;
using System.Text.Json;
namespace LehrerApp.Desktop.Services; 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, public sealed record UntisStudentAbsenceReportDto(int StudentKey, int StartDate, int EndDate, int EntryCount,
int AbsentMinutes, IReadOnlyList<UntisStudentAbsenceDto> Absences); int AbsentMinutes, IReadOnlyList<UntisStudentAbsenceDto> Absences);
/// <summary>Authentifizierter Desktop-Client für die LehrerApp-API. Falls der Server neu gestartet /// <summary>Direkter WebUntis-Client des Desktops. Personenbezogene Antworten und der
/// wurde, baut er die nur im Server-RAM gehaltene WebUntis-Verbindung automatisch erneut auf.</summary> /// unverschlüsselte CSV-Report passieren zu keinem Zeitpunkt den LehrerApp-Server.</summary>
public sealed class WebUntisIntegrationService(HttpClient http, SyncSettingsService syncSettings, public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettingsService settings) : IAsyncDisposable
WebUntisSettingsService settings)
{ {
public bool IsAvailable => syncSettings.IsLoggedIn && !string.IsNullOrWhiteSpace(syncSettings.ServerUrl) private readonly SemaphoreSlim _clientGate = new(1, 1);
&& settings.ApiIsConfigured; private WebUntisClient? _client;
public bool IsAvailable => settings.ApiIsConfigured;
public async Task ConnectAsync(WebUntisCredentials credentials, CancellationToken token = default) 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, await candidate.GetSchoolYearsAsync(token);
Host = string.IsNullOrWhiteSpace(credentials.Host) ? null : credentials.Host, }
credentials.Username, catch (Exception exception) when (IsUntisError(exception))
credentials.Password, {
}, token); await candidate.DisposeAsync();
await EnsureSuccessAsync(response); 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) public async Task DisconnectAsync(CancellationToken token = default)
{ {
using var response = await SendAsync(HttpMethod.Delete, "/api/webuntis/connection", null, token); WebUntisClient? previous;
await EnsureSuccessAsync(response); 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) => public Task<IReadOnlyList<UntisSchoolYearDto>> GetSchoolYearsAsync(CancellationToken token = default) => ExecuteAsync(
GetAsync<IReadOnlyList<UntisSchoolYearDto>>("/api/webuntis/schoolyears", token); 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) => public Task<IReadOnlyList<UntisClassDto>> GetClassesAsync(int schoolYearId, CancellationToken token = default) => ExecuteAsync(
GetAsync<IReadOnlyList<UntisClassDto>>($"/api/webuntis/classes?schoolyearId={schoolYearId}", token); 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) => public Task<IReadOnlyList<UntisTeacherDto>> GetTeachersAsync(CancellationToken token = default) => ExecuteAsync(
GetAsync<IReadOnlyList<UntisTeacherDto>>("/api/webuntis/teachers", token); 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) => public Task<IReadOnlyList<UntisTimeGridDayDto>> GetTimeGridAsync(CancellationToken token = default) => ExecuteAsync(
GetAsync<IReadOnlyList<UntisTimeGridDayDto>>("/api/webuntis/timegrid", token); 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, public Task<IReadOnlyList<UntisTimetablePeriodDto>> GetTimetableAsync(int teacherId, DateOnly start,
DateOnly end, CancellationToken token = default) => GetAsync<IReadOnlyList<UntisTimetablePeriodDto>>( DateOnly end, CancellationToken token = default) => ExecuteAsync(async client =>
$"/api/webuntis/timetable?elementType=teacher&elementId={teacherId}&startDate={Date(start)}&endDate={Date(end)}", token); (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) => public Task<UntisStudentReportDto> GetStudentsAsync(string className, CancellationToken token = default) => ExecuteAsync(
GetAsync<UntisStudentReportDto>($"/api/webuntis/student-report?className={Uri.EscapeDataString(className)}", token); 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, public Task<UntisStudentAbsenceReportDto> GetAbsencesAsync(int studentKey, DateOnly start, DateOnly end,
CancellationToken token = default) => GetAsync<UntisStudentAbsenceReportDto>( CancellationToken token = default) => ExecuteAsync(async client =>
$"/api/webuntis/students/{studentKey}/absences?startDate={Date(start)}&endDate={Date(end)}", token);
private async Task<T> GetAsync<T>(string path, CancellationToken token)
{ {
await EnsureConnectedAsync(token); var report = await client.GetStudentAbsencesAsync(studentKey, Date(start), Date(end), token);
using var response = await SendAsync(HttpMethod.Get, path, null, token); return new UntisStudentAbsenceReportDto(report.StudentKey, report.StartDate, report.EndDate,
await EnsureSuccessAsync(response); report.EntryCount, report.AbsentMinutes, report.Absences.Select(x => new UntisStudentAbsenceDto(
return await response.Content.ReadFromJsonAsync<T>(cancellationToken: token) x.StudentKey, x.Date, x.StartTime, x.EndTime, x.AbsentMinutes, x.Checked, x.AbsenceReason,
?? throw new WebUntisIntegrationException("Der Server hat keine WebUntis-Daten zurückgegeben."); 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() if (_client is not null) return _client;
?? throw new WebUntisIntegrationException("Bitte zuerst die WebUntis-Anmeldedaten einrichten."); await _clientGate.WaitAsync(token);
using var status = await SendAsync(HttpMethod.Get, "/api/webuntis/connection", null, token);
await EnsureSuccessAsync(status);
using var json = JsonDocument.Parse(await status.Content.ReadAsStringAsync(token));
if (!json.RootElement.GetProperty("connected").GetBoolean()) await ConnectAsync(credentials, token);
}
private async Task<HttpResponseMessage> SendAsync(HttpMethod method, string path, object? body,
CancellationToken token)
{
if (!syncSettings.IsLoggedIn || string.IsNullOrWhiteSpace(syncSettings.ServerUrl))
throw new WebUntisIntegrationException("Bitte zuerst unter „Synchronisation“ am LehrerApp-Server anmelden.");
using var request = new HttpRequestMessage(method, new Uri(new Uri(syncSettings.ServerUrl), path));
request.Headers.Authorization = new("Bearer", syncSettings.GetToken());
if (body is not null) request.Content = JsonContent.Create(body);
try { return await http.SendAsync(request, token); }
catch (HttpRequestException) { throw new WebUntisIntegrationException("Der LehrerApp-Server ist nicht erreichbar."); }
}
private static async Task EnsureSuccessAsync(HttpResponseMessage response)
{
if (response.IsSuccessStatusCode) return;
if (response.StatusCode == HttpStatusCode.Unauthorized)
throw new WebUntisIntegrationException("Die Server-Anmeldung ist abgelaufen. Bitte erneut anmelden.");
if (response.StatusCode == HttpStatusCode.NotFound &&
response.RequestMessage?.RequestUri?.AbsolutePath.StartsWith("/api/webuntis",
StringComparison.OrdinalIgnoreCase) == true)
throw new WebUntisIntegrationException(
"Der konfigurierte LehrerApp-Server enthält die WebUntis-Integration noch nicht. " +
"Bitte zuerst die aktuelle LehrerApp.Api auf dem Server bereitstellen.");
try try
{ {
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); if (_client is not null) return _client;
if (json.RootElement.TryGetProperty("detail", out var detail) && !string.IsNullOrWhiteSpace(detail.GetString())) var credentials = settings.GetApiCredentials()
throw new WebUntisIntegrationException(detail.GetString()!); ?? throw new WebUntisIntegrationException("Bitte zuerst die WebUntis-Anmeldedaten einrichten.");
return _client = CreateClient(credentials);
} }
catch (JsonException) { } finally { _clientGate.Release(); }
throw new WebUntisIntegrationException("Der WebUntis-Abruf ist fehlgeschlagen.");
} }
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 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 Text="WebUntis-API" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap" <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"> <Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto" ColumnSpacing="8" RowSpacing="8">
<StackPanel Grid.Row="0" Grid.Column="0" Spacing="4"> <StackPanel Grid.Row="0" Grid.Column="0" Spacing="4">
<TextBlock Text="Schule" FontSize="12" Opacity="0.7"/> <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>
@@ -1,9 +1,9 @@
using System.Net; using System.Net;
using System.Text; using System.Text;
using Microsoft.Extensions.Options; using LehrerApp.WebUntis;
using Xunit; using Xunit;
namespace LehrerApp.Api.Tests; namespace LehrerApp.WebUntis.Tests;
public sealed class WebUntisClientTests public sealed class WebUntisClientTests
{ {
@@ -75,7 +75,7 @@ public sealed class WebUntisClientTests
public async Task FehlendeKonfiguration_BrichtVorHttpRequestAb() public async Task FehlendeKonfiguration_BrichtVorHttpRequestAb()
{ {
var handler = new QueueHandler(); 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>( await Assert.ThrowsAsync<WebUntisConfigurationException>(
() => client.GetSchoolYearsAsync(CancellationToken.None)); () => client.GetSchoolYearsAsync(CancellationToken.None));
@@ -91,12 +91,12 @@ public sealed class WebUntisClientTests
Json("{\"result\":{\"sessionId\":\"s\"}}"), Json("{\"result\":{\"sessionId\":\"s\"}}"),
Json("{\"result\":[{\"id\":1,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"), Json("{\"result\":[{\"id\":1,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"),
Json("{\"result\":{}}")); 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", School = "https://arche.webuntis.com/WebUntis/?school=bk-ostvest#/basic/login",
Username = "api-user", Username = "api-user",
Password = "secret", Password = "secret",
})); });
await client.GetSchoolYearsAsync(CancellationToken.None); await client.GetSchoolYearsAsync(CancellationToken.None);
@@ -112,13 +112,13 @@ public sealed class WebUntisClientTests
Json("{\"result\":{\"sessionId\":\"s\"}}"), Json("{\"result\":{\"sessionId\":\"s\"}}"),
Json("{\"result\":[{\"id\":1,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"), Json("{\"result\":[{\"id\":1,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"),
Json("{\"result\":{}}")); 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", School = "bk-ostvest",
Host = "https://arche.webuntis.com/WebUntis/?school=bk-ostvest#/basic/login", Host = "https://arche.webuntis.com/WebUntis/?school=bk-ostvest#/basic/login",
Username = "api-user", Username = "api-user",
Password = "secret", Password = "secret",
})); });
await client.GetSchoolYearsAsync(CancellationToken.None); await client.GetSchoolYearsAsync(CancellationToken.None);
@@ -175,14 +175,14 @@ public sealed class WebUntisClientTests
private static WebUntisClient CreateClient(HttpMessageHandler handler) => new( private static WebUntisClient CreateClient(HttpMessageHandler handler) => new(
new HttpClient(handler), new HttpClient(handler),
Options.Create(new WebUntisOptions new WebUntisOptions
{ {
School = "meine-schule", School = "meine-schule",
Host = "meine-schule.webuntis.com", Host = "meine-schule.webuntis.com",
Username = "api-user", Username = "api-user",
Password = "secret", Password = "secret",
Client = "tests", Client = "tests",
})); });
private static HttpResponseMessage Json(string json) => new(HttpStatusCode.OK) private static HttpResponseMessage Json(string json) => new(HttpStatusCode.OK)
{ {
@@ -1,6 +1,7 @@
using Xunit; using Xunit;
using LehrerApp.WebUntis;
namespace LehrerApp.Api.Tests; namespace LehrerApp.WebUntis.Tests;
public sealed class WebUntisStudentReportParserTests 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.Headers;
using System.Net.Http.Json;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Options;
namespace LehrerApp.Api; namespace LehrerApp.WebUntis;
public sealed class WebUntisClient : IAsyncDisposable public sealed class WebUntisClient : IAsyncDisposable
{ {
@@ -19,10 +18,10 @@ public sealed class WebUntisClient : IAsyncDisposable
private int _activeRequests; private int _activeRequests;
private bool _disposed; private bool _disposed;
public WebUntisClient(HttpClient http, IOptions<WebUntisOptions> options) public WebUntisClient(HttpClient http, WebUntisOptions options)
{ {
_http = http; _http = http;
_options = options.Value; _options = options;
_sessionExpiryTimer = new Timer( _sessionExpiryTimer = new Timer(
static state => _ = ((WebUntisClient)state!).CloseExpiredSessionAsync(), static state => _ = ((WebUntisClient)state!).CloseExpiredSessionAsync(),
this, this,
@@ -403,12 +402,8 @@ public sealed class WebUntisClient : IAsyncDisposable
private async Task<ReportData?> RequestReportAsync(string sessionId, CancellationToken cancellationToken) private async Task<ReportData?> RequestReportAsync(string sessionId, CancellationToken cancellationToken)
{ {
var query = new Dictionary<string, string?> var uri = $"https://{GetConfiguration().Host}/WebUntis/reports.do" +
{ "?name=Student&format=csv&klasseId=-1&studentsForDate=true&context=klasseId";
["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 request = ReportRequest(uri, sessionId, acceptJson: true);
using var response = await SendAsync(request, TimeSpan.FromSeconds(20), cancellationToken); using var response = await SendAsync(request, TimeSpan.FromSeconds(20), cancellationToken);
var payload = await ReadJsonAsync(response, cancellationToken); var payload = await ReadJsonAsync(response, cancellationToken);
@@ -525,9 +520,8 @@ public sealed class WebUntisClient : IAsyncDisposable
if (LooksLikeLocation(school) && TryParseLocation(school, out var schoolLocation)) if (LooksLikeLocation(school) && TryParseLocation(school, out var schoolLocation))
{ {
hostFromSchool = schoolLocation.Host; hostFromSchool = schoolLocation.Host;
school = QueryHelpers.ParseQuery(schoolLocation.Query).TryGetValue("school", out var querySchool) && school = QueryValue(schoolLocation, "school") is { Length: > 0 } querySchool
!string.IsNullOrWhiteSpace(querySchool) ? querySchool
? querySchool.ToString().Trim()
: schoolLocation.Host.Split('.')[0]; : schoolLocation.Host.Split('.')[0];
} }
@@ -539,10 +533,8 @@ public sealed class WebUntisClient : IAsyncDisposable
host = hostLocation.Host; host = hostLocation.Host;
// Komfortfall: In das Serverfeld wurde die vollständige Login-URL kopiert. Eine // Komfortfall: In das Serverfeld wurde die vollständige Login-URL kopiert. Eine
// explizit im Schulfeld angegebene Kennung behält trotzdem Vorrang. // explizit im Schulfeld angegebene Kennung behält trotzdem Vorrang.
if (LooksLikeLocation(schoolValue) && if (LooksLikeLocation(schoolValue) && QueryValue(hostLocation, "school") is { Length: > 0 } querySchool)
QueryHelpers.ParseQuery(hostLocation.Query).TryGetValue("school", out var querySchool) && school = querySchool;
!string.IsNullOrWhiteSpace(querySchool))
school = querySchool.ToString().Trim();
} }
else else
{ {
@@ -567,6 +559,19 @@ public sealed class WebUntisClient : IAsyncDisposable
return Uri.TryCreate(candidate, UriKind.Absolute, out location!) && !string.IsNullOrWhiteSpace(location.Host); 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 string SchoolCookie() => Convert.ToBase64String(Encoding.UTF8.GetBytes(GetConfiguration().School));
private static async Task<JsonElement> ReadJsonAsync(HttpResponseMessage response, CancellationToken token) => private static async Task<JsonElement> ReadJsonAsync(HttpResponseMessage response, CancellationToken token) =>
@@ -1,6 +1,6 @@
using System.Text.Json; using System.Text.Json;
namespace LehrerApp.Api; namespace LehrerApp.WebUntis;
public sealed class WebUntisOptions public sealed class WebUntisOptions
{ {
@@ -1,7 +1,7 @@
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
namespace LehrerApp.Api; namespace LehrerApp.WebUntis;
public static class WebUntisStudentReportParser public static class WebUntisStudentReportParser
{ {
+28
View File
@@ -6,6 +6,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Data", "LehrerApp
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Sync", "LehrerApp.Sync\LehrerApp.Sync.csproj", "{A1000003-0000-0000-0000-000000000003}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Sync", "LehrerApp.Sync\LehrerApp.Sync.csproj", "{A1000003-0000-0000-0000-000000000003}"
EndProject 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}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Api", "LehrerApp.Api\LehrerApp.Api.csproj", "{A1000004-0000-0000-0000-000000000004}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Desktop", "LehrerApp.Desktop\LehrerApp.Desktop.csproj", "{A1000005-0000-0000-0000-000000000005}" 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|x64.Build.0 = Release|Any CPU
{E8152216-11F1-427E-B189-D8CEC9A71C33}.Release|x86.ActiveCfg = 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 {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 EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
+3 -3
View File
@@ -1223,9 +1223,9 @@ echte DATE-Ganztags-/Mehrtagstermine.
**Nachtrag zu 4.3, WebUntis-API-Integration (August 2026):** Zusätzlich zu den unverändert **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 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- 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 verschlüsselt. Der Desktop verbindet sich direkt mit WebUntis und hält dessen Sitzung nach einem
eigenen Client im Arbeitsspeicher. Dessen WebUntis-Sitzung bleibt nach einem Abruf zehn Minuten Abruf zehn Minuten offen. Der LehrerApp-Server besitzt bewusst weder WebUntis-Endpunkte noch
offen und wird bei weiterer Nutzung wiederverwendet. 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 - 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. WebUntis-Zeitraster auf lokale Stundennummern ab und zeigt vor dem Speichern jede Zuordnung.
-48
View File
@@ -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 `LehrerApp-Server/1.0 (+https://schule.example)`. Der Container benötigt ausgehenden HTTPS-Zugriff
auf `nominatim.openstreetmap.org`, `www.dwd.de` und `opendata.dwd.de`. auf `nominatim.openstreetmap.org`, `www.dwd.de` und `opendata.dwd.de`.
## WebUntis
Die WebUntis-Zugangsdaten werden ausschließlich im API-Container konfiguriert und nie an den
Desktop-Client ausgegeben:
```dotenv
WEBUNTIS_SCHOOL=meine-schule
WEBUNTIS_HOST=meine-schule.webuntis.com
WEBUNTIS_USER=technischer-benutzer
WEBUNTIS_PASSWORD=geheimes-passwort
WEBUNTIS_CLIENT=LehrerApp
WEBUNTIS_SESSION_IDLE_MINUTES=10
```
`WEBUNTIS_HOST` kann entfallen, wenn der Host `<WEBUNTIS_SCHOOL>.webuntis.com` entspricht. Der
technische Benutzer benötigt die jeweiligen WebUntis-Leserechte und darf für die alte JSON-RPC-
Schnittstelle keine aktivierte Zwei-Faktor-Authentifizierung haben.
Der API-Server meldet sich beim ersten Abruf an und verwendet diese Sitzung für weitere Abrufe.
Erst wenn zehn Minuten lang kein WebUntis-Abruf mehr aktiv war, meldet er sich automatisch ab.
`WEBUNTIS_SESSION_IDLE_MINUTES` kann bei Bedarf auf einen Wert zwischen 1 und 30 Minuten geändert
werden.
Alle Endpunkte benötigen dasselbe Bearer-Token wie die Sync-API und führen genau einen Abruf aus;
sie speichern das Ergebnis nicht serverseitig:
| Endpunkt | Zweck |
| --- | --- |
| `GET /api/webuntis/student-report?className=7a` | Schülerreport, optional nach Klasse gefiltert |
| `GET /api/webuntis/schoolyears` | Schuljahre |
| `GET /api/webuntis/classes?schoolyearId=123` | Klassen eines Schuljahres |
| `GET /api/webuntis/teachers` | Lehrkräfte und WebUntis-IDs |
| `GET /api/webuntis/holidays` | Ferienzeiträume |
| `GET /api/webuntis/timegrid` | Stunden-/Zeitraster |
| `GET /api/webuntis/substitutions?startDate=20260824&endDate=20260828` | Vertretungen im Zeitraum |
| `GET /api/webuntis/timetable?elementType=teacher&elementId=42&startDate=20260824&endDate=20260828` | Stundenplan für Klasse, Lehrkraft, Fach, Raum oder Schüler |
| `GET /api/webuntis/students/9001/absences?startDate=20260801&endDate=20270731` | Fehlzeiten eines Schülers; `9001` ist der `externKey` aus dem Schülerreport |
| `GET /api/webuntis/students/17/class-register-entries?startDate=20260801&endDate=20270731` | Klassenbucheinträge eines Schülers; `17` ist dessen `untisId` |
| `GET /api/webuntis/class-register/categories` | Kategorien der Klassenbucheinträge |
| `GET /api/webuntis/class-register/category-groups` | Kategoriegruppen der Klassenbucheinträge |
Datumsparameter verwenden das WebUntis-Format `yyyyMMdd`. Vertretungen sind auf 31 Tage und
Stundenpläne auf 62 Tage pro Anfrage begrenzt, damit ein einzelner Client keine unkontrolliert
großen WebUntis-Abfragen auslösen kann. Fehlzeiten und Klassenbucheinträge dürfen für einen
kompletten Schuljahreszeitraum von bis zu 400 Tagen geladen werden. Diese Klassenbuchfunktionen
sind nur verfügbar, wenn das Modul an der Schule aktiv ist und der technische Benutzer die
benötigten Leserechte besitzt.
## Deployment über Dokploy ## Deployment über Dokploy
Kein manuelles Bauen/Hochladen nötig Dokploy zieht das Repo direkt per Git und baut das Image Kein manuelles Bauen/Hochladen nötig Dokploy zieht das Repo direkt per Git und baut das Image
-6
View File
@@ -13,12 +13,6 @@ 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