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.
|
# 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
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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[]>();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(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>
|
||||||
+9
-9
@@ -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)
|
||||||
{
|
{
|
||||||
+2
-1
@@ -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
-1
@@ -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
|
||||||
{
|
{
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user