Untis-API: Fehlzeiten abgleich
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user