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;
}
}