Untis-API: Fehlzeiten abgleich

This commit is contained in:
2026-08-25 00:21:53 +02:00
parent 17475a781f
commit 87a7badb44
21 changed files with 593 additions and 298 deletions
@@ -0,0 +1,55 @@
using System.Text;
namespace LehrerApp.WebUntis;
internal static class TabSeparatedTextReader
{
public static List<List<string>> ParseRows(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;
}
}
+27 -33
View File
@@ -15,6 +15,7 @@ public sealed class WebUntisClient : IAsyncDisposable
private readonly SemaphoreSlim _sessionGate = new(1, 1);
private readonly Timer _sessionExpiryTimer;
private string? _sessionId;
private int? _myTeacherUntisId;
private DateTimeOffset _sessionExpiresAt;
private int _activeRequests;
private bool _disposed;
@@ -114,7 +115,8 @@ public sealed class WebUntisClient : IAsyncDisposable
public Task<UntisStudentReport> GetStudentReportAsync(string? classNameFilter,
CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
{
var reportData = await RequestReportAsync(sessionId, cancellationToken)
var reportData = await RequestReportAsync(sessionId,
"name=Student&format=csv&klasseId=-1&studentsForDate=true&context=klasseId", cancellationToken)
?? await PollReportAsync(sessionId, cancellationToken);
var reportText = await FetchReportTextAsync(sessionId, reportData, cancellationToken);
var allStudents = WebUntisStudentReportParser.Parse(reportText);
@@ -126,6 +128,26 @@ public sealed class WebUntisClient : IAsyncDisposable
return new UntisStudentReport(students.Count, normalizedFilter, students);
}, cancellationToken);
// Undokumentierter interner Bericht der WebUntis-WebApp (Unterricht -> Berichte zum Unterricht ->
// "Fehlzeiten pro Unterricht pro Schüler*in"). lessonId entspricht der lsid, die pro Schuljahr neu
// vergeben wird (WebUntis-interne Unterrichtsnummer) und deshalb pro Lerngruppe gepflegt werden muss;
// eine JSON-RPC-Methode dafür existiert nicht. teacherId ist dagegen immer die eigene Lehrkraft-Kennung
// aus der Session — der Bericht zeigt ohnehin nur eigenen Unterricht, auch bei Doppelbesetzung.
public Task<IReadOnlyList<UntisLessonAbsence>> GetLessonAbsencesAsync(int lessonId,
int startDate, int endDate, CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
{
var teacherId = _myTeacherUntisId
?? throw new WebUntisException("WebUntis hat keine Lehrer-Kennung (personId) für diese Sitzung geliefert.");
var query = "name=AbsencePerLesson&format=csv" +
$"&lsid={lessonId}&lessonId={lessonId}&teacherId={teacherId}&reportElementType=2" +
"&_withoutPageBreaks=on&_empty=on&_usingMarkNames=on" +
$"&rpt_sd={startDate}&rpt_ed={endDate}&rpt_syid=-1&rpt_drdtype=CUSTOM";
var reportData = await RequestReportAsync(sessionId, query, cancellationToken)
?? await PollReportAsync(sessionId, cancellationToken);
var reportText = await FetchReportTextAsync(sessionId, reportData, cancellationToken);
return WebUntisLessonAbsenceParser.Parse(reportText);
}, cancellationToken);
public Task<IReadOnlyList<UntisSubstitution>> GetSubstitutionsAsync(int startDate, int endDate,
int? departmentId, CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
{
@@ -178,35 +200,6 @@ public sealed class WebUntisClient : IAsyncDisposable
Entities(entry, "ro"), entry.Clone())).ToList();
}, cancellationToken);
public Task<IReadOnlyList<UntisStudentAbsence>> GetAbsencesAsync(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.");
return (IReadOnlyList<UntisStudentAbsence>)periods.EnumerateArray()
.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();
}, cancellationToken);
public Task<IReadOnlyList<UntisClassRegisterEntry>> GetClassRegisterEntriesAsync(int studentId,
int startDate, int endDate, CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
{
@@ -287,6 +280,7 @@ public sealed class WebUntisClient : IAsyncDisposable
$"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.");
_myTeacherUntisId = OptionalInt(result, "personId");
}
_activeRequests++;
@@ -398,10 +392,10 @@ public sealed class WebUntisClient : IAsyncDisposable
return result.Clone();
}
private async Task<ReportData?> RequestReportAsync(string sessionId, CancellationToken cancellationToken)
private async Task<ReportData?> RequestReportAsync(string sessionId, string query,
CancellationToken cancellationToken)
{
var uri = $"https://{GetConfiguration().Host}/WebUntis/reports.do" +
"?name=Student&format=csv&klasseId=-1&studentsForDate=true&context=klasseId";
var uri = $"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);
@@ -0,0 +1,94 @@
using System.Globalization;
namespace LehrerApp.WebUntis;
public static class WebUntisLessonAbsenceParser
{
public static IReadOnlyList<UntisLessonAbsence> Parse(string content)
{
var rows = TabSeparatedTextReader.ParseRows(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<UntisLessonAbsence>();
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 (start, end) = ParseTimeRange(Get(values, "Zeit"));
var (externKey, inParentheses) = ParseExternKey(Get(values, "ENr"));
result.Add(new UntisLessonAbsence(
Get(values, "Schüler*innen")?.Trim() ?? "",
GermanShortDate(Get(values, "Datum")) ?? throw new InvalidDataException(
"Ungültiges Datum in Spalte \"Datum\"."),
RequiredInt(Get(values, "Fehlstd."), "Fehlstd."),
RequiredInt(Get(values, "Unentsch. Fehlstd."), "Unentsch. Fehlstd."),
RequiredInt(Get(values, "Fehlmin."), "Fehlmin."),
RequiredInt(Get(values, "Unentsch. Fehlmin."), "Unentsch. Fehlmin."),
start,
end,
Optional(Get(values, "Abwesenheitsgrund")),
externKey,
inParentheses,
Optional(Get(values, "Erledigt")),
ParseBoolean(Get(values, "Abwesenheit zählt"))));
}
return result;
}
private static (int? Start, int? End) ParseTimeRange(string? value)
{
var parts = value?.Split('-', 2);
return parts is { Length: 2 } ? (ParseClock(parts[0]), ParseClock(parts[1])) : (null, null);
}
private static int? ParseClock(string value) =>
TimeOnly.TryParseExact(value.Trim(), "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None,
out var time)
? time.Hour * 100 + time.Minute
: null;
// WebUntis klammert die externe Schülerkennung in dieser Spalte teils ein, teils nicht - die
// Klammer selbst ist das Signal (siehe Statuszuordnung im Desktop-Client), nicht nur Formatierung,
// und wird deshalb hier separat zurückgegeben statt beim Parsen verworfen zu werden.
private static (int? Key, bool InParentheses) ParseExternKey(string? value)
{
var trimmed = value?.Trim();
if (string.IsNullOrEmpty(trimmed)) return (null, false);
var inParentheses = trimmed.StartsWith('(') && trimmed.EndsWith(')');
var digits = trimmed.Trim('(', ')');
return int.TryParse(digits, NumberStyles.Integer, CultureInfo.InvariantCulture, out var key)
? (key, inParentheses)
: (null, false);
}
private static bool ParseBoolean(string? value) =>
string.Equals(value?.Trim(), "true", StringComparison.OrdinalIgnoreCase);
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? GermanShortDate(string? value)
{
if (!DateOnly.TryParseExact(value?.Trim(), "dd.MM.yy", CultureInfo.InvariantCulture,
DateTimeStyles.None, out var date))
return null;
return date.Year * 10_000 + date.Month * 100 + date.Day;
}
}
+12 -12
View File
@@ -132,20 +132,20 @@ public sealed record UntisTimetablePeriod(
IReadOnlyList<UntisEntity> Rooms,
JsonElement Raw);
public sealed record UntisStudentAbsence(
int StudentKey,
public sealed record UntisLessonAbsence(
string StudentName,
int Date,
int StartTime,
int EndTime,
int AbsentPeriods,
int UnexcusedAbsentPeriods,
int AbsentMinutes,
bool Checked,
string? AbsenceReason,
string? ExcuseStatus,
int? SubjectId,
IReadOnlyList<int> TeacherIds,
string? StudentGroup,
JsonElement Raw);
int UnexcusedAbsentMinutes,
int? StartTime,
int? EndTime,
string? Reason,
int? ExternKey,
bool ExternKeyInParentheses,
string? HandledOn,
bool Counts);
public sealed record UntisClassRegisterEntry(
int? StudentKey,
@@ -1,5 +1,4 @@
using System.Globalization;
using System.Text;
namespace LehrerApp.WebUntis;
@@ -7,7 +6,7 @@ public static class WebUntisStudentReportParser
{
public static IReadOnlyList<UntisStudent> Parse(string content)
{
var rows = ParseSeparatedRows(content, '\t');
var rows = TabSeparatedTextReader.ParseRows(content, '\t');
if (rows.Count == 0) return [];
var headers = rows[0]
@@ -63,55 +62,6 @@ public static class WebUntisStudentReportParser
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;