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
@@ -32,6 +32,12 @@ public sealed record UntisLessonAbsenceDto(string StudentName, int Date, int Abs
string? Reason, int? ExternKey, bool ExternKeyInParentheses, string? HandledOn, bool Counts);
public sealed record UntisClassRegisterEventDto(string ClassName, int Date, string? Subject,
string StudentName, string? CategoryName, string? CategoryGroup, string? Text);
public sealed record UntisForeignClassRegisterEventDto(string ClassName, int Date, string? Subject,
string StudentName, string? TeacherUsername, string? CategoryName, string? CategoryGroup, string? Text);
public sealed record UntisClassAbsenceEntryDto(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);
/// <summary>Direkter WebUntis-Client des Desktops. Personenbezogene Antworten und der
/// unverschlüsselte CSV-Report passieren zu keinem Zeitpunkt den LehrerApp-Server.</summary>
@@ -135,6 +141,65 @@ public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettings
.ToList();
}, token);
// Spiegelbild von GetOwnClassRegisterEventsAsync für das "Klassenlehrer"-Feature (siehe TODO.md):
// dieselben "-alle-"-Rohdaten, aber Einträge anderer Lehrkräfte statt der eigenen, zusätzlich auf
// die übergebene Klasse eingeschränkt (der "-alle-"-Bericht liefert sonst auch andere Klassen mit,
// sobald eine Lehrkraft mehrere Klassen unterrichtet).
public Task<IReadOnlyList<UntisForeignClassRegisterEventDto>> GetForeignClassRegisterEventsAsync(
string className, DateOnly start, DateOnly end, CancellationToken token = default) => ExecuteAsync(async client =>
{
var ownUsername = settings.GetApiCredentials()?.Username;
var entries = await client.GetClassRegisterEventsReportAsync(Date(start), Date(end), token);
return (IReadOnlyList<UntisForeignClassRegisterEventDto>)entries
.Where(x => string.Equals(x.ClassName, className, StringComparison.OrdinalIgnoreCase)
// Nicht einfach "!=" gegen ownUsername: wäre ownUsername unbekannt (null), würde
// ein ebenfalls fehlender TeacherUsername sonst fälschlich als "eigen" erkannt
// (Equals(null,null)==true) und rausgefiltert statt als fremd angezeigt zu werden.
&& !(ownUsername is not null
&& string.Equals(x.TeacherUsername, ownUsername, StringComparison.OrdinalIgnoreCase)))
.Select(x => new UntisForeignClassRegisterEventDto(x.ClassName, x.Date, x.Subject, x.StudentName,
x.TeacherUsername, x.CategoryName, x.CategoryGroup, x.Text))
.ToList();
}, token);
// Fehlzeiten der ganzen Klasse über alle Fächer, Ergänzung zu GetForeignClassRegisterEventsAsync
// für das "Klassenlehrer"-Feature. Der Bericht adressiert die Klasse über "KL"+getKlassen-ID statt
// eines Namens, siehe ResolveClassIdAsync.
public Task<IReadOnlyList<UntisClassAbsenceEntryDto>> GetClassAbsencesAsync(string className,
DateOnly start, DateOnly end, CancellationToken token = default) => ExecuteAsync(async client =>
{
var classId = await ResolveClassIdAsync(client, className, token);
var entries = await client.GetClassAbsencesAsync(classId, Date(start), Date(end), token);
return (IReadOnlyList<UntisClassAbsenceEntryDto>)entries.Select(x => new UntisClassAbsenceEntryDto(
x.StudentName, x.ExternKey, x.ClassName, x.Date, x.AbsentPeriods, x.AbsentMinutes,
x.TeacherUsernames, x.Subject, x.AbsenceReason, x.Note, x.EntryId, x.HandledOn, x.Counts,
x.ExcuseNote, x.PeriodNumber, x.Status, x.CountsAsFullDay))
.ToList();
}, token);
// "KL" + getKlassen-ID, dasselbe Element-Kürzel-Schema wie kl/te/su/ro bei anderen Berichten
// (siehe Entities(...) unten). Unbestätigt (kein offizieller Report-Parameter dokumentiert), aber
// aus einem echten Browser-Request rückgeschlossen - schlägt die Zuordnung fehl, liefert der
// Bericht erkennbar leere/falsche Daten statt eines stillen Fehlers.
private static async Task<string> ResolveClassIdAsync(WebUntisClient client, string className,
CancellationToken token)
{
var years = await client.GetSchoolYearsAsync(token);
var today = Date(DateOnly.FromDateTime(DateTime.Today));
var year = years.FirstOrDefault(y => y.StartDate <= today && y.EndDate >= today)
?? years.OrderByDescending(y => y.StartDate).FirstOrDefault()
?? throw new WebUntisIntegrationException("WebUntis hat kein Schuljahr geliefert.");
var classes = await client.GetClassesAsync(year.UntisId, token);
var matches = classes.Where(c => string.Equals(c.Name, className, StringComparison.OrdinalIgnoreCase)).ToList();
return matches.Count switch
{
1 => $"KL{matches[0].UntisId}",
0 => throw new WebUntisIntegrationException($"WebUntis kennt keine Klasse namens \"{className}\"."),
_ => throw new WebUntisIntegrationException(
$"Mehrere WebUntis-Klassen heißen \"{className}\" - bitte manuell prüfen."),
};
}
private async Task<T> ExecuteAsync<T>(Func<WebUntisClient, Task<T>> operation, CancellationToken token)
{
try { return await operation(await GetClientAsync(token)); }