WIP (unstable): Klassenlehrer-Tab (fremde Klassenbucheinträge + Fehlzeiten der Klasse)

Neuer Tab in der Gruppendetailansicht (LearningGroup.IsClassTeacher-Flag), zeigt
Klassenbucheinträge anderer Lehrkräfte sowie Fehlzeiten der ganzen Klasse über alle
Fächer, pro Schüler*in und Tag zusammengefasst. Beides rein lesend, kein Übernehmen
in lokale Daten. Fehlzeiten-Bericht (AbsencePerStudent) adressiert die Klasse über
"KL"+getKlassen-ID, automatisch aufgelöst - noch nicht mit echtem WebUntis-Zugang
gegengeprüft (siehe TODO.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 21:19:39 +02:00
co-authored by Claude Sonnet 5
parent f9f5f75aa8
commit 7245196689
17 changed files with 649 additions and 7 deletions
@@ -0,0 +1,79 @@
using System.Globalization;
namespace LehrerApp.WebUntis;
public static class WebUntisClassAbsenceParser
{
public static IReadOnlyList<UntisClassAbsenceEntry> 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<UntisClassAbsenceEntry>();
foreach (var row in rows.Skip(1))
{
if (row.All(string.IsNullOrWhiteSpace)) continue;
// Der Header "Text" kommt zweimal vor (Spalte 3, in echten Daten immer leer; die
// eigentliche freitextliche Tagesnotiz steht in der späteren Spalte). Der spätere Index
// überschreibt beim Befüllen den früheren, was hier zufällig den gewünschten Wert übrig
// lässt - bewusst kein Verlass darauf, dass Spalte 3 auch künftig immer leer bleibt.
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;
result.Add(new UntisClassAbsenceEntry(
Get(values, "Schüler*innen")?.Trim() ?? "",
OptionalInt(Get(values, "Externe Id")),
Get(values, "Klasse")?.Trim() ?? "",
GermanShortDate(Get(values, "Datum")) ?? throw new InvalidDataException(
"Ungültiges Datum in Spalte \"Datum\"."),
RequiredInt(Get(values, "Fehlstd."), "Fehlstd."),
RequiredInt(Get(values, "Fehlmin."), "Fehlmin."),
Optional(Get(values, "Lehrkraft")),
Optional(Get(values, "Fach")),
Optional(Get(values, "Abwesenheitsgrund")),
Optional(Get(values, "Text")),
OptionalInt(Get(values, "ENr")),
Optional(Get(values, "Erledigt")),
ParseBoolean(Get(values, "Abwesenheit zählt")),
Optional(Get(values, "Entschuldigungstext")),
OptionalInt(Get(values, "Stundennr.")),
Optional(Get(values, "Status")),
Get(values, "Fehltage")?.Trim() == "1"));
}
return result;
}
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? OptionalInt(string? value) =>
int.TryParse(value?.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
? parsed
: null;
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;
}
}
+29
View File
@@ -175,6 +175,35 @@ public sealed class WebUntisClient : IAsyncDisposable
return WebUntisClassRegisterEventReportParser.Parse(reportText);
}, cancellationToken);
// Undokumentierter Bericht "AbsencePerStudent" (Unterricht -> Berichte -> Fehlzeiten der ganzen
// Klasse), Gegenstück zu GetLessonAbsencesAsync für das "Klassenlehrer"-Feature: deckt alle
// Fächer der Klasse ab, nicht nur den eigenen Unterricht. Adressiert die Klasse über
// klasseOrStudentgroupId = "KL" + getKlassen-ID (Auflösung siehe
// WebUntisIntegrationService.ResolveClassIdAsync) statt einer lsid. Query-Parameter 1:1 aus
// einem echten Browser-Request übernommen (identisch mit GetClassRegisterEventsReportAsync bis
// auf name/klasseOrStudentgroupId/studentId; _csrf/selectedDateRange dort schon als nicht nötig
// bekannt).
public Task<IReadOnlyList<UntisClassAbsenceEntry>> GetClassAbsencesAsync(string klasseOrStudentgroupId,
int startDate, int endDate, CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
{
var query = "name=AbsencePerStudent&format=csv" +
$"&klasseOrStudentgroupId={Uri.EscapeDataString(klasseOrStudentgroupId)}&studentId=-1" +
"&calendarChange=0&withAbsences=true&_withAbsences=on&withLateness=true&_withLateness=on" +
"&excludeNotCountingAbsences=true&_excludeNotCountingAbsences=on&_allStudents=on" +
"&absStudGroup=1&studExcuseStatusId=-1&_withoutPageBreaksAbsPerStudent=on&absClassGroup=1" +
"&classExcuseStatusId=-1&_absClassSorted=on&_separateAbsentDays=on&_filterAbsencesKlasse=on" +
"&absSubjectGroup=7&absSubjectId=-1&absSubjectGroupGroup=7&subjectGroupId=-1&_totalDays=on" +
"&absentPeriodsLimit=16&reportWeeks=4&dayLimit=3&excuseGroup=1&_groupPerWeek=on" +
"&_excuseStatusAll=on&studEventReasonId=-1&classEventReasonId=-1&_evntClassSorted=on" +
"&_withoutPageBreaks=on&withStudentAbsences=true&_withStudentAbsences=on" +
"&examinationTypeId=-1&teachingMethodId=-1&examTypeId=-1&_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 WebUntisClassAbsenceParser.Parse(reportText);
}, cancellationToken);
public Task<IReadOnlyList<UntisSubstitution>> GetSubstitutionsAsync(int startDate, int endDate,
int? departmentId, CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
{
+27
View File
@@ -147,6 +147,33 @@ public sealed record UntisLessonAbsence(
string? HandledOn,
bool Counts);
// Undokumentierter Bericht "AbsencePerStudent" (Fehlzeiten der ganzen Klasse über alle Fächer,
// nicht nur den eigenen Unterricht wie AbsencePerLesson) - eigenes Spaltenschema, eine Zeile pro
// Fehlstunde. Anders als bei AbsencePerLesson liefert "Status" hier einen Klartext-Wert direkt
// (im Sample nur "nicht entsch." beobachtet, andere Werte nicht bestätigt - Rohwert durchreichen).
// "Fehltage" ist der auf eine ganze Zahl abgerundete Fehlzeit-Tagesanteil (Nutzer-Angabe, nicht
// durch WebUntis-Dokumentation bestätigt): bleibt 0, bis auch die letzte Unterrichtsstunde des
// Tages als versäumt gilt - erst dann springt er auf 1. Pro Schüler*in/Tag trägt deshalb genau die
// Zeile der letzten Stunde des Tages den Wert 1, alle vorherigen Stunden dieses Tages bleiben 0.
public sealed record UntisClassAbsenceEntry(
string StudentName,
int? ExternKey,
string ClassName,
int Date,
int AbsentPeriods,
int AbsentMinutes,
string? TeacherUsernames,
string? Subject,
string? AbsenceReason,
string? Note,
int? EntryId,
string? HandledOn,
bool Counts,
string? ExcuseNote,
int? PeriodNumber,
string? Status,
bool CountsAsFullDay);
// "-alle-"-Bericht ohne externe Schülerkennung - anders als bei AbsencePerLesson bleibt für den
// Abgleich mit lokalen Schülern nur der Name (siehe Namens-Fallback im Desktop-Abgleich).
public sealed record UntisClassRegisterEventReportEntry(