Vorarbeit: WebUntis-API
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Globalization;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Sync.Models;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -243,6 +244,132 @@ public static class Endpoints
|
||||
});
|
||||
}
|
||||
|
||||
// ── WebUntis (granulare, zustandslose Abrufe) ─────────────────────────────
|
||||
|
||||
public static void MapWebUntisEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/webuntis").RequireAuthorization();
|
||||
|
||||
group.MapGet("/schoolyears", (WebUntisClient client, CancellationToken cancellationToken) =>
|
||||
WebUntisResult(() => client.GetSchoolYearsAsync(cancellationToken)));
|
||||
|
||||
group.MapGet("/classes", ([FromQuery] int schoolyearId, WebUntisClient client,
|
||||
CancellationToken cancellationToken) => schoolyearId <= 0
|
||||
? Task.FromResult<IResult>(Results.BadRequest("schoolyearId muss größer als 0 sein."))
|
||||
: WebUntisResult(() => client.GetClassesAsync(schoolyearId, cancellationToken)));
|
||||
|
||||
group.MapGet("/teachers", (WebUntisClient client, CancellationToken cancellationToken) =>
|
||||
WebUntisResult(() => client.GetTeachersAsync(cancellationToken)));
|
||||
|
||||
group.MapGet("/student-report", ([FromQuery] string? className, WebUntisClient client,
|
||||
CancellationToken cancellationToken) =>
|
||||
WebUntisResult(() => client.GetStudentReportAsync(className, cancellationToken)));
|
||||
|
||||
group.MapGet("/holidays", (WebUntisClient client, CancellationToken cancellationToken) =>
|
||||
WebUntisResult(() => client.GetHolidaysAsync(cancellationToken)));
|
||||
|
||||
group.MapGet("/timegrid", (WebUntisClient client, CancellationToken cancellationToken) =>
|
||||
WebUntisResult(() => client.GetTimeGridAsync(cancellationToken)));
|
||||
|
||||
group.MapGet("/substitutions", ([FromQuery] int startDate, [FromQuery] int endDate,
|
||||
[FromQuery] int? departmentId, WebUntisClient client, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!ValidDateRange(startDate, endDate, 31, out var error))
|
||||
return Task.FromResult<IResult>(Results.BadRequest(error));
|
||||
return WebUntisResult(() =>
|
||||
client.GetSubstitutionsAsync(startDate, endDate, departmentId, cancellationToken));
|
||||
});
|
||||
|
||||
group.MapGet("/timetable", ([FromQuery] string elementType, [FromQuery] int elementId,
|
||||
[FromQuery] int startDate, [FromQuery] int endDate, WebUntisClient client,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!Enum.TryParse<UntisTimetableElementType>(elementType, true, out var parsedType) ||
|
||||
!Enum.IsDefined(parsedType))
|
||||
return Task.FromResult<IResult>(Results.BadRequest(
|
||||
"elementType muss class, teacher, subject, room oder student sein."));
|
||||
if (elementId <= 0)
|
||||
return Task.FromResult<IResult>(Results.BadRequest("elementId muss größer als 0 sein."));
|
||||
if (!ValidDateRange(startDate, endDate, 62, out var error))
|
||||
return Task.FromResult<IResult>(Results.BadRequest(error));
|
||||
return WebUntisResult(() =>
|
||||
client.GetTimetableAsync(parsedType, elementId, startDate, endDate, cancellationToken));
|
||||
});
|
||||
|
||||
group.MapGet("/students/{studentKey:int}/absences", (int studentKey, [FromQuery] int startDate,
|
||||
[FromQuery] int endDate, WebUntisClient client, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (studentKey <= 0)
|
||||
return Task.FromResult<IResult>(Results.BadRequest("studentKey muss größer als 0 sein."));
|
||||
if (!ValidDateRange(startDate, endDate, 400, out var error))
|
||||
return Task.FromResult<IResult>(Results.BadRequest(error));
|
||||
return WebUntisResult(() =>
|
||||
client.GetStudentAbsencesAsync(studentKey, startDate, endDate, cancellationToken));
|
||||
});
|
||||
|
||||
group.MapGet("/students/{studentId:int}/class-register-entries", (int studentId,
|
||||
[FromQuery] int startDate, [FromQuery] int endDate, WebUntisClient client,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (studentId <= 0)
|
||||
return Task.FromResult<IResult>(Results.BadRequest("studentId muss größer als 0 sein."));
|
||||
if (!ValidDateRange(startDate, endDate, 400, out var error))
|
||||
return Task.FromResult<IResult>(Results.BadRequest(error));
|
||||
return WebUntisResult(() =>
|
||||
client.GetClassRegisterEntriesAsync(studentId, startDate, endDate, cancellationToken));
|
||||
});
|
||||
|
||||
group.MapGet("/class-register/categories", (WebUntisClient client,
|
||||
CancellationToken cancellationToken) =>
|
||||
WebUntisResult(() => client.GetClassRegisterCategoriesAsync(cancellationToken)));
|
||||
|
||||
group.MapGet("/class-register/category-groups", (WebUntisClient client,
|
||||
CancellationToken cancellationToken) =>
|
||||
WebUntisResult(() => client.GetClassRegisterCategoryGroupsAsync(cancellationToken)));
|
||||
}
|
||||
|
||||
private static async Task<IResult> WebUntisResult<T>(Func<Task<T>> operation)
|
||||
{
|
||||
try { return Results.Ok(await operation()); }
|
||||
catch (WebUntisConfigurationException exception)
|
||||
{
|
||||
return Results.Problem(exception.Message, statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
catch (WebUntisException exception)
|
||||
{
|
||||
return Results.Problem(exception.Message, statusCode: StatusCodes.Status502BadGateway);
|
||||
}
|
||||
catch (InvalidDataException exception)
|
||||
{
|
||||
return Results.Problem($"Der WebUntis-Report ist ungültig: {exception.Message}",
|
||||
statusCode: StatusCodes.Status502BadGateway);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ValidDateRange(int startDate, int endDate, int maximumDays, out string error)
|
||||
{
|
||||
error = "";
|
||||
if (!DateOnly.TryParseExact(startDate.ToString(CultureInfo.InvariantCulture), "yyyyMMdd",
|
||||
CultureInfo.InvariantCulture, DateTimeStyles.None, out var start) ||
|
||||
!DateOnly.TryParseExact(endDate.ToString(CultureInfo.InvariantCulture), "yyyyMMdd",
|
||||
CultureInfo.InvariantCulture, DateTimeStyles.None, out var end))
|
||||
{
|
||||
error = "startDate und endDate müssen gültige Datumswerte im Format yyyyMMdd sein.";
|
||||
return false;
|
||||
}
|
||||
if (end < start)
|
||||
{
|
||||
error = "endDate darf nicht vor startDate liegen.";
|
||||
return false;
|
||||
}
|
||||
if (end.DayNumber - start.DayNumber + 1 > maximumDays)
|
||||
{
|
||||
error = $"Der Zeitraum darf höchstens {maximumDays} Tage umfassen.";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string[]> ValidateLocation(SchoolLocationRequest request)
|
||||
{
|
||||
var errors = new Dictionary<string, string[]>();
|
||||
|
||||
@@ -97,6 +97,28 @@ builder.Services.AddHttpClient("dwd", client =>
|
||||
});
|
||||
builder.Services.AddSingleton(sp => new DwdWeatherService(
|
||||
sp.GetRequiredService<IHttpClientFactory>().CreateClient("dwd")));
|
||||
builder.Services.Configure<WebUntisOptions>(options =>
|
||||
{
|
||||
builder.Configuration.GetSection("WebUntis").Bind(options);
|
||||
options.School = builder.Configuration["WEBUNTIS_SCHOOL"] ?? options.School;
|
||||
options.Host = builder.Configuration["WEBUNTIS_HOST"] ?? options.Host;
|
||||
options.Username = builder.Configuration["WEBUNTIS_USER"] ?? options.Username;
|
||||
options.Password = builder.Configuration["WEBUNTIS_PASSWORD"] ?? options.Password;
|
||||
options.Client = builder.Configuration["WEBUNTIS_CLIENT"] ?? options.Client;
|
||||
if (int.TryParse(builder.Configuration["WEBUNTIS_SESSION_IDLE_MINUTES"], out var idleMinutes))
|
||||
options.SessionIdleTimeoutMinutes = idleMinutes;
|
||||
});
|
||||
builder.Services.AddHttpClient("webuntis", client =>
|
||||
{
|
||||
// Die einzelnen WebUntis-Schritte besitzen eigene Timeouts; insbesondere ein asynchron
|
||||
// erzeugter Schülerreport darf länger als der HttpClient-Standardtimeout pollen.
|
||||
client.Timeout = Timeout.InfiniteTimeSpan;
|
||||
});
|
||||
// Der Client hält eine WebUntis-Session über mehrere API-Aufrufe hinweg. Deshalb muss seine
|
||||
// Lebensdauer der Serveranwendung entsprechen und darf nicht pro HTTP-Anfrage neu beginnen.
|
||||
builder.Services.AddSingleton(sp => new WebUntisClient(
|
||||
sp.GetRequiredService<IHttpClientFactory>().CreateClient("webuntis"),
|
||||
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<WebUntisOptions>>()));
|
||||
|
||||
var app = builder.Build();
|
||||
app.UseForwardedHeaders();
|
||||
@@ -112,5 +134,6 @@ app.MapSnapshotEndpoints();
|
||||
app.MapReadableSnapshotEndpoints();
|
||||
app.MapPlainSyncEndpoints();
|
||||
app.MapSchoolWeatherEndpoints();
|
||||
app.MapWebUntisEndpoints();
|
||||
app.Run();
|
||||
return 0;
|
||||
|
||||
@@ -0,0 +1,630 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace LehrerApp.Api;
|
||||
|
||||
public sealed class WebUntisClient : IAsyncDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
|
||||
private readonly HttpClient _http;
|
||||
private readonly WebUntisOptions _options;
|
||||
private readonly SemaphoreSlim _sessionGate = new(1, 1);
|
||||
private readonly Timer _sessionExpiryTimer;
|
||||
private string? _sessionId;
|
||||
private DateTimeOffset _sessionExpiresAt;
|
||||
private int _activeRequests;
|
||||
private bool _disposed;
|
||||
|
||||
public WebUntisClient(HttpClient http, IOptions<WebUntisOptions> options)
|
||||
{
|
||||
_http = http;
|
||||
_options = options.Value;
|
||||
_sessionExpiryTimer = new Timer(
|
||||
static state => _ = ((WebUntisClient)state!).CloseExpiredSessionAsync(),
|
||||
this,
|
||||
Timeout.InfiniteTimeSpan,
|
||||
Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<UntisSchoolYear>> GetSchoolYearsAsync(CancellationToken cancellationToken) =>
|
||||
WithSessionAsync(async sessionId =>
|
||||
{
|
||||
var entries = RequireArray(await RpcAsync("getSchoolyears", new { }, sessionId, cancellationToken),
|
||||
"WebUntis hat keine gültige Schuljahr-Liste geliefert.");
|
||||
return (IReadOnlyList<UntisSchoolYear>)entries.Select(entry =>
|
||||
{
|
||||
var id = RequiredInt(entry, "id");
|
||||
var name = OptionalString(entry, "name")
|
||||
?? throw new WebUntisException($"WebUntis-Schuljahr {id} hat keinen Namen.");
|
||||
return new UntisSchoolYear(id, name, RequiredInt(entry, "startDate"), RequiredInt(entry, "endDate"));
|
||||
}).ToList();
|
||||
}, cancellationToken);
|
||||
|
||||
public Task<IReadOnlyList<UntisClass>> GetClassesAsync(int schoolYearId, CancellationToken cancellationToken) =>
|
||||
WithSessionAsync(async sessionId =>
|
||||
{
|
||||
var entries = RequireArray(await RpcAsync("getKlassen", new { schoolyearId = schoolYearId }, sessionId,
|
||||
cancellationToken), "WebUntis hat keine gültige Klassen-Liste geliefert.");
|
||||
return (IReadOnlyList<UntisClass>)entries.Select(entry =>
|
||||
{
|
||||
var id = RequiredInt(entry, "id");
|
||||
var name = OptionalString(entry, "name")
|
||||
?? throw new WebUntisException($"WebUntis-Klasse {id} hat keinen Namen.");
|
||||
return new UntisClass(id, name, OptionalString(entry, "longName"),
|
||||
OptionalString(entry, "foreColor"), OptionalString(entry, "backColor"),
|
||||
OptionalInt(entry, "did"), OptionalInt(entry, "teacher1"), OptionalInt(entry, "teacher2"));
|
||||
}).ToList();
|
||||
}, cancellationToken);
|
||||
|
||||
public Task<IReadOnlyList<UntisTeacher>> GetTeachersAsync(CancellationToken cancellationToken) =>
|
||||
WithSessionAsync(async sessionId =>
|
||||
{
|
||||
var entries = RequireArray(await RpcAsync("getTeachers", new { }, sessionId, cancellationToken),
|
||||
"WebUntis hat keine gültige Lehrer-Liste geliefert.");
|
||||
return (IReadOnlyList<UntisTeacher>)entries.Select(entry =>
|
||||
{
|
||||
var id = RequiredInt(entry, "id");
|
||||
var name = OptionalString(entry, "name")
|
||||
?? throw new WebUntisException($"WebUntis-Lehrer {id} hat kein Kürzel.");
|
||||
var departments = TryProperty(entry, "dids", out var dids) && dids.ValueKind == JsonValueKind.Array
|
||||
? dids.EnumerateArray().Select(OptionalInt).Where(value => value is not null)
|
||||
.Select(value => value!.Value).Distinct().Order().ToList()
|
||||
: [];
|
||||
return new UntisTeacher(id, name, OptionalString(entry, "foreName"),
|
||||
OptionalString(entry, "longName"), OptionalString(entry, "title"),
|
||||
OptionalBoolean(entry, "active"), departments);
|
||||
}).ToList();
|
||||
}, cancellationToken);
|
||||
|
||||
public Task<IReadOnlyList<UntisHoliday>> GetHolidaysAsync(CancellationToken cancellationToken) =>
|
||||
WithSessionAsync(async sessionId =>
|
||||
{
|
||||
var entries = RequireArray(await RpcAsync("getHolidays", new { }, sessionId, cancellationToken),
|
||||
"WebUntis hat keine gültige Ferien-Liste geliefert.");
|
||||
return (IReadOnlyList<UntisHoliday>)entries.Select(entry =>
|
||||
{
|
||||
var id = RequiredInt(entry, "id");
|
||||
var name = OptionalString(entry, "name")
|
||||
?? throw new WebUntisException($"WebUntis-Ferien {id} haben keinen Namen.");
|
||||
return new UntisHoliday(id, name, OptionalString(entry, "longName"),
|
||||
RequiredInt(entry, "startDate"), RequiredInt(entry, "endDate"));
|
||||
}).ToList();
|
||||
}, cancellationToken);
|
||||
|
||||
public Task<IReadOnlyList<UntisTimeGridDay>> GetTimeGridAsync(CancellationToken cancellationToken) =>
|
||||
WithSessionAsync(async sessionId =>
|
||||
{
|
||||
var entries = RequireArray(await RpcAsync("getTimegridUnits", new { }, sessionId, cancellationToken),
|
||||
"WebUntis hat keine gültigen Zeitraster-Daten geliefert.");
|
||||
return (IReadOnlyList<UntisTimeGridDay>)entries.Select(entry =>
|
||||
{
|
||||
var units = TryProperty(entry, "timeUnits", out var values) && values.ValueKind == JsonValueKind.Array
|
||||
? values.EnumerateArray().Select(unit => new UntisTimeUnit(
|
||||
OptionalString(unit, "name") ?? "", RequiredInt(unit, "startTime"), RequiredInt(unit, "endTime")))
|
||||
.ToList()
|
||||
: [];
|
||||
return new UntisTimeGridDay(RequiredInt(entry, "day"), units, entry.Clone());
|
||||
}).ToList();
|
||||
}, cancellationToken);
|
||||
|
||||
public Task<UntisStudentReport> GetStudentReportAsync(string? classNameFilter,
|
||||
CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
|
||||
{
|
||||
var reportData = await RequestReportAsync(sessionId, cancellationToken)
|
||||
?? await PollReportAsync(sessionId, cancellationToken);
|
||||
var reportText = await FetchReportTextAsync(sessionId, reportData, cancellationToken);
|
||||
var allStudents = WebUntisStudentReportParser.Parse(reportText);
|
||||
var normalizedFilter = string.IsNullOrWhiteSpace(classNameFilter) ? null : classNameFilter.Trim();
|
||||
var students = normalizedFilter is null
|
||||
? allStudents
|
||||
: allStudents.Where(student => string.Equals(student.ClassName, normalizedFilter,
|
||||
StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
return new UntisStudentReport(students.Count, normalizedFilter, students);
|
||||
}, cancellationToken);
|
||||
|
||||
public Task<IReadOnlyList<UntisSubstitution>> GetSubstitutionsAsync(int startDate, int endDate,
|
||||
int? departmentId, CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
|
||||
{
|
||||
var entries = RequireArray(await RpcAsync("getSubstitutions", new
|
||||
{
|
||||
startDate,
|
||||
endDate,
|
||||
departmentId = departmentId ?? 0,
|
||||
}, sessionId, cancellationToken), "WebUntis hat keine gültigen Vertretungsplan-Daten geliefert.");
|
||||
|
||||
return (IReadOnlyList<UntisSubstitution>)entries.Select(entry => new UntisSubstitution(
|
||||
OptionalString(entry, "type") ?? "unknown",
|
||||
OptionalInt(entry, "lsid"),
|
||||
OptionalString(entry, "lstype"),
|
||||
RequiredInt(entry, "date"),
|
||||
RequiredInt(entry, "startTime"),
|
||||
RequiredInt(entry, "endTime"),
|
||||
OptionalString(entry, "txt"),
|
||||
Entities(entry, "kl"),
|
||||
Entities(entry, "te"),
|
||||
Entities(entry, "su"),
|
||||
Entities(entry, "ro"),
|
||||
ParseReschedule(entry),
|
||||
entry.Clone())).ToList();
|
||||
}, cancellationToken);
|
||||
|
||||
public Task<IReadOnlyList<UntisTimetablePeriod>> GetTimetableAsync(UntisTimetableElementType elementType,
|
||||
int elementId, int startDate, int endDate, CancellationToken cancellationToken) =>
|
||||
WithSessionAsync(async sessionId =>
|
||||
{
|
||||
var options = new
|
||||
{
|
||||
element = new { id = elementId, type = (int)elementType },
|
||||
startDate,
|
||||
endDate,
|
||||
showBooking = false,
|
||||
showInfo = true,
|
||||
showSubstText = true,
|
||||
showLsText = true,
|
||||
showLsNumber = true,
|
||||
showStudentgroup = true,
|
||||
};
|
||||
var entries = RequireArray(await RpcAsync("getTimetable", new { options }, sessionId, cancellationToken),
|
||||
"WebUntis hat keine gültigen Stundenplan-Daten geliefert.");
|
||||
return (IReadOnlyList<UntisTimetablePeriod>)entries.Select(entry => new UntisTimetablePeriod(
|
||||
RequiredInt(entry, "id"), RequiredInt(entry, "date"), RequiredInt(entry, "startTime"),
|
||||
RequiredInt(entry, "endTime"), OptionalString(entry, "code"), OptionalString(entry, "activityType"),
|
||||
OptionalString(entry, "info"), OptionalString(entry, "lstext"), OptionalString(entry, "substText"),
|
||||
OptionalString(entry, "sg"), Entities(entry, "kl"), Entities(entry, "te"), Entities(entry, "su"),
|
||||
Entities(entry, "ro"), entry.Clone())).ToList();
|
||||
}, cancellationToken);
|
||||
|
||||
public Task<UntisStudentAbsenceReport> GetStudentAbsencesAsync(int studentKey, int startDate, int endDate,
|
||||
CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
|
||||
{
|
||||
var result = await RpcAsync("getTimetableWithAbsences", new
|
||||
{
|
||||
options = new { startDate, endDate },
|
||||
}, sessionId, cancellationToken);
|
||||
if (!TryProperty(result, "periodsWithAbsences", out var periods) || periods.ValueKind != JsonValueKind.Array)
|
||||
throw new WebUntisException("WebUntis hat keine gültigen Fehlzeiten-Daten geliefert.");
|
||||
|
||||
var absences = periods.EnumerateArray()
|
||||
.Where(entry => OptionalInt(entry, "studentId") == studentKey)
|
||||
.Select(entry => new UntisStudentAbsence(
|
||||
RequiredInt(entry, "studentId"),
|
||||
RequiredInt(entry, "date"),
|
||||
RequiredInt(entry, "startTime"),
|
||||
RequiredInt(entry, "endTime"),
|
||||
OptionalInt(entry, "absentTime") ?? 0,
|
||||
OptionalBoolean(entry, "checked"),
|
||||
OptionalString(entry, "absenceReason"),
|
||||
OptionalString(entry, "excuseStatus"),
|
||||
OptionalInt(entry, "subjectId"),
|
||||
IntArray(entry, "teacherIds"),
|
||||
OptionalString(entry, "studentGroup"),
|
||||
entry.Clone()))
|
||||
.OrderBy(entry => entry.Date)
|
||||
.ThenBy(entry => entry.StartTime)
|
||||
.ToList();
|
||||
|
||||
return new UntisStudentAbsenceReport(studentKey, startDate, endDate, absences.Count,
|
||||
absences.Sum(entry => entry.AbsentMinutes), absences);
|
||||
}, cancellationToken);
|
||||
|
||||
public Task<IReadOnlyList<UntisClassRegisterEntry>> GetClassRegisterEntriesAsync(int studentId,
|
||||
int startDate, int endDate, CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
|
||||
{
|
||||
var entries = RequireArray(await RpcAsync("getClassregEvents", new
|
||||
{
|
||||
startDate,
|
||||
endDate,
|
||||
id = studentId,
|
||||
type = (int)UntisTimetableElementType.Student,
|
||||
}, sessionId, cancellationToken), "WebUntis hat keine gültigen Klassenbuch-Daten geliefert.");
|
||||
|
||||
return (IReadOnlyList<UntisClassRegisterEntry>)entries.Select(entry =>
|
||||
{
|
||||
var surname = OptionalString(entry, "surname");
|
||||
var foreName = OptionalString(entry, "forname");
|
||||
var displayName = string.Join(' ', new[] { foreName, surname }.Where(value => value is not null));
|
||||
return new UntisClassRegisterEntry(OptionalInt(entry, "studentid"), surname, foreName,
|
||||
displayName, RequiredInt(entry, "date"), OptionalString(entry, "subject"),
|
||||
OptionalInt(entry, "categoryId"), OptionalString(entry, "reason"),
|
||||
OptionalString(entry, "text"), entry.Clone());
|
||||
}).OrderBy(entry => entry.Date).ThenBy(entry => entry.DisplayName).ToList();
|
||||
}, cancellationToken);
|
||||
|
||||
public Task<IReadOnlyList<UntisClassRegisterCategory>> GetClassRegisterCategoriesAsync(
|
||||
CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
|
||||
{
|
||||
var entries = RequireArray(await RpcAsync("getClassregCategories", new { }, sessionId, cancellationToken),
|
||||
"WebUntis hat keine gültigen Klassenbuch-Kategorien geliefert.");
|
||||
return (IReadOnlyList<UntisClassRegisterCategory>)entries.Select(entry =>
|
||||
new UntisClassRegisterCategory(RequiredInt(entry, "id"), OptionalString(entry, "name") ?? "",
|
||||
OptionalString(entry, "longName"), OptionalInt(entry, "groupId"), entry.Clone())).ToList();
|
||||
}, cancellationToken);
|
||||
|
||||
public Task<IReadOnlyList<UntisClassRegisterCategoryGroup>> GetClassRegisterCategoryGroupsAsync(
|
||||
CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
|
||||
{
|
||||
var entries = RequireArray(await RpcAsync("getClassregCategoryGroups", new { }, sessionId,
|
||||
cancellationToken), "WebUntis hat keine gültigen Klassenbuch-Kategoriegruppen geliefert.");
|
||||
return (IReadOnlyList<UntisClassRegisterCategoryGroup>)entries.Select(entry =>
|
||||
new UntisClassRegisterCategoryGroup(RequiredInt(entry, "id"),
|
||||
OptionalString(entry, "name") ?? "", entry.Clone())).ToList();
|
||||
}, cancellationToken);
|
||||
|
||||
private async Task<T> WithSessionAsync<T>(Func<string, Task<T>> action, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var lease = await AcquireSessionAsync(cancellationToken);
|
||||
return await action(lease.SessionId);
|
||||
}
|
||||
|
||||
private async Task<SessionLease> AcquireSessionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _sessionGate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
_sessionExpiryTimer.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
|
||||
|
||||
// Falls der Timer durch Threadpool-Last verspätet ausgeführt wird, darf eine bereits
|
||||
// abgelaufene Sitzung nicht noch einmal für einen neuen Abruf verwendet werden.
|
||||
if (_sessionId is not null && _activeRequests == 0 && _sessionExpiresAt <= DateTimeOffset.UtcNow)
|
||||
{
|
||||
var expiredSessionId = _sessionId;
|
||||
_sessionId = null;
|
||||
await TryLogoutAsync(expiredSessionId);
|
||||
}
|
||||
|
||||
if (_sessionId is null)
|
||||
{
|
||||
var configuration = GetConfiguration();
|
||||
var result = await RpcAsync("authenticate", new
|
||||
{
|
||||
user = configuration.Username,
|
||||
password = configuration.Password,
|
||||
client = configuration.Client,
|
||||
}, null, cancellationToken);
|
||||
_sessionId = OptionalString(result, "sessionId")
|
||||
?? throw new WebUntisException("WebUntis-Login fehlgeschlagen: Keine sessionId erhalten.");
|
||||
}
|
||||
|
||||
_activeRequests++;
|
||||
return new SessionLease(this, _sessionId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sessionGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask ReleaseSessionAsync()
|
||||
{
|
||||
await _sessionGate.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (_activeRequests > 0) _activeRequests--;
|
||||
if (_disposed || _activeRequests != 0 || _sessionId is null) return;
|
||||
|
||||
var timeout = SessionIdleTimeout;
|
||||
_sessionExpiresAt = DateTimeOffset.UtcNow.Add(timeout);
|
||||
_sessionExpiryTimer.Change(timeout, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sessionGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CloseExpiredSessionAsync()
|
||||
{
|
||||
string? sessionId = null;
|
||||
await _sessionGate.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (_disposed || _activeRequests != 0 || _sessionId is null) return;
|
||||
var remaining = _sessionExpiresAt - DateTimeOffset.UtcNow;
|
||||
if (remaining > TimeSpan.Zero)
|
||||
{
|
||||
_sessionExpiryTimer.Change(remaining, Timeout.InfiniteTimeSpan);
|
||||
return;
|
||||
}
|
||||
|
||||
sessionId = _sessionId;
|
||||
_sessionId = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sessionGate.Release();
|
||||
}
|
||||
|
||||
if (sessionId is not null) await TryLogoutAsync(sessionId);
|
||||
}
|
||||
|
||||
private async Task TryLogoutAsync(string sessionId)
|
||||
{
|
||||
try { await RpcAsync("logout", new { }, sessionId, CancellationToken.None); }
|
||||
catch { /* Ein fehlgeschlagener Logout darf Abrufe und Shutdown nicht fehlschlagen lassen. */ }
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
string? sessionId;
|
||||
await _sessionGate.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
sessionId = _sessionId;
|
||||
_sessionId = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sessionGate.Release();
|
||||
}
|
||||
|
||||
// Außerhalb des Gates warten: ein bereits laufender Timer-Callback könnte selbst gerade
|
||||
// auf dieses Gate warten und würde sonst den Shutdown blockieren.
|
||||
await _sessionExpiryTimer.DisposeAsync();
|
||||
if (sessionId is not null) await TryLogoutAsync(sessionId);
|
||||
}
|
||||
|
||||
private TimeSpan SessionIdleTimeout =>
|
||||
TimeSpan.FromMinutes(Math.Clamp(_options.SessionIdleTimeoutMinutes, 1, 30));
|
||||
|
||||
private async Task<JsonElement> RpcAsync(string method, object parameters, string? sessionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var configuration = GetConfiguration();
|
||||
var uri = $"https://{configuration.Host}/WebUntis/jsonrpc.do?school={Uri.EscapeDataString(configuration.School)}";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, uri)
|
||||
{
|
||||
Content = JsonContent.Create(new { id = "lehrerapp-webuntis", method, @params = parameters, jsonrpc = "2.0" },
|
||||
options: JsonOptions),
|
||||
};
|
||||
if (sessionId is not null) request.Headers.Add("Cookie", $"JSESSIONID={sessionId}");
|
||||
|
||||
using var response = await SendAsync(request, TimeSpan.FromSeconds(20), cancellationToken);
|
||||
var payload = await ReadJsonAsync(response, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new WebUntisException($"WebUntis RPC ({method}) fehlgeschlagen: {ErrorMessage(payload, response)}");
|
||||
if (payload.ValueKind != JsonValueKind.Object)
|
||||
throw new WebUntisException($"Leere Antwort von WebUntis RPC ({method}).");
|
||||
if (TryProperty(payload, "error", out var error) && error.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined)
|
||||
throw new WebUntisException($"WebUntis RPC-Fehler ({method}): {ErrorMessage(error, response)}");
|
||||
if (!TryProperty(payload, "result", out var result))
|
||||
throw new WebUntisException($"WebUntis RPC ({method}) ohne Ergebnis.");
|
||||
return result.Clone();
|
||||
}
|
||||
|
||||
private async Task<ReportData?> RequestReportAsync(string sessionId, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = new Dictionary<string, string?>
|
||||
{
|
||||
["name"] = "Student", ["format"] = "csv", ["klasseId"] = "-1",
|
||||
["studentsForDate"] = "true", ["context"] = "klasseId",
|
||||
};
|
||||
var uri = QueryHelpers.AddQueryString($"https://{GetConfiguration().Host}/WebUntis/reports.do", query);
|
||||
using var request = ReportRequest(uri, sessionId, acceptJson: true);
|
||||
using var response = await SendAsync(request, TimeSpan.FromSeconds(20), cancellationToken);
|
||||
var payload = await ReadJsonAsync(response, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
if (TryProperty(payload, "errors", out var errors) && errors.ValueKind == JsonValueKind.Array &&
|
||||
errors.EnumerateArray().Any(error => OptionalString(error, "code") == "4" || OptionalInt(error, "code") == 4))
|
||||
return null;
|
||||
throw new WebUntisException($"Report-Anfrage fehlgeschlagen: {ErrorMessage(payload, response)}");
|
||||
}
|
||||
|
||||
if (!TryProperty(payload, "data", out var data) || data.ValueKind != JsonValueKind.Object ||
|
||||
(TryProperty(data, "error", out var reportError) && reportError.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined))
|
||||
throw new WebUntisException($"Report-Anfrage fehlgeschlagen: {ErrorMessage(payload, response)}");
|
||||
if (!OptionalBoolean(data, "finished")) return null;
|
||||
return ReportData.From(data);
|
||||
}
|
||||
|
||||
private async Task<ReportData> PollReportAsync(string sessionId, CancellationToken cancellationToken)
|
||||
{
|
||||
var uri = $"https://{GetConfiguration().Host}/WebUntis/api/polling/REPORT";
|
||||
for (var attempt = 0; attempt < 60; attempt++)
|
||||
{
|
||||
using var request = ReportRequest(uri, sessionId, acceptJson: true);
|
||||
using var response = await SendAsync(request, TimeSpan.FromSeconds(20), cancellationToken);
|
||||
var payload = await ReadJsonAsync(response, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new WebUntisException($"Report-Polling fehlgeschlagen: HTTP {(int)response.StatusCode}.");
|
||||
|
||||
if (TryProperty(payload, "data", out var data) && TryProperty(data, "pollingJobs", out var jobs) &&
|
||||
jobs.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var job in jobs.EnumerateArray().Where(job => OptionalBoolean(job, "isJobFinished")))
|
||||
{
|
||||
if (OptionalBoolean(job, "hasJobError"))
|
||||
throw new WebUntisException("Report-Polling enthält einen Job-Fehler.");
|
||||
if (TryProperty(job, "data", out var jobData)) return ReportData.From(jobData);
|
||||
}
|
||||
}
|
||||
await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken);
|
||||
}
|
||||
throw new WebUntisException("Report wurde nicht rechtzeitig fertiggestellt.");
|
||||
}
|
||||
|
||||
private async Task<string> FetchReportTextAsync(string sessionId, ReportData reportData,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var uri = reportData.ReportParams is not null
|
||||
? $"https://{GetConfiguration().Host}/WebUntis/reports.do?{reportData.ReportParams}"
|
||||
: $"https://{GetConfiguration().Host}/WebUntis/reports.do?msgId={Uri.EscapeDataString(reportData.MessageId!)}";
|
||||
using var request = ReportRequest(uri, sessionId, acceptJson: false);
|
||||
using var response = await SendAsync(request, TimeSpan.FromSeconds(60), cancellationToken);
|
||||
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||
if (response.Content.Headers.ContentType?.MediaType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
var payload = ParseJson(bytes);
|
||||
throw new WebUntisException($"Report-Abruf fehlgeschlagen: {ErrorMessage(payload, response)}");
|
||||
}
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new WebUntisException($"Report-Abruf fehlgeschlagen: HTTP {(int)response.StatusCode}.");
|
||||
try { return StrictUtf8.GetString(bytes); }
|
||||
catch (DecoderFallbackException) { return Encoding.Latin1.GetString(bytes); }
|
||||
}
|
||||
|
||||
private HttpRequestMessage ReportRequest(string uri, string sessionId, bool acceptJson)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
if (acceptJson) request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
request.Headers.Add("Cookie", $"JSESSIONID={sessionId}; schoolname=\"_{SchoolCookie()}\"");
|
||||
return request;
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutSource.CancelAfter(timeout);
|
||||
try { return await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeoutSource.Token); }
|
||||
catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw new WebUntisException($"Timeout nach {timeout.TotalSeconds:0} Sekunden.", exception);
|
||||
}
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
throw new WebUntisException("WebUntis ist momentan nicht erreichbar.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private Config GetConfiguration()
|
||||
{
|
||||
var schoolValue = _options.School.Trim();
|
||||
var username = _options.Username.Trim();
|
||||
var password = _options.Password.Trim();
|
||||
if (schoolValue.Length == 0) throw new WebUntisConfigurationException("WEBUNTIS_SCHOOL fehlt.");
|
||||
if (username.Length == 0) throw new WebUntisConfigurationException("WEBUNTIS_USER fehlt.");
|
||||
if (password.Length == 0) throw new WebUntisConfigurationException("WEBUNTIS_PASSWORD fehlt.");
|
||||
|
||||
var cleaned = schoolValue.Replace("https://", "", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("http://", "", StringComparison.OrdinalIgnoreCase).Split('/')[0];
|
||||
var school = cleaned.Contains('.') ? cleaned.Split('.')[0] : cleaned;
|
||||
var host = string.IsNullOrWhiteSpace(_options.Host)
|
||||
? (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.");
|
||||
return new Config(school, host, username, password,
|
||||
string.IsNullOrWhiteSpace(_options.Client) ? "LehrerApp" : _options.Client.Trim());
|
||||
}
|
||||
|
||||
private string SchoolCookie() => Convert.ToBase64String(Encoding.UTF8.GetBytes(GetConfiguration().School));
|
||||
|
||||
private static async Task<JsonElement> ReadJsonAsync(HttpResponseMessage response, CancellationToken token) =>
|
||||
ParseJson(await response.Content.ReadAsByteArrayAsync(token));
|
||||
|
||||
private static JsonElement ParseJson(byte[] bytes)
|
||||
{
|
||||
try { return JsonSerializer.Deserialize<JsonElement>(bytes, JsonOptions); }
|
||||
catch (JsonException) { return default; }
|
||||
}
|
||||
|
||||
private static IReadOnlyList<JsonElement> RequireArray(JsonElement value, string error) =>
|
||||
value.ValueKind == JsonValueKind.Array ? value.EnumerateArray().Select(item => item.Clone()).ToList()
|
||||
: throw new WebUntisException(error);
|
||||
|
||||
private static IReadOnlyList<UntisEntity> Entities(JsonElement parent, string property) =>
|
||||
TryProperty(parent, property, out var entries) && entries.ValueKind == JsonValueKind.Array
|
||||
? entries.EnumerateArray().Select(entry => new UntisEntity(OptionalInt(entry, "id") ?? 0,
|
||||
OptionalString(entry, "name") ?? "", OptionalInt(entry, "orgid"),
|
||||
OptionalString(entry, "orgname"), OptionalString(entry, "externalkey"))).ToList()
|
||||
: [];
|
||||
|
||||
private static IReadOnlyList<int> IntArray(JsonElement parent, string property) =>
|
||||
TryProperty(parent, property, out var entries) && entries.ValueKind == JsonValueKind.Array
|
||||
? entries.EnumerateArray().Select(OptionalInt).Where(value => value is not null)
|
||||
.Select(value => value!.Value).ToList()
|
||||
: [];
|
||||
|
||||
private static UntisReschedule? ParseReschedule(JsonElement parent)
|
||||
{
|
||||
if (!TryProperty(parent, "reschedule", out var value) || value.ValueKind != JsonValueKind.Object) return null;
|
||||
var date = OptionalInt(value, "date");
|
||||
var start = OptionalInt(value, "startTime");
|
||||
var end = OptionalInt(value, "endTime");
|
||||
return date is not null && start is not null && end is not null
|
||||
? new UntisReschedule(date.Value, start.Value, end.Value)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static bool TryProperty(JsonElement value, string name, out JsonElement property)
|
||||
{
|
||||
property = default;
|
||||
return value.ValueKind == JsonValueKind.Object && value.TryGetProperty(name, out property);
|
||||
}
|
||||
|
||||
private static int RequiredInt(JsonElement value, string property) => OptionalInt(value, property)
|
||||
?? throw new WebUntisException($"WebUntis-Feld \"{property}\" ist ungültig.");
|
||||
|
||||
private static int? OptionalInt(JsonElement parent, string property) =>
|
||||
TryProperty(parent, property, out var value) ? OptionalInt(value) : null;
|
||||
|
||||
private static int? OptionalInt(JsonElement value)
|
||||
{
|
||||
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number)) return number;
|
||||
return value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), out number) ? number : null;
|
||||
}
|
||||
|
||||
private static string? OptionalString(JsonElement parent, string property)
|
||||
{
|
||||
if (!TryProperty(parent, property, out var value)) return null;
|
||||
var text = value.ValueKind == JsonValueKind.String ? value.GetString() :
|
||||
value.ValueKind is JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False ? value.ToString() : null;
|
||||
return string.IsNullOrWhiteSpace(text) ? null : text.Trim();
|
||||
}
|
||||
|
||||
private static bool OptionalBoolean(JsonElement parent, string property)
|
||||
{
|
||||
if (!TryProperty(parent, property, out var value)) return false;
|
||||
if (value.ValueKind is JsonValueKind.True or JsonValueKind.False) return value.GetBoolean();
|
||||
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number)) return number != 0;
|
||||
return value.ValueKind == JsonValueKind.String &&
|
||||
(string.Equals(value.GetString(), "true", StringComparison.OrdinalIgnoreCase) || value.GetString() == "1");
|
||||
}
|
||||
|
||||
private static string ErrorMessage(JsonElement payload, HttpResponseMessage response)
|
||||
{
|
||||
if (payload.ValueKind == JsonValueKind.String) return payload.GetString() ?? "Unerwartete Antwort.";
|
||||
if (payload.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (TryProperty(payload, "error", out var error) && OptionalString(error, "message") is { } errorMessage)
|
||||
return errorMessage;
|
||||
if (OptionalString(payload, "message") is { } message) return message;
|
||||
if (TryProperty(payload, "data", out var data) && OptionalString(data, "message") is { } dataMessage)
|
||||
return dataMessage;
|
||||
}
|
||||
return response.IsSuccessStatusCode ? "Unerwartete JSON-Antwort." : $"HTTP {(int)response.StatusCode}";
|
||||
}
|
||||
|
||||
private sealed record Config(string School, string Host, string Username, string Password, string Client);
|
||||
|
||||
private sealed class SessionLease(WebUntisClient owner, string sessionId) : IAsyncDisposable
|
||||
{
|
||||
private int _released;
|
||||
public string SessionId { get; } = sessionId;
|
||||
|
||||
public ValueTask DisposeAsync() => Interlocked.Exchange(ref _released, 1) == 0
|
||||
? owner.ReleaseSessionAsync()
|
||||
: ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed record ReportData(string? ReportParams, string? MessageId)
|
||||
{
|
||||
public static ReportData From(JsonElement data)
|
||||
{
|
||||
var result = new ReportData(OptionalString(data, "reportParams"), OptionalString(data, "messageId"));
|
||||
return result.ReportParams is null && result.MessageId is null
|
||||
? throw new WebUntisException("Report-Antwort enthält weder reportParams noch messageId.")
|
||||
: result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LehrerApp.Api;
|
||||
|
||||
public sealed class WebUntisOptions
|
||||
{
|
||||
public string School { get; set; } = "";
|
||||
public string Host { get; set; } = "";
|
||||
public string Username { get; set; } = "";
|
||||
public string Password { get; set; } = "";
|
||||
public string Client { get; set; } = "LehrerApp";
|
||||
public int SessionIdleTimeoutMinutes { get; set; } = 10;
|
||||
}
|
||||
|
||||
public sealed record UntisSchoolYear(int UntisId, string Name, int StartDate, int EndDate);
|
||||
|
||||
public sealed record UntisClass(
|
||||
int UntisId,
|
||||
string Name,
|
||||
string? LongName,
|
||||
string? ForeColor,
|
||||
string? BackColor,
|
||||
int? DepartmentUntisId,
|
||||
int? Teacher1UntisId,
|
||||
int? Teacher2UntisId);
|
||||
|
||||
public sealed record UntisTeacher(
|
||||
int UntisId,
|
||||
string Name,
|
||||
string? ForeName,
|
||||
string? LongName,
|
||||
string? Title,
|
||||
bool Active,
|
||||
IReadOnlyList<int> DepartmentUntisIds);
|
||||
|
||||
public sealed record UntisStudentAddress(
|
||||
string? Email,
|
||||
string? Mobile,
|
||||
string? Phone,
|
||||
string? City,
|
||||
string? PostCode,
|
||||
string? Street);
|
||||
|
||||
public sealed record UntisStudent(
|
||||
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,
|
||||
UntisStudentAddress Address,
|
||||
string? AttributeIL);
|
||||
|
||||
public sealed record UntisStudentReport(
|
||||
int Count,
|
||||
string? ClassNameFilter,
|
||||
IReadOnlyList<UntisStudent> Students);
|
||||
|
||||
public sealed record UntisTimeUnit(string Name, int StartTime, int EndTime);
|
||||
|
||||
public sealed record UntisTimeGridDay(
|
||||
int Day,
|
||||
IReadOnlyList<UntisTimeUnit> TimeUnits,
|
||||
JsonElement Raw);
|
||||
|
||||
public sealed record UntisHoliday(
|
||||
int UntisId,
|
||||
string Name,
|
||||
string? LongName,
|
||||
int StartDate,
|
||||
int EndDate);
|
||||
|
||||
public sealed record UntisEntity(
|
||||
int Id,
|
||||
string Name,
|
||||
int? OriginalId,
|
||||
string? OriginalName,
|
||||
string? ExternalKey);
|
||||
|
||||
public sealed record UntisReschedule(int Date, int StartTime, int EndTime);
|
||||
|
||||
public sealed record UntisSubstitution(
|
||||
string Type,
|
||||
int? LessonId,
|
||||
string? LessonType,
|
||||
int Date,
|
||||
int StartTime,
|
||||
int EndTime,
|
||||
string? Text,
|
||||
IReadOnlyList<UntisEntity> Classes,
|
||||
IReadOnlyList<UntisEntity> Teachers,
|
||||
IReadOnlyList<UntisEntity> Subjects,
|
||||
IReadOnlyList<UntisEntity> Rooms,
|
||||
UntisReschedule? Reschedule,
|
||||
JsonElement Raw);
|
||||
|
||||
public enum UntisTimetableElementType
|
||||
{
|
||||
Class = 1,
|
||||
Teacher = 2,
|
||||
Subject = 3,
|
||||
Room = 4,
|
||||
Student = 5,
|
||||
}
|
||||
|
||||
public sealed record UntisTimetablePeriod(
|
||||
int Id,
|
||||
int Date,
|
||||
int StartTime,
|
||||
int EndTime,
|
||||
string? Code,
|
||||
string? ActivityType,
|
||||
string? Info,
|
||||
string? LessonText,
|
||||
string? SubstitutionText,
|
||||
string? StudentGroup,
|
||||
IReadOnlyList<UntisEntity> Classes,
|
||||
IReadOnlyList<UntisEntity> Teachers,
|
||||
IReadOnlyList<UntisEntity> Subjects,
|
||||
IReadOnlyList<UntisEntity> Rooms,
|
||||
JsonElement Raw);
|
||||
|
||||
public sealed record UntisStudentAbsence(
|
||||
int StudentKey,
|
||||
int Date,
|
||||
int StartTime,
|
||||
int EndTime,
|
||||
int AbsentMinutes,
|
||||
bool Checked,
|
||||
string? AbsenceReason,
|
||||
string? ExcuseStatus,
|
||||
int? SubjectId,
|
||||
IReadOnlyList<int> TeacherIds,
|
||||
string? StudentGroup,
|
||||
JsonElement Raw);
|
||||
|
||||
public sealed record UntisStudentAbsenceReport(
|
||||
int StudentKey,
|
||||
int StartDate,
|
||||
int EndDate,
|
||||
int EntryCount,
|
||||
int AbsentMinutes,
|
||||
IReadOnlyList<UntisStudentAbsence> Absences);
|
||||
|
||||
public sealed record UntisClassRegisterEntry(
|
||||
int? StudentKey,
|
||||
string? Surname,
|
||||
string? ForeName,
|
||||
string DisplayName,
|
||||
int Date,
|
||||
string? Subject,
|
||||
int? CategoryId,
|
||||
string? Reason,
|
||||
string? Text,
|
||||
JsonElement Raw);
|
||||
|
||||
public sealed record UntisClassRegisterCategory(
|
||||
int Id,
|
||||
string Name,
|
||||
string? LongName,
|
||||
int? GroupId,
|
||||
JsonElement Raw);
|
||||
|
||||
public sealed record UntisClassRegisterCategoryGroup(
|
||||
int Id,
|
||||
string Name,
|
||||
JsonElement Raw);
|
||||
|
||||
public sealed class WebUntisException(string message, Exception? innerException = null)
|
||||
: Exception(message, innerException);
|
||||
|
||||
public sealed class WebUntisConfigurationException(string message) : Exception(message);
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace LehrerApp.Api;
|
||||
|
||||
public static class WebUntisStudentReportParser
|
||||
{
|
||||
public static IReadOnlyList<UntisStudent> Parse(string content)
|
||||
{
|
||||
var rows = ParseSeparatedRows(content, '\t');
|
||||
if (rows.Count == 0) return [];
|
||||
|
||||
var headers = rows[0]
|
||||
.Select((header, index) => (index == 0 ? header.TrimStart('\uFEFF') : header).Trim())
|
||||
.ToArray();
|
||||
var result = new List<UntisStudent>();
|
||||
|
||||
foreach (var row in rows.Skip(1))
|
||||
{
|
||||
if (row.All(string.IsNullOrWhiteSpace)) continue;
|
||||
|
||||
var values = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
for (var index = 0; index < headers.Length; index++)
|
||||
values[headers[index]] = index < row.Count ? row[index].Trim() : null;
|
||||
|
||||
var untisId = RequiredInt(Get(values, "id"), "id");
|
||||
var externalKey = RequiredInt(Get(values, "externKey"), "externKey");
|
||||
var name = Optional(Get(values, "name"));
|
||||
var lastName = Optional(Get(values, "longName"));
|
||||
var firstName = Optional(Get(values, "foreName"));
|
||||
var displayName = string.Join(' ', new[] { firstName, lastName }.Where(value => value is not null));
|
||||
if (string.IsNullOrWhiteSpace(displayName)) displayName = name ?? $"Schüler {untisId}";
|
||||
|
||||
result.Add(new UntisStudent(
|
||||
untisId,
|
||||
externalKey,
|
||||
Get(values, "klasse.name")?.Trim() ?? "",
|
||||
name,
|
||||
lastName,
|
||||
firstName,
|
||||
displayName,
|
||||
Optional(Get(values, "gender")),
|
||||
GermanDate(Get(values, "birthDate")),
|
||||
Optional(Get(values, "birthDate")),
|
||||
GermanDate(Get(values, "entryDate")),
|
||||
Optional(Get(values, "entryDate")),
|
||||
GermanDate(Get(values, "exitDate")),
|
||||
Optional(Get(values, "exitDate")),
|
||||
Optional(Get(values, "text")),
|
||||
Optional(Get(values, "medicalReportDuty")),
|
||||
Optional(Get(values, "schulpflicht")),
|
||||
Optional(Get(values, "majority")),
|
||||
new UntisStudentAddress(
|
||||
Optional(Get(values, "adress.email")),
|
||||
Optional(Get(values, "adress.mobile")),
|
||||
Optional(Get(values, "adress.phone")),
|
||||
Optional(Get(values, "adress.city")),
|
||||
Optional(Get(values, "adress.postCode")),
|
||||
Optional(Get(values, "adress.street"))),
|
||||
Optional(Get(values, "attribute.iL"))));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<List<string>> ParseSeparatedRows(string content, char separator)
|
||||
{
|
||||
var rows = new List<List<string>>();
|
||||
var row = new List<string>();
|
||||
var field = new StringBuilder();
|
||||
var inQuotes = false;
|
||||
|
||||
for (var index = 0; index < content.Length; index++)
|
||||
{
|
||||
var character = content[index];
|
||||
if (character == '"')
|
||||
{
|
||||
if (inQuotes && index + 1 < content.Length && content[index + 1] == '"')
|
||||
{
|
||||
field.Append('"');
|
||||
index++;
|
||||
}
|
||||
else
|
||||
{
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inQuotes && character == separator)
|
||||
{
|
||||
row.Add(field.ToString());
|
||||
field.Clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inQuotes && character == '\n')
|
||||
{
|
||||
row.Add(field.ToString());
|
||||
rows.Add(row);
|
||||
row = [];
|
||||
field.Clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inQuotes && character == '\r') continue;
|
||||
field.Append(character);
|
||||
}
|
||||
|
||||
row.Add(field.ToString());
|
||||
if (row.Count > 1 || !string.IsNullOrWhiteSpace(row[0])) rows.Add(row);
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static string? Get(IReadOnlyDictionary<string, string?> values, string key) =>
|
||||
values.TryGetValue(key, out var value) ? value : null;
|
||||
|
||||
private static string? Optional(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static int RequiredInt(string? value, string field) =>
|
||||
int.TryParse(value?.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
|
||||
? parsed
|
||||
: throw new InvalidDataException($"Ungültige Zahl in Spalte \"{field}\".");
|
||||
|
||||
private static int? GermanDate(string? value)
|
||||
{
|
||||
if (!DateOnly.TryParseExact(value?.Trim(), "dd.MM.yyyy", CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None, out var date))
|
||||
return null;
|
||||
return date.Year * 10_000 + date.Month * 100 + date.Day;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user