Untis API Integration
This commit is contained in:
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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/ganztä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.
|
||||||
|
|||||||
Reference in New Issue
Block a user