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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 =>
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user