Untis API Integration

This commit is contained in:
2026-08-24 21:52:00 +02:00
parent 5841d96c5b
commit 0f10f754d0
26 changed files with 1237 additions and 58 deletions
@@ -84,6 +84,49 @@ public sealed class WebUntisClientTests
await client.DisposeAsync(); await client.DisposeAsync();
} }
[Fact]
public async Task VollstaendigeLoginUrl_TrenntRegionalenServerUndSchulkennung()
{
var handler = new QueueHandler(
Json("{\"result\":{\"sessionId\":\"s\"}}"),
Json("{\"result\":[{\"id\":1,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"),
Json("{\"result\":{}}"));
var client = new WebUntisClient(new HttpClient(handler), Options.Create(new WebUntisOptions
{
School = "https://arche.webuntis.com/WebUntis/?school=bk-ostvest#/basic/login",
Username = "api-user",
Password = "secret",
}));
await client.GetSchoolYearsAsync(CancellationToken.None);
Assert.StartsWith("https://arche.webuntis.com/WebUntis/jsonrpc.do", handler.Requests[0].Uri);
Assert.Contains("school=bk-ostvest", handler.Requests[0].Uri);
await client.DisposeAsync();
}
[Fact]
public async Task VollstaendigeServerUrl_WirdOhnePfadVerwendet()
{
var handler = new QueueHandler(
Json("{\"result\":{\"sessionId\":\"s\"}}"),
Json("{\"result\":[{\"id\":1,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"),
Json("{\"result\":{}}"));
var client = new WebUntisClient(new HttpClient(handler), Options.Create(new WebUntisOptions
{
School = "bk-ostvest",
Host = "https://arche.webuntis.com/WebUntis/?school=bk-ostvest#/basic/login",
Username = "api-user",
Password = "secret",
}));
await client.GetSchoolYearsAsync(CancellationToken.None);
Assert.StartsWith("https://arche.webuntis.com/WebUntis/jsonrpc.do", handler.Requests[0].Uri);
Assert.Contains("school=bk-ostvest", handler.Requests[0].Uri);
await client.DisposeAsync();
}
[Fact] [Fact]
public async Task FehlzeitenUndKlassenbuch_WerdenNachSchuelerAbgerufenUndTypisiert() public async Task FehlzeitenUndKlassenbuch_WerdenNachSchuelerAbgerufenUndTypisiert()
{ {
+79 -34
View File
@@ -244,45 +244,79 @@ public static class Endpoints
}); });
} }
// ── WebUntis (granulare, zustandslose Abrufe) ───────────────────────────── // ── WebUntis (granulare Abrufe über benutzergebundene RAM-Sitzungen) ──────
public static void MapWebUntisEndpoints(this WebApplication app) public static void MapWebUntisEndpoints(this WebApplication app)
{ {
var group = app.MapGroup("/api/webuntis").RequireAuthorization(); var group = app.MapGroup("/api/webuntis").RequireAuthorization();
group.MapGet("/schoolyears", (WebUntisClient client, CancellationToken cancellationToken) => group.MapGet("/connection", (ClaimsPrincipal user, WebUntisConnectionStore connections) =>
WebUntisResult(() => client.GetSchoolYearsAsync(cancellationToken))); {
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
return uid is null ? Results.Unauthorized() : Results.Ok(connections.GetStatus(uid));
});
group.MapGet("/classes", ([FromQuery] int schoolyearId, WebUntisClient client, 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 CancellationToken cancellationToken) => schoolyearId <= 0
? Task.FromResult<IResult>(Results.BadRequest("schoolyearId muss größer als 0 sein.")) ? Task.FromResult<IResult>(Results.BadRequest("schoolyearId muss größer als 0 sein."))
: WebUntisResult(() => client.GetClassesAsync(schoolyearId, cancellationToken))); : WithWebUntisClient(user, connections, fallback,
client => client.GetClassesAsync(schoolyearId, cancellationToken)));
group.MapGet("/teachers", (WebUntisClient client, CancellationToken cancellationToken) => group.MapGet("/teachers", (ClaimsPrincipal user, WebUntisConnectionStore connections,
WebUntisResult(() => client.GetTeachersAsync(cancellationToken))); WebUntisClient fallback, CancellationToken cancellationToken) =>
WithWebUntisClient(user, connections, fallback, client => client.GetTeachersAsync(cancellationToken)));
group.MapGet("/student-report", ([FromQuery] string? className, WebUntisClient client, group.MapGet("/student-report", ([FromQuery] string? className, ClaimsPrincipal user,
CancellationToken cancellationToken) => WebUntisConnectionStore connections, WebUntisClient fallback, CancellationToken cancellationToken) =>
WebUntisResult(() => client.GetStudentReportAsync(className, cancellationToken))); WithWebUntisClient(user, connections, fallback,
client => client.GetStudentReportAsync(className, cancellationToken)));
group.MapGet("/holidays", (WebUntisClient client, CancellationToken cancellationToken) => group.MapGet("/holidays", (ClaimsPrincipal user, WebUntisConnectionStore connections,
WebUntisResult(() => client.GetHolidaysAsync(cancellationToken))); WebUntisClient fallback, CancellationToken cancellationToken) =>
WithWebUntisClient(user, connections, fallback, client => client.GetHolidaysAsync(cancellationToken)));
group.MapGet("/timegrid", (WebUntisClient client, CancellationToken cancellationToken) => group.MapGet("/timegrid", (ClaimsPrincipal user, WebUntisConnectionStore connections,
WebUntisResult(() => client.GetTimeGridAsync(cancellationToken))); WebUntisClient fallback, CancellationToken cancellationToken) =>
WithWebUntisClient(user, connections, fallback, client => client.GetTimeGridAsync(cancellationToken)));
group.MapGet("/substitutions", ([FromQuery] int startDate, [FromQuery] int endDate, group.MapGet("/substitutions", ([FromQuery] int startDate, [FromQuery] int endDate,
[FromQuery] int? departmentId, WebUntisClient client, CancellationToken cancellationToken) => [FromQuery] int? departmentId, ClaimsPrincipal user, WebUntisConnectionStore connections,
WebUntisClient fallback, CancellationToken cancellationToken) =>
{ {
if (!ValidDateRange(startDate, endDate, 31, out var error)) if (!ValidDateRange(startDate, endDate, 31, out var error))
return Task.FromResult<IResult>(Results.BadRequest(error)); return Task.FromResult<IResult>(Results.BadRequest(error));
return WebUntisResult(() => return WithWebUntisClient(user, connections, fallback,
client.GetSubstitutionsAsync(startDate, endDate, departmentId, cancellationToken)); client => client.GetSubstitutionsAsync(startDate, endDate, departmentId, cancellationToken));
}); });
group.MapGet("/timetable", ([FromQuery] string elementType, [FromQuery] int elementId, group.MapGet("/timetable", ([FromQuery] string elementType, [FromQuery] int elementId,
[FromQuery] int startDate, [FromQuery] int endDate, WebUntisClient client, [FromQuery] int startDate, [FromQuery] int endDate, ClaimsPrincipal user,
CancellationToken cancellationToken) => WebUntisConnectionStore connections, WebUntisClient fallback, CancellationToken cancellationToken) =>
{ {
if (!Enum.TryParse<UntisTimetableElementType>(elementType, true, out var parsedType) || if (!Enum.TryParse<UntisTimetableElementType>(elementType, true, out var parsedType) ||
!Enum.IsDefined(parsedType)) !Enum.IsDefined(parsedType))
@@ -292,40 +326,51 @@ public static class Endpoints
return Task.FromResult<IResult>(Results.BadRequest("elementId muss größer als 0 sein.")); return Task.FromResult<IResult>(Results.BadRequest("elementId muss größer als 0 sein."));
if (!ValidDateRange(startDate, endDate, 62, out var error)) if (!ValidDateRange(startDate, endDate, 62, out var error))
return Task.FromResult<IResult>(Results.BadRequest(error)); return Task.FromResult<IResult>(Results.BadRequest(error));
return WebUntisResult(() => return WithWebUntisClient(user, connections, fallback,
client.GetTimetableAsync(parsedType, elementId, startDate, endDate, cancellationToken)); client => client.GetTimetableAsync(parsedType, elementId, startDate, endDate, cancellationToken));
}); });
group.MapGet("/students/{studentKey:int}/absences", (int studentKey, [FromQuery] int startDate, group.MapGet("/students/{studentKey:int}/absences", (int studentKey, [FromQuery] int startDate,
[FromQuery] int endDate, WebUntisClient client, CancellationToken cancellationToken) => [FromQuery] int endDate, ClaimsPrincipal user, WebUntisConnectionStore connections,
WebUntisClient fallback, CancellationToken cancellationToken) =>
{ {
if (studentKey <= 0) if (studentKey <= 0)
return Task.FromResult<IResult>(Results.BadRequest("studentKey muss größer als 0 sein.")); return Task.FromResult<IResult>(Results.BadRequest("studentKey muss größer als 0 sein."));
if (!ValidDateRange(startDate, endDate, 400, out var error)) if (!ValidDateRange(startDate, endDate, 400, out var error))
return Task.FromResult<IResult>(Results.BadRequest(error)); return Task.FromResult<IResult>(Results.BadRequest(error));
return WebUntisResult(() => return WithWebUntisClient(user, connections, fallback,
client.GetStudentAbsencesAsync(studentKey, startDate, endDate, cancellationToken)); client => client.GetStudentAbsencesAsync(studentKey, startDate, endDate, cancellationToken));
}); });
group.MapGet("/students/{studentId:int}/class-register-entries", (int studentId, group.MapGet("/students/{studentId:int}/class-register-entries", (int studentId,
[FromQuery] int startDate, [FromQuery] int endDate, WebUntisClient client, [FromQuery] int startDate, [FromQuery] int endDate, ClaimsPrincipal user,
CancellationToken cancellationToken) => WebUntisConnectionStore connections, WebUntisClient fallback, CancellationToken cancellationToken) =>
{ {
if (studentId <= 0) if (studentId <= 0)
return Task.FromResult<IResult>(Results.BadRequest("studentId muss größer als 0 sein.")); return Task.FromResult<IResult>(Results.BadRequest("studentId muss größer als 0 sein."));
if (!ValidDateRange(startDate, endDate, 400, out var error)) if (!ValidDateRange(startDate, endDate, 400, out var error))
return Task.FromResult<IResult>(Results.BadRequest(error)); return Task.FromResult<IResult>(Results.BadRequest(error));
return WebUntisResult(() => return WithWebUntisClient(user, connections, fallback,
client.GetClassRegisterEntriesAsync(studentId, startDate, endDate, cancellationToken)); client => client.GetClassRegisterEntriesAsync(studentId, startDate, endDate, cancellationToken));
}); });
group.MapGet("/class-register/categories", (WebUntisClient client, group.MapGet("/class-register/categories", (ClaimsPrincipal user,
CancellationToken cancellationToken) => WebUntisConnectionStore connections, WebUntisClient fallback, CancellationToken cancellationToken) =>
WebUntisResult(() => client.GetClassRegisterCategoriesAsync(cancellationToken))); WithWebUntisClient(user, connections, fallback,
client => client.GetClassRegisterCategoriesAsync(cancellationToken)));
group.MapGet("/class-register/category-groups", (WebUntisClient client, group.MapGet("/class-register/category-groups", (ClaimsPrincipal user,
CancellationToken cancellationToken) => WebUntisConnectionStore connections, WebUntisClient fallback, CancellationToken cancellationToken) =>
WebUntisResult(() => client.GetClassRegisterCategoryGroupsAsync(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) private static async Task<IResult> WebUntisResult<T>(Func<Task<T>> operation)
+1
View File
@@ -119,6 +119,7 @@ builder.Services.AddHttpClient("webuntis", client =>
builder.Services.AddSingleton(sp => new WebUntisClient( builder.Services.AddSingleton(sp => new WebUntisClient(
sp.GetRequiredService<IHttpClientFactory>().CreateClient("webuntis"), sp.GetRequiredService<IHttpClientFactory>().CreateClient("webuntis"),
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<WebUntisOptions>>())); sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<WebUntisOptions>>()));
builder.Services.AddSingleton<WebUntisConnectionStore>();
var app = builder.Build(); var app = builder.Build();
app.UseForwardedHeaders(); app.UseForwardedHeaders();
+60 -10
View File
@@ -287,7 +287,10 @@ public sealed class WebUntisClient : IAsyncDisposable
client = configuration.Client, client = configuration.Client,
}, null, cancellationToken); }, null, cancellationToken);
_sessionId = OptionalString(result, "sessionId") _sessionId = OptionalString(result, "sessionId")
?? throw new WebUntisException("WebUntis-Login fehlgeschlagen: Keine sessionId erhalten."); ?? throw new WebUntisException(
$"WebUntis-Login fehlgeschlagen: {configuration.Host} hat für die Schulkennung " +
$"„{configuration.School}“ keine Sitzung geliefert. Bitte insbesondere Server und " +
"Schulkennung mit der WebUntis-Anmeldeseite vergleichen.");
} }
_activeRequests++; _activeRequests++;
@@ -499,24 +502,71 @@ public sealed class WebUntisClient : IAsyncDisposable
{ {
var schoolValue = _options.School.Trim(); var schoolValue = _options.School.Trim();
var username = _options.Username.Trim(); var username = _options.Username.Trim();
var password = _options.Password.Trim(); // Passwörter dürfen führende/abschließende Leerzeichen enthalten und werden deshalb
// anders als Schule/Benutzername nicht normalisiert.
var password = _options.Password;
if (schoolValue.Length == 0) throw new WebUntisConfigurationException("WEBUNTIS_SCHOOL fehlt."); if (schoolValue.Length == 0) throw new WebUntisConfigurationException("WEBUNTIS_SCHOOL fehlt.");
if (username.Length == 0) throw new WebUntisConfigurationException("WEBUNTIS_USER fehlt."); if (username.Length == 0) throw new WebUntisConfigurationException("WEBUNTIS_USER fehlt.");
if (password.Length == 0) throw new WebUntisConfigurationException("WEBUNTIS_PASSWORD fehlt."); if (password.Length == 0) throw new WebUntisConfigurationException("WEBUNTIS_PASSWORD fehlt.");
var cleaned = schoolValue.Replace("https://", "", StringComparison.OrdinalIgnoreCase) var (school, host) = ResolveLocation(schoolValue, _options.Host);
.Replace("http://", "", StringComparison.OrdinalIgnoreCase).Split('/')[0]; if (host.Contains('/') || !Uri.CheckHostName(host).Equals(UriHostNameType.Dns) ||
var school = cleaned.Contains('.') ? cleaned.Split('.')[0] : cleaned; !(host.Equals("webuntis.com", StringComparison.OrdinalIgnoreCase) ||
var host = string.IsNullOrWhiteSpace(_options.Host) host.EndsWith(".webuntis.com", StringComparison.OrdinalIgnoreCase)))
? (cleaned.Contains('.') ? cleaned : $"{school}.webuntis.com")
: _options.Host.Trim().Replace("https://", "", StringComparison.OrdinalIgnoreCase)
.Replace("http://", "", StringComparison.OrdinalIgnoreCase).TrimEnd('/');
if (host.Contains('/') || !Uri.CheckHostName(host).Equals(UriHostNameType.Dns))
throw new WebUntisConfigurationException("WEBUNTIS_HOST ist ungültig."); throw new WebUntisConfigurationException("WEBUNTIS_HOST ist ungültig.");
return new Config(school, host, username, password, return new Config(school, host, username, password,
string.IsNullOrWhiteSpace(_options.Client) ? "LehrerApp" : _options.Client.Trim()); string.IsNullOrWhiteSpace(_options.Client) ? "LehrerApp" : _options.Client.Trim());
} }
private static (string School, string Host) ResolveLocation(string schoolValue, string? hostValue)
{
var school = schoolValue.Trim();
string? hostFromSchool = null;
if (LooksLikeLocation(school) && TryParseLocation(school, out var schoolLocation))
{
hostFromSchool = schoolLocation.Host;
school = QueryHelpers.ParseQuery(schoolLocation.Query).TryGetValue("school", out var querySchool) &&
!string.IsNullOrWhiteSpace(querySchool)
? querySchool.ToString().Trim()
: schoolLocation.Host.Split('.')[0];
}
string host;
if (!string.IsNullOrWhiteSpace(hostValue))
{
if (!TryParseLocation(hostValue, out var hostLocation))
throw new WebUntisConfigurationException("WEBUNTIS_HOST ist ungültig.");
host = hostLocation.Host;
// Komfortfall: In das Serverfeld wurde die vollständige Login-URL kopiert. Eine
// explizit im Schulfeld angegebene Kennung behält trotzdem Vorrang.
if (LooksLikeLocation(schoolValue) &&
QueryHelpers.ParseQuery(hostLocation.Query).TryGetValue("school", out var querySchool) &&
!string.IsNullOrWhiteSpace(querySchool))
school = querySchool.ToString().Trim();
}
else
{
host = hostFromSchool ?? $"{school}.webuntis.com";
}
if (school.Length == 0)
throw new WebUntisConfigurationException("WEBUNTIS_SCHOOL fehlt oder ist ungültig.");
return (school, host);
}
private static bool LooksLikeLocation(string value) => value.Contains('.') || value.Contains('/') ||
value.StartsWith("http:", StringComparison.OrdinalIgnoreCase) ||
value.StartsWith("https:", StringComparison.OrdinalIgnoreCase);
private static bool TryParseLocation(string value, out Uri location)
{
var candidate = value.Trim();
if (!candidate.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
!candidate.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
candidate = $"https://{candidate}";
return Uri.TryCreate(candidate, UriKind.Absolute, out location!) && !string.IsNullOrWhiteSpace(location.Host);
}
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) =>
+75
View File
@@ -0,0 +1,75 @@
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();
}
}
@@ -86,4 +86,39 @@ public sealed class WebUntisSettingsServiceTests
Assert.Equal(at, reloaded.LastSyncAt); Assert.Equal(at, reloaded.LastSyncAt);
Assert.Equal("3 Vertretungen erkannt", reloaded.LastSyncStatus); Assert.Equal("3 Vertretungen erkannt", reloaded.LastSyncStatus);
} }
[Fact]
public void ApiCredentials_SindVerschluesseltUndPersistieren()
{
var path = BuildTempPath();
var credentials = new WebUntisCredentials("schule", "mese.webuntis.com", "lehrkraft", "sehr geheim");
var service = new WebUntisSettingsService(path);
service.SetApiCredentials(credentials);
service.SetTeacherUntisId(4711);
var raw = File.ReadAllText(Path.Combine(path, "webuntis-settings.json"));
var reloaded = new WebUntisSettingsService(path);
Assert.DoesNotContain("sehr geheim", raw);
Assert.True(reloaded.ApiIsConfigured);
Assert.Equal(credentials, reloaded.GetApiCredentials());
Assert.Equal(4711, reloaded.TeacherUntisId);
}
[Fact]
public void ClearApiCredentials_LaesstIcalKonfigurationUnveraendert()
{
var service = new WebUntisSettingsService(BuildTempPath());
service.SetIcalUrl(SampleUrl);
service.SetApiCredentials(new("schule", "", "user", "password"));
service.SetTeacherUntisId(12);
service.ClearApiCredentials();
Assert.False(service.ApiIsConfigured);
Assert.Null(service.GetApiCredentials());
Assert.Null(service.TeacherUntisId);
Assert.True(service.IsConfigured);
Assert.Equal(SampleUrl, service.GetIcalUrl());
}
} }
+1
View File
@@ -223,6 +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));
// 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.
@@ -0,0 +1,140 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
namespace LehrerApp.Desktop.Services;
public sealed class WebUntisIntegrationException(string message) : Exception(message);
public sealed record UntisSchoolYearDto(int UntisId, string Name, int StartDate, int EndDate);
public sealed record UntisClassDto(int UntisId, string Name, string? LongName);
public sealed record UntisTeacherDto(int UntisId, string Name, string? ForeName, string? LongName, string? Title,
bool Active, IReadOnlyList<int> DepartmentUntisIds)
{
public string DisplayName => string.IsNullOrWhiteSpace(LongName)
? Name
: $"{ForeName} {LongName} ({Name})".Trim();
}
public sealed record UntisEntityDto(int Id, string Name, int? OriginalId, string? OriginalName, string? ExternalKey);
public sealed record UntisTimeUnitDto(string Name, int StartTime, int EndTime);
public sealed record UntisTimeGridDayDto(int Day, IReadOnlyList<UntisTimeUnitDto> TimeUnits);
public sealed record UntisTimetablePeriodDto(int Id, int Date, int StartTime, int EndTime, string? Code,
string? ActivityType, string? Info, string? LessonText, string? SubstitutionText, string? StudentGroup,
IReadOnlyList<UntisEntityDto> Classes, IReadOnlyList<UntisEntityDto> Teachers,
IReadOnlyList<UntisEntityDto> Subjects, IReadOnlyList<UntisEntityDto> Rooms);
public sealed record UntisStudentAddressDto(string? Email, string? Mobile, string? Phone, string? City,
string? PostCode, string? Street);
public sealed record UntisStudentDto(int UntisId, int ExternKey, string ClassName, string? Name, string? LongName,
string? ForeName, string DisplayName, string? Gender, int? BirthDate, string? BirthDateRaw, int? EntryDate,
string? EntryDateRaw, int? ExitDate, string? ExitDateRaw, string? Text, string? MedicalReportDuty,
string? Schulpflicht, string? Majority, UntisStudentAddressDto Address, string? AttributeIL);
public sealed record UntisStudentReportDto(int Count, string? ClassNameFilter, IReadOnlyList<UntisStudentDto> Students);
public sealed record UntisStudentAbsenceDto(int StudentKey, int Date, int StartTime, int EndTime, int AbsentMinutes,
bool Checked, string? AbsenceReason, string? ExcuseStatus, int? SubjectId, IReadOnlyList<int> TeacherIds,
string? StudentGroup);
public sealed record UntisStudentAbsenceReportDto(int StudentKey, int StartDate, int EndDate, int EntryCount,
int AbsentMinutes, IReadOnlyList<UntisStudentAbsenceDto> Absences);
/// <summary>Authentifizierter Desktop-Client für die LehrerApp-API. Falls der Server neu gestartet
/// wurde, baut er die nur im Server-RAM gehaltene WebUntis-Verbindung automatisch erneut auf.</summary>
public sealed class WebUntisIntegrationService(HttpClient http, SyncSettingsService syncSettings,
WebUntisSettingsService settings)
{
public bool IsAvailable => syncSettings.IsLoggedIn && !string.IsNullOrWhiteSpace(syncSettings.ServerUrl)
&& settings.ApiIsConfigured;
public async Task ConnectAsync(WebUntisCredentials credentials, CancellationToken token = default)
{
using var response = await SendAsync(HttpMethod.Post, "/api/webuntis/connection", new
{
credentials.School,
Host = string.IsNullOrWhiteSpace(credentials.Host) ? null : credentials.Host,
credentials.Username,
credentials.Password,
}, token);
await EnsureSuccessAsync(response);
}
public async Task DisconnectAsync(CancellationToken token = default)
{
using var response = await SendAsync(HttpMethod.Delete, "/api/webuntis/connection", null, token);
await EnsureSuccessAsync(response);
}
public Task<IReadOnlyList<UntisSchoolYearDto>> GetSchoolYearsAsync(CancellationToken token = default) =>
GetAsync<IReadOnlyList<UntisSchoolYearDto>>("/api/webuntis/schoolyears", token);
public Task<IReadOnlyList<UntisClassDto>> GetClassesAsync(int schoolYearId, CancellationToken token = default) =>
GetAsync<IReadOnlyList<UntisClassDto>>($"/api/webuntis/classes?schoolyearId={schoolYearId}", token);
public Task<IReadOnlyList<UntisTeacherDto>> GetTeachersAsync(CancellationToken token = default) =>
GetAsync<IReadOnlyList<UntisTeacherDto>>("/api/webuntis/teachers", token);
public Task<IReadOnlyList<UntisTimeGridDayDto>> GetTimeGridAsync(CancellationToken token = default) =>
GetAsync<IReadOnlyList<UntisTimeGridDayDto>>("/api/webuntis/timegrid", token);
public Task<IReadOnlyList<UntisTimetablePeriodDto>> GetTimetableAsync(int teacherId, DateOnly start,
DateOnly end, CancellationToken token = default) => GetAsync<IReadOnlyList<UntisTimetablePeriodDto>>(
$"/api/webuntis/timetable?elementType=teacher&elementId={teacherId}&startDate={Date(start)}&endDate={Date(end)}", token);
public Task<UntisStudentReportDto> GetStudentsAsync(string className, CancellationToken token = default) =>
GetAsync<UntisStudentReportDto>($"/api/webuntis/student-report?className={Uri.EscapeDataString(className)}", token);
public Task<UntisStudentAbsenceReportDto> GetAbsencesAsync(int studentKey, DateOnly start, DateOnly end,
CancellationToken token = default) => GetAsync<UntisStudentAbsenceReportDto>(
$"/api/webuntis/students/{studentKey}/absences?startDate={Date(start)}&endDate={Date(end)}", token);
private async Task<T> GetAsync<T>(string path, CancellationToken token)
{
await EnsureConnectedAsync(token);
using var response = await SendAsync(HttpMethod.Get, path, null, token);
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<T>(cancellationToken: token)
?? throw new WebUntisIntegrationException("Der Server hat keine WebUntis-Daten zurückgegeben.");
}
private async Task EnsureConnectedAsync(CancellationToken token)
{
var credentials = settings.GetApiCredentials()
?? throw new WebUntisIntegrationException("Bitte zuerst die WebUntis-Anmeldedaten einrichten.");
using var status = await SendAsync(HttpMethod.Get, "/api/webuntis/connection", null, token);
await EnsureSuccessAsync(status);
using var json = JsonDocument.Parse(await status.Content.ReadAsStringAsync(token));
if (!json.RootElement.GetProperty("connected").GetBoolean()) await ConnectAsync(credentials, token);
}
private async Task<HttpResponseMessage> SendAsync(HttpMethod method, string path, object? body,
CancellationToken token)
{
if (!syncSettings.IsLoggedIn || string.IsNullOrWhiteSpace(syncSettings.ServerUrl))
throw new WebUntisIntegrationException("Bitte zuerst unter „Synchronisation“ am LehrerApp-Server anmelden.");
using var request = new HttpRequestMessage(method, new Uri(new Uri(syncSettings.ServerUrl), path));
request.Headers.Authorization = new("Bearer", syncSettings.GetToken());
if (body is not null) request.Content = JsonContent.Create(body);
try { return await http.SendAsync(request, token); }
catch (HttpRequestException) { throw new WebUntisIntegrationException("Der LehrerApp-Server ist nicht erreichbar."); }
}
private static async Task EnsureSuccessAsync(HttpResponseMessage response)
{
if (response.IsSuccessStatusCode) return;
if (response.StatusCode == HttpStatusCode.Unauthorized)
throw new WebUntisIntegrationException("Die Server-Anmeldung ist abgelaufen. Bitte erneut anmelden.");
if (response.StatusCode == HttpStatusCode.NotFound &&
response.RequestMessage?.RequestUri?.AbsolutePath.StartsWith("/api/webuntis",
StringComparison.OrdinalIgnoreCase) == true)
throw new WebUntisIntegrationException(
"Der konfigurierte LehrerApp-Server enthält die WebUntis-Integration noch nicht. " +
"Bitte zuerst die aktuelle LehrerApp.Api auf dem Server bereitstellen.");
try
{
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
if (json.RootElement.TryGetProperty("detail", out var detail) && !string.IsNullOrWhiteSpace(detail.GetString()))
throw new WebUntisIntegrationException(detail.GetString()!);
}
catch (JsonException) { }
throw new WebUntisIntegrationException("Der WebUntis-Abruf ist fehlgeschlagen.");
}
private static int Date(DateOnly date) => date.Year * 10000 + date.Month * 100 + date.Day;
}
@@ -9,8 +9,12 @@ internal class WebUntisSettingsConfig
public string? EncryptedIcalUrl { get; set; } public string? EncryptedIcalUrl { get; set; }
public DateTime? LastSyncAt { get; set; } public DateTime? LastSyncAt { get; set; }
public string LastSyncStatus { get; set; } = ""; public string LastSyncStatus { get; set; } = "";
public string? EncryptedApiCredentials { get; set; }
public int? TeacherUntisId { get; set; }
} }
public sealed record WebUntisCredentials(string School, string Host, string Username, string Password);
/// <summary> /// <summary>
/// Einstellungen für den WebUntis-iCal-Abgleich (Nutzer-Feedback, siehe TODO.md). Liegt wie /// Einstellungen für den WebUntis-iCal-Abgleich (Nutzer-Feedback, siehe TODO.md). Liegt wie
/// AiSettingsService/SyncSettingsService in LehrerApp.Desktop statt LehrerApp.Core, da die /// AiSettingsService/SyncSettingsService in LehrerApp.Desktop statt LehrerApp.Core, da die
@@ -30,6 +34,8 @@ public class WebUntisSettingsService
public bool Enabled => _config.Enabled; public bool Enabled => _config.Enabled;
public bool IsConfigured => _config.EncryptedIcalUrl is not null; public bool IsConfigured => _config.EncryptedIcalUrl is not null;
public bool ApiIsConfigured => _config.EncryptedApiCredentials is not null;
public int? TeacherUntisId => _config.TeacherUntisId;
public DateTime? LastSyncAt => _config.LastSyncAt; public DateTime? LastSyncAt => _config.LastSyncAt;
public string LastSyncStatus => _config.LastSyncStatus; public string LastSyncStatus => _config.LastSyncStatus;
@@ -63,6 +69,29 @@ public class WebUntisSettingsService
Save(); Save();
} }
public void SetApiCredentials(WebUntisCredentials credentials)
{
_config.EncryptedApiCredentials = SyncCrypto.EncryptObject(credentials, _urlKey);
Save();
}
public WebUntisCredentials? GetApiCredentials() => _config.EncryptedApiCredentials is null
? null
: SyncCrypto.DecryptObject<WebUntisCredentials>(_config.EncryptedApiCredentials, _urlKey);
public void ClearApiCredentials()
{
_config.EncryptedApiCredentials = null;
_config.TeacherUntisId = null;
Save();
}
public void SetTeacherUntisId(int? teacherUntisId)
{
_config.TeacherUntisId = teacherUntisId;
Save();
}
public void SetLastSync(DateTime at, string status) public void SetLastSync(DateTime at, string status)
{ {
_config.LastSyncAt = at; _config.LastSyncAt = at;
@@ -0,0 +1,131 @@
using System.Collections.Concurrent;
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Importing;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop.ViewModels.Groups;
public partial class WebUntisAbsenceRow : ObservableObject
{
public required string StudentName { get; init; }
public required DateOnly Date { get; init; }
public required string TimeLabel { get; init; }
public required string UntisStatus { get; init; }
public required string LocalStatus { get; init; }
public required AttendanceStatus TargetStatus { get; init; }
public required Guid StudentId { get; init; }
public required Guid? SessionId { get; init; }
public string? Reason { get; init; }
public string DateLabel => Date.ToString("dd.MM.yyyy");
public bool CanApply => SessionId is not null;
[ObservableProperty] private bool _selected;
}
public partial class WebUntisAbsenceComparisonViewModel : ObservableObject
{
private readonly LearningGroup _group;
private readonly WebUntisIntegrationService _untis;
private readonly IStudentRepository _students;
private readonly IParticipationSessionRepository _sessions;
private readonly IParticipationRepository _participation;
public ObservableCollection<WebUntisAbsenceRow> Rows { get; } = [];
[ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddMonths(-2);
[ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now;
[ObservableProperty] private string _status = "Zeitraum wählen und Fehlzeiten laden.";
[ObservableProperty] private bool _busy;
public WebUntisAbsenceComparisonViewModel(LearningGroup group, WebUntisIntegrationService untis,
IStudentRepository students, IParticipationSessionRepository sessions,
IParticipationRepository participation)
{
_group = group; _untis = untis; _students = students; _sessions = sessions;
_participation = participation;
}
[RelayCommand]
private async Task Load()
{
var start = DateOnly.FromDateTime(StartDate.LocalDateTime);
var end = DateOnly.FromDateTime(EndDate.LocalDateTime);
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
Busy = true; Rows.Clear();
try
{
var courseStudents = _students.GetByGroup(_group.Id);
var localSessions = _sessions.GetByGroup(_group.Id)
.Where(x => x.Date >= start && x.Date <= end).GroupBy(x => x.Date)
.ToDictionary(x => x.Key, x => x.First());
var loaded = new ConcurrentBag<(Student Student, UntisStudentAbsenceDto Absence)>();
var linked = courseStudents.Select(student => (Student: student, Key: StudentKey(student)))
.Where(x => x.Key is not null).ToList();
await Parallel.ForEachAsync(linked, new ParallelOptions { MaxDegreeOfParallelism = 4 }, async (item, token) =>
{
var report = await _untis.GetAbsencesAsync(item.Key!.Value, start, end, token);
foreach (var absence in report.Absences) loaded.Add((item.Student, absence));
});
foreach (var item in loaded.OrderBy(x => x.Absence.Date).ThenBy(x => x.Student.FullName))
{
if (!TryDate(item.Absence.Date, out var date)) continue;
localSessions.TryGetValue(date, out var session);
var entry = session is null ? null : _participation.GetBySessionAndStudent(session.Id, item.Student.Id);
var target = MapStatus(item.Absence.ExcuseStatus);
Rows.Add(new WebUntisAbsenceRow
{
StudentName = item.Student.FullName, StudentId = item.Student.Id, Date = date,
TimeLabel = $"{Time(item.Absence.StartTime)}{Time(item.Absence.EndTime)}",
UntisStatus = DisplayUntisStatus(item.Absence),
LocalStatus = entry?.Attendance?.ToString() ?? (session is null ? "keine lokale Stunde" : "nicht erfasst"),
TargetStatus = target, SessionId = session?.Id, Reason = item.Absence.AbsenceReason,
Selected = session is not null && entry?.Attendance != target,
});
}
var withoutKey = courseStudents.Count - linked.Count;
Status = $"{Rows.Count} Untis-Fehlzeiten gefunden; {Rows.Count(x => x.CanApply)} sind einer lokalen Kursstunde zuordenbar."
+ (withoutKey > 0 ? $" {withoutKey} Schüler haben noch keine WebUntis-Kennung." : "");
}
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
finally { Busy = false; }
}
[RelayCommand]
private void Apply()
{
var selected = Rows.Where(x => x.Selected && x.SessionId is not null).ToList();
foreach (var row in selected)
{
var entry = _participation.GetBySessionAndStudent(row.SessionId!.Value, row.StudentId)
?? new ParticipationEntry { SessionId = row.SessionId.Value, StudentId = row.StudentId };
entry.Attendance = row.TargetStatus;
entry.UpdatedAt = DateTime.UtcNow;
_participation.Save(entry);
}
Status = $"{selected.Count} Anwesenheitsstatus übernommen.";
foreach (var row in selected) row.Selected = false;
}
private static int? StudentKey(Student student)
{
student.ExternalIds ??= [];
return student.ExternalIds.TryGetValue(StudentImportFormats.MasterDataCsv.Value, out var value)
&& int.TryParse(value, out var key) ? key : null;
}
private static AttendanceStatus MapStatus(string? value)
{
var text = value?.Trim().ToLowerInvariant() ?? "";
if (text.Contains("unexcused") || text.Contains("unentschuldigt") || text.Contains("nicht entschuldigt"))
return AttendanceStatus.Unexcused;
if (text.Contains("excused") || text.Contains("entschuldigt")) return AttendanceStatus.Excused;
return AttendanceStatus.ExcusePending;
}
private static string DisplayUntisStatus(UntisStudentAbsenceDto absence) =>
string.Join(" · ", new[] { absence.ExcuseStatus, absence.AbsenceReason }.Where(x => !string.IsNullOrWhiteSpace(x)))
is { Length: > 0 } text ? text : "offen";
private static bool TryDate(int value, out DateOnly date) => DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
private static string Time(int value) => $"{value / 100:00}:{value % 100:00}";
}
@@ -0,0 +1,56 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop.ViewModels.Groups;
public partial class WebUntisClassSelectionViewModel(WebUntisIntegrationService untis) : ObservableObject
{
public ObservableCollection<UntisSchoolYearDto> SchoolYears { get; } = [];
public ObservableCollection<UntisClassDto> Classes { get; } = [];
[ObservableProperty] private UntisSchoolYearDto? _selectedSchoolYear;
[ObservableProperty] private UntisClassDto? _selectedClass;
[ObservableProperty] private string _status = "";
[ObservableProperty] private bool _busy;
public bool CanConfirm => SelectedClass is not null && !Busy;
partial void OnSelectedClassChanged(UntisClassDto? value) => OnPropertyChanged(nameof(CanConfirm));
partial void OnBusyChanged(bool value) => OnPropertyChanged(nameof(CanConfirm));
public async Task InitializeAsync()
{
Busy = true;
try
{
foreach (var year in (await untis.GetSchoolYearsAsync()).OrderByDescending(x => x.StartDate))
SchoolYears.Add(year);
SelectedSchoolYear = SchoolYears.FirstOrDefault(x => x.StartDate <= Today() && x.EndDate >= Today())
?? SchoolYears.FirstOrDefault();
await LoadClasses();
}
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
finally { Busy = false; }
}
[RelayCommand]
private async Task LoadClasses()
{
if (SelectedSchoolYear is null) return;
Busy = true; Classes.Clear(); SelectedClass = null;
try
{
foreach (var entry in (await untis.GetClassesAsync(SelectedSchoolYear.UntisId)).OrderBy(x => x.Name))
Classes.Add(entry);
Status = Classes.Count == 0 ? "Keine Klassen gefunden." : $"{Classes.Count} Klassen gefunden.";
}
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
finally { Busy = false; }
}
private static int Today()
{
var date = DateOnly.FromDateTime(DateTime.Today);
return date.Year * 10000 + date.Month * 100 + date.Day;
}
}
@@ -103,6 +103,7 @@ public partial class TimetableViewModel : ObservableObject
public Func<TimetableCellItem, Task>? OnEditSlot { get; set; } public Func<TimetableCellItem, Task>? OnEditSlot { get; set; }
public Action<Guid>? OnNavigateToGroup { get; set; } public Action<Guid>? OnNavigateToGroup { get; set; }
public Func<Task>? OnAddSubstitution { get; set; } public Func<Task>? OnAddSubstitution { get; set; }
public Func<Task>? OnImportWebUntisTimetable { get; set; }
public Action<SettingsTab>? OnNavigateToSettings { get; set; } public Action<SettingsTab>? OnNavigateToSettings { get; set; }
public Func<Lesson, Task>? OnOpenLessonViewer { get; set; } public Func<Lesson, Task>? OnOpenLessonViewer { get; set; }
public Func<Lesson, Task>? OnOpenTeachingMode { get; set; } public Func<Lesson, Task>? OnOpenTeachingMode { get; set; }
@@ -143,6 +144,14 @@ public partial class TimetableViewModel : ObservableObject
Load(); Load();
} }
[RelayCommand]
private async Task ImportWebUntisTimetable()
{
if (OnImportWebUntisTimetable is null) return;
await OnImportWebUntisTimetable();
Load();
}
public void Load() public void Load()
{ {
var today = DateOnly.FromDateTime(DateTime.Today); var today = DateOnly.FromDateTime(DateTime.Today);
@@ -0,0 +1,159 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop.ViewModels.Planning;
public sealed record WebUntisGroupOption(Guid Id, string DisplayName);
public partial class WebUntisTimetableRow : ObservableObject
{
public required DayOfWeek Weekday { get; init; }
public required int PeriodNumber { get; init; }
public required string TimeLabel { get; init; }
public required string UntisLabel { get; init; }
public required string SuggestedGroupName { get; init; }
public string? SubjectName { get; init; }
public string? Room { get; init; }
public ObservableCollection<WebUntisGroupOption> GroupOptions { get; } = [];
[ObservableProperty] private WebUntisGroupOption? _selectedGroup;
public string WeekdayLabel => Weekday switch
{
DayOfWeek.Monday => "Mo", DayOfWeek.Tuesday => "Di", DayOfWeek.Wednesday => "Mi",
DayOfWeek.Thursday => "Do", DayOfWeek.Friday => "Fr", _ => Weekday.ToString()[..2],
};
}
public partial class WebUntisTimetableImportViewModel : ObservableObject
{
private readonly WebUntisIntegrationService _untis;
private readonly WebUntisSettingsService _settings;
private readonly ITimetableSlotRepository _slots;
private readonly IGroupRepository _groups;
public ObservableCollection<UntisTeacherDto> Teachers { get; } = [];
public ObservableCollection<WebUntisTimetableRow> Rows { get; } = [];
[ObservableProperty] private UntisTeacherDto? _selectedTeacher;
[ObservableProperty] private DateTimeOffset _weekDate = DateTimeOffset.Now;
[ObservableProperty] private string _status = "Lehrkraft auswählen und Untis-Woche laden.";
[ObservableProperty] private bool _busy;
public bool Saved { get; private set; }
public Func<WebUntisTimetableRow, Task<LearningGroup?>>? OnCreateGroup { get; set; }
public WebUntisTimetableImportViewModel(WebUntisIntegrationService untis, WebUntisSettingsService settings,
ITimetableSlotRepository slots, IGroupRepository groups)
{
_untis = untis; _settings = settings; _slots = slots; _groups = groups;
}
public async Task InitializeAsync()
{
Busy = true;
try
{
foreach (var teacher in (await _untis.GetTeachersAsync()).Where(x => x.Active).OrderBy(x => x.DisplayName))
Teachers.Add(teacher);
SelectedTeacher = Teachers.FirstOrDefault(x => x.UntisId == _settings.TeacherUntisId)
?? Teachers.FirstOrDefault();
Status = Teachers.Count == 0 ? "WebUntis hat keine Lehrkräfte geliefert." : "Bereit zum Laden.";
}
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
finally { Busy = false; }
}
[RelayCommand]
private async Task Load()
{
if (SelectedTeacher is null) { Status = "Bitte eine Lehrkraft auswählen."; return; }
Busy = true; Rows.Clear();
try
{
var selected = DateOnly.FromDateTime(WeekDate.LocalDateTime);
var monday = selected.AddDays(-(((int)selected.DayOfWeek + 6) % 7));
var periods = await _untis.GetTimetableAsync(SelectedTeacher.UntisId, monday, monday.AddDays(6));
var grid = await _untis.GetTimeGridAsync();
var groupOptions = BuildGroupOptions();
var localSlots = _slots.GetAll().ToDictionary(x => (x.Weekday, x.PeriodNumber));
foreach (var period in periods.Where(x => string.IsNullOrWhiteSpace(x.Code) || x.Code != "cancelled")
.GroupBy(x => (x.Date, x.StartTime, x.EndTime, x.StudentGroup,
Class: string.Join("/", x.Classes.Select(c => c.Name)),
Subject: string.Join("/", x.Subjects.Select(s => s.Name)),
Room: string.Join("/", x.Rooms.Select(r => r.Name))))
.Select(x => x.First()).OrderBy(x => x.Date).ThenBy(x => x.StartTime))
{
if (!TryDate(period.Date, out var date) || date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday)
continue;
var dayGrid = grid.FirstOrDefault(x => x.Day == UntisDay(date.DayOfWeek));
var number = dayGrid?.TimeUnits.ToList().FindIndex(x => x.StartTime == period.StartTime) + 1 ?? 0;
if (number <= 0) continue;
var className = period.Classes.FirstOrDefault()?.Name ?? "";
var subject = period.Subjects.FirstOrDefault()?.Name;
var suggested = !string.IsNullOrWhiteSpace(period.StudentGroup) ? period.StudentGroup! : className;
var row = new WebUntisTimetableRow
{
Weekday = date.DayOfWeek, PeriodNumber = number,
TimeLabel = $"{Time(period.StartTime)}{Time(period.EndTime)}",
UntisLabel = string.Join(" · ", new[] { subject, suggested, period.Rooms.FirstOrDefault()?.Name }
.Where(x => !string.IsNullOrWhiteSpace(x))),
SuggestedGroupName = suggested, SubjectName = subject,
Room = period.Rooms.FirstOrDefault()?.Name,
};
foreach (var option in groupOptions) row.GroupOptions.Add(option);
if (localSlots.TryGetValue((row.Weekday, row.PeriodNumber), out var existing))
row.SelectedGroup = groupOptions.FirstOrDefault(x => x.Id == existing.GroupId);
row.SelectedGroup ??= BestMatch(groupOptions, suggested, className, subject);
Rows.Add(row);
}
_settings.SetTeacherUntisId(SelectedTeacher.UntisId);
Status = Rows.Count == 0 ? "In dieser Woche wurde kein Unterricht gefunden."
: $"{Rows.Count} regelmäßige Termine gefunden. Zuordnung prüfen und übernehmen.";
}
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
finally { Busy = false; }
}
[RelayCommand]
private async Task CreateGroup(WebUntisTimetableRow row)
{
if (OnCreateGroup is null) return;
var group = await OnCreateGroup(row);
if (group is null) return;
var option = new WebUntisGroupOption(group.Id, group.Name);
foreach (var item in Rows.Where(x => x.SuggestedGroupName == row.SuggestedGroupName))
{
item.GroupOptions.Add(option);
item.SelectedGroup = option;
}
}
[RelayCommand]
private void Save()
{
var selected = Rows.Where(x => x.SelectedGroup is not null).ToList();
foreach (var row in selected)
{
var existing = _slots.GetAll().FirstOrDefault(x => x.Weekday == row.Weekday && x.PeriodNumber == row.PeriodNumber);
var slot = existing ?? new TimetableSlot { Weekday = row.Weekday, PeriodNumber = row.PeriodNumber };
slot.GroupId = row.SelectedGroup!.Id;
slot.Room = string.IsNullOrWhiteSpace(row.Room) ? null : row.Room;
_slots.Save(slot);
}
Saved = true;
Status = $"{selected.Count} Stundenplan-Einträge übernommen.";
}
private List<WebUntisGroupOption> BuildGroupOptions() => _groups.GetAll().OrderBy(x => x.Name)
.Select(x => new WebUntisGroupOption(x.Id, x.Name)).ToList();
private static WebUntisGroupOption? BestMatch(IEnumerable<WebUntisGroupOption> options, params string?[] terms) =>
options.FirstOrDefault(x => terms.Any(term => !string.IsNullOrWhiteSpace(term) &&
(x.DisplayName.Equals(term, StringComparison.OrdinalIgnoreCase) ||
x.DisplayName.Contains(term, StringComparison.OrdinalIgnoreCase))));
private static int UntisDay(DayOfWeek day) => day == DayOfWeek.Sunday ? 7 : (int)day;
private static bool TryDate(int value, out DateOnly date) => DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
private static string Time(int value) => $"{value / 100:00}:{value % 100:00}";
}
@@ -210,6 +210,13 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty] private string _untisUrlError = ""; [ObservableProperty] private string _untisUrlError = "";
[ObservableProperty] private string _untisStatusDisplay = ""; [ObservableProperty] private string _untisStatusDisplay = "";
[ObservableProperty] private bool _untisFetchBusy; [ObservableProperty] private bool _untisFetchBusy;
[ObservableProperty] private bool _untisApiIsConfigured;
[ObservableProperty] private string _untisSchool = "";
[ObservableProperty] private string _untisHost = "";
[ObservableProperty] private string _untisUsername = "";
[ObservableProperty] private string _untisPassword = "";
[ObservableProperty] private string _untisApiStatus = "";
[ObservableProperty] private bool _untisApiBusy;
public Func<Task>? OnReviewUntisMapping { get; set; } public Func<Task>? OnReviewUntisMapping { get; set; }
// ── Schulweiter Jahresplan (ClassyPlan-iCal) ───────────────────────────── // ── Schulweiter Jahresplan (ClassyPlan-iCal) ─────────────────────────────
@@ -299,6 +306,7 @@ public partial class SettingsViewModel : ObservableObject
private readonly AiSettingsService _aiSettings; private readonly AiSettingsService _aiSettings;
private readonly AiPlanningService _aiPlanning; private readonly AiPlanningService _aiPlanning;
private readonly WebUntisSettingsService _untisSettings; private readonly WebUntisSettingsService _untisSettings;
private readonly WebUntisIntegrationService? _untisIntegration;
private readonly UntisSyncService? _untisSync; private readonly UntisSyncService? _untisSync;
private readonly AnnualPlanSettingsService _annualPlanSettings; private readonly AnnualPlanSettingsService _annualPlanSettings;
private readonly AnnualPlanSyncService? _annualPlanSync; private readonly AnnualPlanSyncService? _annualPlanSync;
@@ -330,7 +338,7 @@ public partial class SettingsViewModel : ObservableObject
AppearanceSettingsService appearance, TrashViewModel trashTab, AppearanceSettingsService appearance, TrashViewModel trashTab,
SnapshotService? snapshotService = null, SyncEngine? syncEngine = null, SnapshotService? snapshotService = null, SyncEngine? syncEngine = null,
UntisSyncService? untisSync = null, AnnualPlanSyncService? annualPlanSync = null, UntisSyncService? untisSync = null, AnnualPlanSyncService? annualPlanSync = null,
SchoolWeatherService? schoolWeather = null) SchoolWeatherService? schoolWeather = null, WebUntisIntegrationService? untisIntegration = null)
{ {
_logger = logger; _logger = logger;
_syncKeyRecovery = syncKeyRecovery; _syncKeyRecovery = syncKeyRecovery;
@@ -360,6 +368,7 @@ public partial class SettingsViewModel : ObservableObject
_aiSettings = aiSettings; _aiSettings = aiSettings;
_aiPlanning = aiPlanning; _aiPlanning = aiPlanning;
_untisSettings = untisSettings; _untisSettings = untisSettings;
_untisIntegration = untisIntegration;
_untisSync = untisSync; _untisSync = untisSync;
_annualPlanSettings = annualPlanSettings; _annualPlanSettings = annualPlanSettings;
_annualPlanSync = annualPlanSync; _annualPlanSync = annualPlanSync;
@@ -542,6 +551,60 @@ public partial class SettingsViewModel : ObservableObject
UntisStatusDisplay = _untisSettings.LastSyncAt is { } at UntisStatusDisplay = _untisSettings.LastSyncAt is { } at
? $"Letzter Abgleich: {at.ToLocalTime():dd.MM.yyyy HH:mm} — {_untisSettings.LastSyncStatus}" ? $"Letzter Abgleich: {at.ToLocalTime():dd.MM.yyyy HH:mm} — {_untisSettings.LastSyncStatus}"
: "Noch kein Abgleich durchgeführt."; : "Noch kein Abgleich durchgeführt.";
UntisApiIsConfigured = _untisSettings.ApiIsConfigured;
if (_untisSettings.GetApiCredentials() is { } credentials)
{
UntisSchool = credentials.School;
UntisHost = credentials.Host;
UntisUsername = credentials.Username;
UntisApiStatus = $"API-Zugang für {credentials.Username} ist lokal verschlüsselt gespeichert.";
}
}
[RelayCommand]
private async Task UntisSaveApi()
{
UntisApiStatus = "";
if (_untisIntegration is null)
{
UntisApiStatus = "Die WebUntis-Integration ist nicht verfügbar.";
return;
}
if (string.IsNullOrWhiteSpace(UntisSchool) || string.IsNullOrWhiteSpace(UntisUsername) ||
string.IsNullOrWhiteSpace(UntisPassword))
{
UntisApiStatus = "Schule, Benutzername und Passwort sind erforderlich.";
return;
}
UntisApiBusy = true;
try
{
var credentials = new WebUntisCredentials(UntisSchool.Trim(), UntisHost.Trim(),
UntisUsername.Trim(), UntisPassword);
await _untisIntegration.ConnectAsync(credentials);
_untisSettings.SetApiCredentials(credentials);
UntisPassword = "";
UntisApiIsConfigured = true;
UntisApiStatus = "Anmeldung erfolgreich. Die WebUntis-Session bleibt bei Nutzung bis zu 10 Minuten offen.";
}
catch (WebUntisIntegrationException ex) { UntisApiStatus = ex.Message; }
finally { UntisApiBusy = false; }
}
[RelayCommand]
private async Task UntisRemoveApi()
{
UntisApiBusy = true;
try { if (_untisIntegration is not null) await _untisIntegration.DisconnectAsync(); }
catch (WebUntisIntegrationException) { /* lokale Zugangsdaten trotzdem sicher entfernen */ }
finally
{
_untisSettings.ClearApiCredentials();
UntisPassword = "";
UntisApiIsConfigured = false;
UntisApiStatus = "WebUntis-API-Zugang entfernt.";
UntisApiBusy = false;
}
} }
[RelayCommand] [RelayCommand]
@@ -25,6 +25,9 @@
<Button Content=" Schüler" Command="{Binding AddStudentCommand}" IsEnabled="{Binding IsEditable}"/> <Button Content=" Schüler" Command="{Binding AddStudentCommand}" IsEnabled="{Binding IsEditable}"/>
<Button Content="⇩ Teilnehmer importieren…" Click="OnImportParticipantsClick" <Button Content="⇩ Teilnehmer importieren…" Click="OnImportParticipantsClick"
IsEnabled="{Binding IsEditable}"/> IsEnabled="{Binding IsEditable}"/>
<Button Content="↻ Aus WebUntis…" Click="OnImportParticipantsFromWebUntisClick"
IsEnabled="{Binding IsEditable}"/>
<Button Content="Fehlzeiten abgleichen…" Click="OnCompareWebUntisAbsencesClick"/>
<Button Content="{Binding SelectedStudent.WithdrawActionLabel}" <Button Content="{Binding SelectedStudent.WithdrawActionLabel}"
Command="{Binding WithdrawStudentCommand}" Command="{Binding WithdrawStudentCommand}"
IsVisible="{Binding SelectedStudent, Converter={x:Static ObjectConverters.IsNotNull}}"/> IsVisible="{Binding SelectedStudent, Converter={x:Static ObjectConverters.IsNotNull}}"/>
@@ -13,6 +13,7 @@ using LehrerApp.Desktop.Views.Shared;
using LehrerApp.Desktop.Views.Students; using LehrerApp.Desktop.Views.Students;
using LehrerApp.Desktop.Views.Workload; using LehrerApp.Desktop.Views.Workload;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using System.Text;
namespace LehrerApp.Desktop.Views.Groups; namespace LehrerApp.Desktop.Views.Groups;
@@ -113,18 +114,7 @@ public partial class GroupDetailView : UserControl
throw new InvalidDataException("Die Importdatei ist größer als 20 MB."); throw new InvalidDataException("Die Importdatei ist größer als 20 MB.");
var importFile = new ImportFile(files[0].Name, buffer.ToArray()); var importFile = new ImportFile(files[0].Name, buffer.ToArray());
var preview = await Task.Run(async () => await ShowStudentImportPreview(owner, vm, importFile, files[0].Name);
await service.AnalyzeAsync(importFile, vm.Group.Id).ConfigureAwait(false));
var dialogVm = new StudentImportDialogViewModel(service, preview, files[0].Name);
var dialog = new StudentImportDialog { DataContext = dialogVm };
if (!await dialog.ShowDialog<bool>(owner)) return;
vm.LoadStudents();
vm.ParticipationTab.RefreshCurrentGrid();
var result = dialogVm.Result!;
App.Services.GetRequiredService<NotificationService>().ShowSuccess(
$"Teilnehmerimport abgeschlossen: {result.CreatedStudents} neu, "
+ $"{result.UpdatedStudents} ergänzt, {result.CreatedMemberships} zugeordnet.");
} }
catch (Exception ex) when (ex is ImportFormatException catch (Exception ex) when (ex is ImportFormatException
or InvalidDataException or InvalidDataException
@@ -135,6 +125,85 @@ public partial class GroupDetailView : UserControl
} }
} }
private async void OnImportParticipantsFromWebUntisClick(object? sender, RoutedEventArgs e)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null || DataContext is not GroupDetailViewModel vm || vm.Group is null) return;
try
{
var untis = App.Services.GetRequiredService<WebUntisIntegrationService>();
var selectionVm = new WebUntisClassSelectionViewModel(untis);
var selection = new WebUntisClassSelectionDialog { DataContext = selectionVm };
await selectionVm.InitializeAsync();
if (!await selection.ShowDialog<bool>(owner) || selectionVm.SelectedClass is null) return;
var report = await untis.GetStudentsAsync(selectionVm.SelectedClass.Name);
var importFile = BuildWebUntisStudentImport(report);
await ShowStudentImportPreview(owner, vm, importFile,
$"WebUntis · {selectionVm.SelectedClass.Name}");
}
catch (WebUntisIntegrationException ex)
{
App.Services.GetRequiredService<NotificationService>().ShowError(ex.Message);
}
catch (Exception ex) when (ex is ImportFormatException or InvalidDataException)
{
App.Services.GetRequiredService<NotificationService>().ShowError(ex.Message);
}
}
private async void OnCompareWebUntisAbsencesClick(object? sender, RoutedEventArgs e)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null || DataContext is not GroupDetailViewModel vm || vm.Group is null) return;
var dialogVm = new WebUntisAbsenceComparisonViewModel(vm.Group,
App.Services.GetRequiredService<WebUntisIntegrationService>(),
App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IParticipationSessionRepository>(),
App.Services.GetRequiredService<IParticipationRepository>());
await new WebUntisAbsenceComparisonDialog { DataContext = dialogVm }.ShowDialog(owner);
vm.ParticipationTab.RefreshCurrentGrid();
}
private static ImportFile BuildWebUntisStudentImport(UntisStudentReportDto report)
{
var builder = new StringBuilder();
builder.AppendLine("longName\tforeName\tgender\tbirthDate\tklasse.name\texternKey\taddress.email\taddress.mobile\taddress.phone\taddress.city\taddress.postCode\taddress.street");
foreach (var student in report.Students)
{
var values = new[]
{
student.LongName ?? student.Name, student.ForeName, student.Gender,
student.BirthDate?.ToString() ?? student.BirthDateRaw, student.ClassName,
student.ExternKey.ToString(), student.Address.Email, student.Address.Mobile,
student.Address.Phone, student.Address.City, student.Address.PostCode, student.Address.Street,
};
builder.AppendLine(string.Join('\t', values.Select(SafeTsv)));
}
return new ImportFile("webuntis-students.csv", Encoding.UTF8.GetBytes(builder.ToString()));
}
private static string SafeTsv(string? value) => (value ?? "").Replace('\t', ' ')
.Replace('\r', ' ').Replace('\n', ' ');
private static async Task ShowStudentImportPreview(Window owner, GroupDetailViewModel vm,
ImportFile importFile, string sourceName)
{
var service = App.Services.GetRequiredService<StudentImportService>();
var preview = await Task.Run(async () =>
await service.AnalyzeAsync(importFile, vm.Group!.Id).ConfigureAwait(false));
var dialogVm = new StudentImportDialogViewModel(service, preview, sourceName);
var dialog = new StudentImportDialog { DataContext = dialogVm };
if (!await dialog.ShowDialog<bool>(owner)) return;
vm.LoadStudents();
vm.ParticipationTab.RefreshCurrentGrid();
var result = dialogVm.Result!;
App.Services.GetRequiredService<NotificationService>().ShowSuccess(
$"Teilnehmerimport abgeschlossen: {result.CreatedStudents} neu, "
+ $"{result.UpdatedStudents} ergänzt, {result.CreatedMemberships} zugeordnet.");
}
private async Task<bool> ShowWithdrawStudentDialog(StudentSummary student) private async Task<bool> ShowWithdrawStudentDialog(StudentSummary student)
{ {
if (DataContext is not GroupDetailViewModel vm || vm.Group is null) return false; if (DataContext is not GroupDetailViewModel vm || vm.Group is null) return false;
@@ -0,0 +1,42 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.WebUntisAbsenceComparisonDialog"
x:DataType="vm:WebUntisAbsenceComparisonViewModel"
Title="Fehlzeiten mit WebUntis abgleichen" Width="850" Height="620"
MinWidth="700" MinHeight="450" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="24" RowSpacing="12">
<StackPanel Grid.Row="0" Spacing="4">
<TextBlock Text="Fehlzeiten mit WebUntis abgleichen" Classes="dialogtitle"/>
<TextBlock Text="Nur markierte Zeilen mit einer vorhandenen lokalen Kursstunde werden übernommen."
FontSize="12" Opacity="0.65"/>
</StackPanel>
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8">
<DatePicker SelectedDate="{Binding StartDate}"/>
<TextBlock Text="bis" VerticalAlignment="Center"/>
<DatePicker SelectedDate="{Binding EndDate}"/>
<Button Content="Fehlzeiten laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
</StackPanel>
<ScrollViewer Grid.Row="2">
<ItemsControl ItemsSource="{Binding Rows}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:WebUntisAbsenceRow">
<Grid ColumnDefinitions="Auto,1.5*,90,90,1.5*,1.5*" ColumnSpacing="8" Margin="0,3">
<CheckBox Grid.Column="0" IsChecked="{Binding Selected}" IsEnabled="{Binding CanApply}"/>
<TextBlock Grid.Column="1" Text="{Binding StudentName}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="2" Text="{Binding DateLabel}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="3" Text="{Binding TimeLabel}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="4" Text="{Binding UntisStatus}" TextWrapping="Wrap" VerticalAlignment="Center"/>
<TextBlock Grid.Column="5" Text="{Binding LocalStatus}" TextWrapping="Wrap" VerticalAlignment="Center"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<Grid Grid.Row="3" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
<TextBlock Grid.Column="0" Text="{Binding Status}" FontSize="12" TextWrapping="Wrap" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Schließen" Click="OnClose"/>
<Button Grid.Column="2" Content="Markierte übernehmen" Command="{Binding ApplyCommand}" IsEnabled="{Binding !Busy}"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,10 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace LehrerApp.Desktop.Views.Groups;
public partial class WebUntisAbsenceComparisonDialog : Window
{
public WebUntisAbsenceComparisonDialog() => InitializeComponent();
private void OnClose(object? sender, RoutedEventArgs e) => Close();
}
@@ -0,0 +1,34 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
xmlns:svc="clr-namespace:LehrerApp.Desktop.Services"
x:Class="LehrerApp.Desktop.Views.Groups.WebUntisClassSelectionDialog"
x:DataType="vm:WebUntisClassSelectionViewModel"
Title="Schüler aus WebUntis" Width="480" SizeToContent="Height"
CanResize="False" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="12">
<TextBlock Text="WebUntis-Klasse auswählen" Classes="dialogtitle"/>
<TextBlock Text="Die Schülerliste wird danach im gewohnten Importdialog geprüft."
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="8">
<ComboBox Grid.Column="0" ItemsSource="{Binding SchoolYears}" SelectedItem="{Binding SelectedSchoolYear}">
<ComboBox.ItemTemplate><DataTemplate x:DataType="svc:UntisSchoolYearDto"><TextBlock Text="{Binding Name}"/></DataTemplate></ComboBox.ItemTemplate>
</ComboBox>
<Button Grid.Column="1" Content="Klassen laden" Command="{Binding LoadClassesCommand}" IsEnabled="{Binding !Busy}"/>
</Grid>
<ComboBox ItemsSource="{Binding Classes}" SelectedItem="{Binding SelectedClass}" PlaceholderText="Klasse auswählen">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="svc:UntisClassDto">
<TextBlock><Run Text="{Binding Name}"/><Run Text=" — "/><Run Text="{Binding LongName}"/></TextBlock>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Text="{Binding Status}" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
<Button Grid.Column="0" Content="Abbrechen" Click="OnCancel"/>
<Button Grid.Column="2" Content="Schülerliste laden" IsEnabled="{Binding CanConfirm}" Click="OnConfirm"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,15 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.Groups;
namespace LehrerApp.Desktop.Views.Groups;
public partial class WebUntisClassSelectionDialog : Window
{
public WebUntisClassSelectionDialog() => InitializeComponent();
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
private void OnConfirm(object? sender, RoutedEventArgs e)
{
if (DataContext is WebUntisClassSelectionViewModel { SelectedClass: not null }) Close(true);
}
}
@@ -308,6 +308,11 @@
<ContentPage Header="Bearbeiten"> <ContentPage Header="Bearbeiten">
<ScrollViewer> <ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="10"> <StackPanel Margin="32,20,32,28" Spacing="10">
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,6">
<Button Content="Aus WebUntis laden…" Command="{Binding ImportWebUntisTimetableCommand}"/>
<TextBlock Text="Importiert oder vergleicht eine typische Unterrichtswoche; Vertretungen bleiben im iCal-Abgleich."
FontSize="11" Opacity="0.6" VerticalAlignment="Center"/>
</StackPanel>
<!-- Zeilenweise (GridRows) statt eines flachen UniformGrid, siehe Kommentar im <!-- Zeilenweise (GridRows) statt eines flachen UniformGrid, siehe Kommentar im
Wochenraster oben (Heute-Tab) - gleicher Grund (Grid.RowDefinitions lässt sich Wochenraster oben (Heute-Tab) - gleicher Grund (Grid.RowDefinitions lässt sich
nicht per {Binding} setzen). --> nicht per {Binding} setzen). -->
@@ -5,6 +5,7 @@ using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Planning; using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Desktop.ViewModels.Groups; using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.Views.Groups; using LehrerApp.Desktop.Views.Groups;
using LehrerApp.Desktop.Services;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Planning; namespace LehrerApp.Desktop.Views.Planning;
@@ -20,11 +21,27 @@ public partial class TimetableView : UserControl
{ {
vm.OnEditSlot = ShowSlotDialog; vm.OnEditSlot = ShowSlotDialog;
vm.OnAddSubstitution = ShowSubstitutionDialog; vm.OnAddSubstitution = ShowSubstitutionDialog;
vm.OnImportWebUntisTimetable = ShowWebUntisTimetableDialog;
vm.OnOpenLessonViewer = ShowLessonViewerDialog; vm.OnOpenLessonViewer = ShowLessonViewerDialog;
vm.OnOpenTeachingMode = ShowTeachingMode; vm.OnOpenTeachingMode = ShowTeachingMode;
} }
} }
private async Task ShowWebUntisTimetableDialog()
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return;
var vm = new WebUntisTimetableImportViewModel(
App.Services.GetRequiredService<WebUntisIntegrationService>(),
App.Services.GetRequiredService<WebUntisSettingsService>(),
App.Services.GetRequiredService<ITimetableSlotRepository>(),
App.Services.GetRequiredService<IGroupRepository>());
var dialog = new WebUntisTimetableImportDialog { DataContext = vm };
vm.OnCreateGroup = dialog.CreateGroupAsync;
await vm.InitializeAsync();
await dialog.ShowDialog<bool>(owner);
}
private async Task ShowTeachingMode(Lesson lesson) private async Task ShowTeachingMode(Lesson lesson)
{ {
var owner = TopLevel.GetTopLevel(this) as Window; var owner = TopLevel.GetTopLevel(this) as Window;
@@ -0,0 +1,56 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Planning"
xmlns:svc="clr-namespace:LehrerApp.Desktop.Services"
x:Class="LehrerApp.Desktop.Views.Planning.WebUntisTimetableImportDialog"
x:DataType="vm:WebUntisTimetableImportViewModel"
Title="Stundenplan aus WebUntis" Width="820" Height="650"
MinWidth="680" MinHeight="480" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="24" RowSpacing="14">
<StackPanel Grid.Row="0" Spacing="4">
<TextBlock Text="Stundenplan aus WebUntis" Classes="dialogtitle"/>
<TextBlock Text="Wähle eine typische Unterrichtswoche. Vorhandene Einträge werden vorgeschlagen und erst nach Bestätigung ersetzt."
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="2*,*,Auto" ColumnSpacing="10">
<ComboBox Grid.Column="0" ItemsSource="{Binding Teachers}" SelectedItem="{Binding SelectedTeacher}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="svc:UntisTeacherDto"><TextBlock Text="{Binding DisplayName}"/></DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<DatePicker Grid.Column="1" SelectedDate="{Binding WeekDate}"/>
<Button Grid.Column="2" Content="Woche laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
</Grid>
<ScrollViewer Grid.Row="2">
<ItemsControl ItemsSource="{Binding Rows}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:WebUntisTimetableRow">
<Grid ColumnDefinitions="48,85,2*,2*,Auto" ColumnSpacing="8" Margin="0,3">
<TextBlock Grid.Column="0" Text="{Binding WeekdayLabel}" VerticalAlignment="Center" FontWeight="SemiBold"/>
<StackPanel Grid.Column="1">
<TextBlock Text="{Binding PeriodNumber, StringFormat={}{0}. Std.}" FontSize="12"/>
<TextBlock Text="{Binding TimeLabel}" FontSize="10" Opacity="0.6"/>
</StackPanel>
<TextBlock Grid.Column="2" Text="{Binding UntisLabel}" TextWrapping="Wrap" VerticalAlignment="Center"/>
<ComboBox Grid.Column="3" ItemsSource="{Binding GroupOptions}" SelectedItem="{Binding SelectedGroup}"
PlaceholderText="nicht übernehmen">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:WebUntisGroupOption"><TextBlock Text="{Binding DisplayName}"/></DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Grid.Column="4" Content="Neue Gruppe…" FontSize="11"
Command="{Binding $parent[ItemsControl].((vm:WebUntisTimetableImportViewModel)DataContext).CreateGroupCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<Grid Grid.Row="3" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
<TextBlock Grid.Column="0" Text="{Binding Status}" FontSize="12" TextWrapping="Wrap" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Abbrechen" Click="OnCancel"/>
<Button Grid.Column="2" Content="Zuordnung übernehmen" Command="{Binding SaveCommand}" Click="OnSave"
IsEnabled="{Binding !Busy}"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,36 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Desktop.Views.Groups;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Planning;
public partial class WebUntisTimetableImportDialog : Window
{
public WebUntisTimetableImportDialog() => InitializeComponent();
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
private void OnSave(object? sender, RoutedEventArgs e)
{
if (DataContext is WebUntisTimetableImportViewModel { Saved: true }) Close(true);
}
public async Task<LearningGroup?> CreateGroupAsync(WebUntisTimetableRow source)
{
var vm = App.Services.GetRequiredService<AddGroupDialogViewModel>();
vm.Name = source.SuggestedGroupName;
vm.Subject = source.SubjectName ?? "";
vm.GradeLevel = ParseGrade(source.SuggestedGroupName) ?? 10;
var dialog = new AddGroupDialog { DataContext = vm };
return await dialog.ShowDialog<bool>(this) ? vm.Result : null;
}
private static int? ParseGrade(string value)
{
var digits = new string(value.TakeWhile(char.IsDigit).ToArray());
return int.TryParse(digits, out var grade) && grade is >= 1 and <= 13 ? grade : null;
}
}
@@ -967,7 +967,40 @@
<TextBlock Text="Untis-Einbettung" FontSize="18" FontWeight="SemiBold"/> <TextBlock Text="Untis-Einbettung" FontSize="18" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap" <TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Bindet den persönlichen WebUntis-Stundenplan und den schulweiten Jahresplan als zwei unabhängige iCal-Quellen ein."/> Text="Bindet WebUntis-Daten über deinen persönlichen Zugang ein. Der bestehende iCal-Abgleich für Vertretungen bleibt davon unabhängig."/>
<TextBlock Text="WebUntis-API" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Ermöglicht Stundenplan-, Schüler- und Fehlzeitenabgleich. Die Zugangsdaten werden nur auf diesem Gerät verschlüsselt gespeichert; der LehrerApp-Server hält sie und die WebUntis-Sitzung nur im Arbeitsspeicher."/>
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto" ColumnSpacing="8" RowSpacing="8">
<StackPanel Grid.Row="0" Grid.Column="0" Spacing="4">
<TextBlock Text="Schule" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding UntisSchool}" PlaceholderText="Schulkennung, nicht Anzeigename"/>
</StackPanel>
<StackPanel Grid.Row="0" Grid.Column="1" Spacing="4">
<TextBlock Text="Server (optional)" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding UntisHost}" PlaceholderText="z. B. arche.webuntis.com"/>
</StackPanel>
<StackPanel Grid.Row="1" Grid.Column="0" Spacing="4">
<TextBlock Text="Benutzername" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding UntisUsername}"/>
</StackPanel>
<StackPanel Grid.Row="1" Grid.Column="1" Spacing="4">
<TextBlock Text="Passwort" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding UntisPassword}" PasswordChar="●"/>
</StackPanel>
</Grid>
<TextBlock FontSize="11" Opacity="0.6" TextWrapping="Wrap"
Text="Die Schulkennung steht in der WebUntis-Anmelde-URL hinter ?school=. Als Server kannst du auch die vollständige Anmelde-URL einfügen; Server und Schulkennung sind häufig verschieden."/>
<TextBlock Text="{Binding UntisApiStatus}" FontSize="12" TextWrapping="Wrap"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Anmeldung prüfen und speichern" Command="{Binding UntisSaveApiCommand}"
IsEnabled="{Binding !UntisApiBusy}"/>
<Button Content="API-Zugang entfernen" Command="{Binding UntisRemoveApiCommand}"
IsVisible="{Binding UntisApiIsConfigured}" IsEnabled="{Binding !UntisApiBusy}"/>
</StackPanel>
<Separator Margin="0,8"/>
<TextBlock Text="WebUntis-Stundenplan-Abgleich" FontSize="16" FontWeight="SemiBold"/> <TextBlock Text="WebUntis-Stundenplan-Abgleich" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap" <TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
+22
View File
@@ -1220,6 +1220,28 @@ echte DATE-Ganztags-/Mehrtagstermine.
- Tests decken Parservarianten, strikten Teilimport-Schutz, Snapshot-Idempotenz/-Bereinigung, - Tests decken Parservarianten, strikten Teilimport-Schutz, Snapshot-Idempotenz/-Bereinigung,
verschlüsselte Einstellungen, Bereichsabfragen und die parallele Dashboard-Anzeige ab. verschlüsselte Einstellungen, Bereichsabfragen und die parallele Dashboard-Anzeige ab.
**Nachtrag zu 4.3, WebUntis-API-Integration (August 2026):** Zusätzlich zu den unverändert
weiterlaufenden iCal-Quellen kann ein persönlicher WebUntis-Zugang im selben Einstellungsreiter
hinterlegt werden. Schule, Host, Benutzername und Passwort liegen auf dem Desktop AES-256-GCM-
verschlüsselt; der API-Server persistiert sie nicht, sondern hält je LehrerApp-Benutzer einen
eigenen Client im Arbeitsspeicher. Dessen WebUntis-Sitzung bleibt nach einem Abruf zehn Minuten
offen und wird bei weiterer Nutzung wiederverwendet.
- 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.
Vorhandene Gruppen werden vorgeschlagen; eine neue Lerngruppe kann direkt aus einer Untis-Zeile
angelegt werden. Vertretungen bleiben bewusst beim etablierten iCal-Diff.
- Die Kursansicht kann eine WebUntis-Klasse statt einer CSV-Datei auswählen. Der abgerufene
Schülerreport läuft anschließend durch exakt dieselbe Dubletten-, Ergänzungs- und
Mitgliedschaftsvorschau wie der manuelle Stammdatenimport; `externKey` bleibt als stabile
WebUntis-Kennung erhalten. Eine echte Unterrichts-/Schülergruppen-Mitgliederliste ist mit dem
hier verwendeten kennwortbasierten JSON-RPC-Zugang nicht verlässlich verfügbar und bleibt ein
späterer Ausbau über die freigabepflichtige offizielle Platform-/OneRoster-API.
- Der Fehlzeitenabgleich lädt die in WebUntis bekannten Fehlzeiten der Kursmitglieder und stellt
Untis- und lokalen Status gegenüber. Nur explizit markierte Einträge, für deren Datum bereits
eine lokale `ParticipationSession` dieses Kurses existiert, können als offen, entschuldigt oder
unentschuldigt übernommen werden; fremde/ganz­tägige Abwesenheiten erzeugen keine lokale Stunde.
### 4.4 Wochen-/Tagesansicht ### 4.4 Wochen-/Tagesansicht
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3 - [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3
("Heute"-Tab: Tagesliste unten angedockt, gruppenübergreifendes Wochenraster darüber, inkl. ("Heute"-Tab: Tagesliste unten angedockt, gruppenübergreifendes Wochenraster darüber, inkl.