diff --git a/LehrerApp.Core/Models/LearningGroup.cs b/LehrerApp.Core/Models/LearningGroup.cs
index 8329a5e..c2afd80 100644
--- a/LehrerApp.Core/Models/LearningGroup.cs
+++ b/LehrerApp.Core/Models/LearningGroup.cs
@@ -20,6 +20,12 @@ public class LearningGroup
/// deaktivierbar - blendet die Erinnerung "Ungeplante Stunden" im Dashboard für diese Gruppe aus.
///
public bool RequiresLessonPlanning { get; set; } = true;
+ ///
+ /// WebUntis-interne Unterrichtsnummer (lsid) dieser Lerngruppe, Grundlage für den Abruf des
+ /// "Fehlzeiten pro Unterricht"-Berichts. Wird von WebUntis pro Schuljahr neu vergeben und muss
+ /// deshalb händisch je Lerngruppe/Schuljahr gepflegt werden - keine JSON-RPC-Methode liefert sie.
+ ///
+ public int? WebUntisLessonId { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
diff --git a/LehrerApp.Core/Services/GroupRolloverService.cs b/LehrerApp.Core/Services/GroupRolloverService.cs
index fc035ff..b7be352 100644
--- a/LehrerApp.Core/Services/GroupRolloverService.cs
+++ b/LehrerApp.Core/Services/GroupRolloverService.cs
@@ -60,6 +60,8 @@ public sealed class GroupRolloverService(
IsActive = true,
IsOwnClass = source.IsOwnClass,
IsDifferentiated = source.IsDifferentiated,
+ // WebUntisLessonId bewusst nicht übernommen: WebUntis vergibt sie pro Schuljahr neu,
+ // eine übernommene alte lsid würde in der Folgegruppe stumm falsche Fehlzeiten liefern.
};
var sourceWasActive = source.IsActive;
diff --git a/LehrerApp.Desktop/Services/WebUntisIntegrationService.cs b/LehrerApp.Desktop/Services/WebUntisIntegrationService.cs
index 76d3218..ae2fc05 100644
--- a/LehrerApp.Desktop/Services/WebUntisIntegrationService.cs
+++ b/LehrerApp.Desktop/Services/WebUntisIntegrationService.cs
@@ -27,9 +27,9 @@ public sealed record UntisStudentDto(int UntisId, int ExternKey, string ClassNam
string? EntryDateRaw, int? ExitDate, string? ExitDateRaw, string? Text, string? MedicalReportDuty,
string? Schulpflicht, string? Majority, UntisStudentAddressDto Address, string? AttributeIL);
public sealed record UntisStudentReportDto(int Count, string? ClassNameFilter, IReadOnlyList Students);
-public sealed record UntisStudentAbsenceDto(int StudentKey, int Date, int StartTime, int EndTime, int AbsentMinutes,
- bool Checked, string? AbsenceReason, string? ExcuseStatus, int? SubjectId, IReadOnlyList TeacherIds,
- string? StudentGroup);
+public sealed record UntisLessonAbsenceDto(string StudentName, int Date, int AbsentPeriods,
+ int UnexcusedAbsentPeriods, int AbsentMinutes, int UnexcusedAbsentMinutes, int? StartTime, int? EndTime,
+ string? Reason, int? ExternKey, bool ExternKeyInParentheses, string? HandledOn, bool Counts);
/// Direkter WebUntis-Client des Desktops. Personenbezogene Antworten und der
/// unverschlüsselte CSV-Report passieren zu keinem Zeitpunkt den LehrerApp-Server.
@@ -107,13 +107,14 @@ public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettings
x.Address.PostCode, x.Address.Street), x.AttributeIL)).ToList());
}, token);
- public Task> GetAbsencesAsync(DateOnly start, DateOnly end,
- CancellationToken token = default) => ExecuteAsync(async client =>
+ public Task> GetLessonAbsencesAsync(int lessonId,
+ DateOnly start, DateOnly end, CancellationToken token = default) => ExecuteAsync(async client =>
{
- var absences = await client.GetAbsencesAsync(Date(start), Date(end), token);
- return (IReadOnlyList)absences.Select(x => new UntisStudentAbsenceDto(
- x.StudentKey, x.Date, x.StartTime, x.EndTime, x.AbsentMinutes, x.Checked, x.AbsenceReason,
- x.ExcuseStatus, x.SubjectId, x.TeacherIds, x.StudentGroup)).ToList();
+ var absences = await client.GetLessonAbsencesAsync(lessonId, Date(start), Date(end), token);
+ return (IReadOnlyList)absences.Select(x => new UntisLessonAbsenceDto(
+ x.StudentName, x.Date, x.AbsentPeriods, x.UnexcusedAbsentPeriods, x.AbsentMinutes,
+ x.UnexcusedAbsentMinutes, x.StartTime, x.EndTime, x.Reason, x.ExternKey, x.ExternKeyInParentheses,
+ x.HandledOn, x.Counts)).ToList();
}, token);
private async Task ExecuteAsync(Func> operation, CancellationToken token)
diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs
index da02dca..265dad6 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs
@@ -878,6 +878,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
[ObservableProperty] private bool _isOwnClass;
[ObservableProperty] private bool _isDifferentiated;
[ObservableProperty] private bool _requiresLessonPlanning = true;
+ [ObservableProperty] private int? _webUntisLessonId;
[ObservableProperty] private string _nameError = "";
[ObservableProperty] private string _gradeLevelError = "";
@@ -917,6 +918,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
IsOwnClass = group.IsOwnClass;
IsDifferentiated = group.IsDifferentiated;
RequiresLessonPlanning = group.RequiresLessonPlanning;
+ WebUntisLessonId = group.WebUntisLessonId;
OnPropertyChanged(nameof(DialogTitle));
OnPropertyChanged(nameof(SaveButtonText));
}
@@ -963,6 +965,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
Result.IsOwnClass = IsOwnClass;
Result.IsDifferentiated = IsDifferentiated;
Result.RequiresLessonPlanning = RequiresLessonPlanning;
+ Result.WebUntisLessonId = WebUntisLessonId;
_groups.Save(Result);
}
}
diff --git a/LehrerApp.Desktop/ViewModels/Groups/WebUntisAbsenceComparisonViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/WebUntisAbsenceComparisonViewModel.cs
deleted file mode 100644
index b96cb9f..0000000
--- a/LehrerApp.Desktop/ViewModels/Groups/WebUntisAbsenceComparisonViewModel.cs
+++ /dev/null
@@ -1,128 +0,0 @@
-using System.Collections.ObjectModel;
-using CommunityToolkit.Mvvm.ComponentModel;
-using CommunityToolkit.Mvvm.Input;
-using LehrerApp.Core.Importing;
-using LehrerApp.Core.Interfaces;
-using LehrerApp.Core.Models;
-using LehrerApp.Desktop.Services;
-
-namespace LehrerApp.Desktop.ViewModels.Groups;
-
-public partial class WebUntisAbsenceRow : ObservableObject
-{
- public required string StudentName { get; init; }
- public required DateOnly Date { get; init; }
- public required string TimeLabel { get; init; }
- public required string UntisStatus { get; init; }
- public required string LocalStatus { get; init; }
- public required AttendanceStatus TargetStatus { get; init; }
- public required Guid StudentId { get; init; }
- public required Guid? SessionId { get; init; }
- public string? Reason { get; init; }
- public string DateLabel => Date.ToString("dd.MM.yyyy");
- public bool CanApply => SessionId is not null;
- [ObservableProperty] private bool _selected;
-}
-
-public partial class WebUntisAbsenceComparisonViewModel : ObservableObject
-{
- private readonly LearningGroup _group;
- private readonly WebUntisIntegrationService _untis;
- private readonly IStudentRepository _students;
- private readonly IParticipationSessionRepository _sessions;
- private readonly IParticipationRepository _participation;
-
- public ObservableCollection Rows { get; } = [];
- [ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddMonths(-2);
- [ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now;
- [ObservableProperty] private string _status = "Zeitraum wählen und Fehlzeiten laden.";
- [ObservableProperty] private bool _busy;
-
- public WebUntisAbsenceComparisonViewModel(LearningGroup group, WebUntisIntegrationService untis,
- IStudentRepository students, IParticipationSessionRepository sessions,
- IParticipationRepository participation)
- {
- _group = group; _untis = untis; _students = students; _sessions = sessions;
- _participation = participation;
- }
-
- [RelayCommand]
- private async Task Load()
- {
- var start = DateOnly.FromDateTime(StartDate.LocalDateTime);
- var end = DateOnly.FromDateTime(EndDate.LocalDateTime);
- if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
- Busy = true; Rows.Clear();
- try
- {
- var courseStudents = _students.GetByGroup(_group.Id);
- var localSessions = _sessions.GetByGroup(_group.Id)
- .Where(x => x.Date >= start && x.Date <= end).GroupBy(x => x.Date)
- .ToDictionary(x => x.Key, x => x.First());
- var linked = courseStudents.Select(student => (Student: student, Key: StudentKey(student)))
- .Where(x => x.Key is not null).ToDictionary(x => x.Key!.Value, x => x.Student);
- var absences = await _untis.GetAbsencesAsync(start, end);
- var loaded = absences.Where(absence => linked.ContainsKey(absence.StudentKey))
- .Select(absence => (Student: linked[absence.StudentKey], Absence: absence))
- .OrderBy(x => x.Absence.Date).ThenBy(x => x.Student.FullName);
-
- foreach (var item in loaded)
- {
- if (!TryDate(item.Absence.Date, out var date)) continue;
- localSessions.TryGetValue(date, out var session);
- var entry = session is null ? null : _participation.GetBySessionAndStudent(session.Id, item.Student.Id);
- var target = MapStatus(item.Absence.ExcuseStatus);
- Rows.Add(new WebUntisAbsenceRow
- {
- StudentName = item.Student.FullName, StudentId = item.Student.Id, Date = date,
- TimeLabel = $"{Time(item.Absence.StartTime)}–{Time(item.Absence.EndTime)}",
- UntisStatus = DisplayUntisStatus(item.Absence),
- LocalStatus = entry?.Attendance?.ToString() ?? (session is null ? "keine lokale Stunde" : "nicht erfasst"),
- TargetStatus = target, SessionId = session?.Id, Reason = item.Absence.AbsenceReason,
- Selected = session is not null && entry?.Attendance != target,
- });
- }
- var withoutKey = courseStudents.Count - linked.Count;
- Status = $"{Rows.Count} Untis-Fehlzeiten gefunden; {Rows.Count(x => x.CanApply)} sind einer lokalen Kursstunde zuordenbar."
- + (withoutKey > 0 ? $" {withoutKey} Schüler haben noch keine WebUntis-Kennung." : "");
- }
- catch (WebUntisIntegrationException ex) { Status = ex.Message; }
- finally { Busy = false; }
- }
-
- [RelayCommand]
- private void Apply()
- {
- var selected = Rows.Where(x => x.Selected && x.SessionId is not null).ToList();
- foreach (var row in selected)
- {
- var entry = _participation.GetBySessionAndStudent(row.SessionId!.Value, row.StudentId)
- ?? new ParticipationEntry { SessionId = row.SessionId.Value, StudentId = row.StudentId };
- entry.Attendance = row.TargetStatus;
- entry.UpdatedAt = DateTime.UtcNow;
- _participation.Save(entry);
- }
- Status = $"{selected.Count} Anwesenheitsstatus übernommen.";
- foreach (var row in selected) row.Selected = false;
- }
-
- private static int? StudentKey(Student student)
- {
- student.ExternalIds ??= [];
- return student.ExternalIds.TryGetValue(StudentImportFormats.MasterDataCsv.Value, out var value)
- && int.TryParse(value, out var key) ? key : null;
- }
- private static AttendanceStatus MapStatus(string? value)
- {
- var text = value?.Trim().ToLowerInvariant() ?? "";
- if (text.Contains("unexcused") || text.Contains("unentschuldigt") || text.Contains("nicht entschuldigt"))
- return AttendanceStatus.Unexcused;
- if (text.Contains("excused") || text.Contains("entschuldigt")) return AttendanceStatus.Excused;
- return AttendanceStatus.ExcusePending;
- }
- private static string DisplayUntisStatus(UntisStudentAbsenceDto absence) =>
- string.Join(" · ", new[] { absence.ExcuseStatus, absence.AbsenceReason }.Where(x => !string.IsNullOrWhiteSpace(x)))
- is { Length: > 0 } text ? text : "offen";
- private static bool TryDate(int value, out DateOnly date) => DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
- private static string Time(int value) => $"{value / 100:00}:{value % 100:00}";
-}
diff --git a/LehrerApp.Desktop/ViewModels/Groups/WebUntisLessonAbsenceComparisonViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/WebUntisLessonAbsenceComparisonViewModel.cs
new file mode 100644
index 0000000..0d65aa4
--- /dev/null
+++ b/LehrerApp.Desktop/ViewModels/Groups/WebUntisLessonAbsenceComparisonViewModel.cs
@@ -0,0 +1,202 @@
+using System.Collections.ObjectModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using LehrerApp.Core.Importing;
+using LehrerApp.Core.Interfaces;
+using LehrerApp.Core.Models;
+using LehrerApp.Desktop.Services;
+
+namespace LehrerApp.Desktop.ViewModels.Groups;
+
+/// Eine Zeile bleibt auch ohne automatische Zuordnung sichtbar (statt stillschweigend
+/// weggefiltert zu werden) - kann manuell per Auswahlliste gesetzt
+/// werden, wenn weder `ENr` noch der Name eindeutig auf ein Kursmitglied passen.
+public partial class WebUntisLessonAbsenceRow : ObservableObject
+{
+ public required string UntisStudentName { get; init; }
+ public required DateOnly Date { get; init; }
+ public required string TimeLabel { get; init; }
+ public required string UntisStatus { get; init; }
+ public required AttendanceStatus TargetStatus { get; init; }
+ public required IReadOnlyList Candidates { get; init; }
+ internal Action? OnAssignmentChanged { get; init; }
+ public string? Reason { get; init; }
+ public string DateLabel => Date.ToString("dd.MM.yyyy");
+ public bool CanApply => SessionId is not null;
+
+ [ObservableProperty] private Student? _assignedStudent;
+ [ObservableProperty] private string _localStatus = "ohne Zuordnung";
+ [ObservableProperty] private Guid? _sessionId;
+ [ObservableProperty] private bool _selected;
+
+ partial void OnAssignedStudentChanged(Student? value) => OnAssignmentChanged?.Invoke(this);
+}
+
+/// Fehlzeitenabgleich über den "Fehlzeiten pro Unterricht"-Bericht ().
+/// Der früher genutzte, pro Schüler abgerufene Fehlzeiten-Report (getTimetableWithAbsences) brauchte
+/// weitergehende WebUntis-Rechte als dieser Lerngruppen-Bericht und wurde deshalb entfernt.
+public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
+{
+ private readonly LearningGroup _group;
+ private readonly WebUntisIntegrationService _untis;
+ private readonly IStudentRepository _students;
+ private readonly IParticipationSessionRepository _sessions;
+ private readonly IParticipationRepository _participation;
+
+ public ObservableCollection Rows { get; } = [];
+ [ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddMonths(-2);
+ [ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now;
+ [ObservableProperty] private string _status = "Zeitraum wählen und Fehlzeiten laden.";
+ [ObservableProperty] private bool _busy;
+
+ public WebUntisLessonAbsenceComparisonViewModel(LearningGroup group, WebUntisIntegrationService untis,
+ IStudentRepository students, IParticipationSessionRepository sessions,
+ IParticipationRepository participation)
+ {
+ _group = group; _untis = untis; _students = students; _sessions = sessions;
+ _participation = participation;
+ }
+
+ [RelayCommand]
+ private async Task Load()
+ {
+ var lessonId = _group.WebUntisLessonId;
+ if (lessonId is null)
+ {
+ Status = "Für diese Lerngruppe ist noch keine WebUntis-Unterrichtsnummer hinterlegt " +
+ "(Gruppe bearbeiten).";
+ return;
+ }
+
+ var start = DateOnly.FromDateTime(StartDate.LocalDateTime);
+ var end = DateOnly.FromDateTime(EndDate.LocalDateTime);
+ if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
+ Busy = true; Rows.Clear();
+ try
+ {
+ var courseStudents = _students.GetByGroup(_group.Id);
+ var localSessions = _sessions.GetByGroup(_group.Id)
+ .Where(x => x.Date >= start && x.Date <= end).GroupBy(x => x.Date)
+ .ToDictionary(x => x.Key, x => x.First());
+ // Erste Wahl: WebUntis-Kennung (ENr). Nicht jeder Schüler hat eine (z.B. manuell statt
+ // per WebUntis-Import angelegt) - Fallback über den Namen, aber nur wenn er innerhalb
+ // der Kursmitglieder eindeutig ist, sonst lieber unzugeordnet lassen als raten.
+ var byKey = courseStudents.Select(student => (Student: student, Key: StudentKey(student)))
+ .Where(x => x.Key is not null).ToDictionary(x => x.Key!.Value, x => x.Student);
+ var byName = courseStudents
+ .SelectMany(student => new[]
+ {
+ NameKey($"{student.LastName} {student.FirstName}"),
+ NameKey($"{student.FirstName} {student.LastName}"),
+ }.Select(key => (Key: key, Student: student)))
+ .GroupBy(x => x.Key)
+ .Where(group => group.Select(x => x.Student).Distinct().Count() == 1)
+ .ToDictionary(group => group.Key, group => group.First().Student);
+
+ var absences = await _untis.GetLessonAbsencesAsync(lessonId.Value, start, end);
+ var ordered = absences
+ .Select(absence => (Absence: absence, Date: TryDate(absence.Date, out var date) ? date : (DateOnly?)null))
+ .Where(x => x.Date is not null)
+ .OrderBy(x => x.Date).ThenBy(x => x.Absence.StudentName);
+
+ void ResolveLocalMatch(WebUntisLessonAbsenceRow row)
+ {
+ if (row.AssignedStudent is not { } student)
+ {
+ row.SessionId = null; row.LocalStatus = "ohne Zuordnung"; row.Selected = false;
+ return;
+ }
+ localSessions.TryGetValue(row.Date, out var session);
+ var entry = session is null ? null : _participation.GetBySessionAndStudent(session.Id, student.Id);
+ row.SessionId = session?.Id;
+ row.LocalStatus = session is null ? "keine lokale Stunde" : AttendanceDisplay.Label(entry?.Attendance);
+ row.Selected = session is not null && entry?.Attendance != row.TargetStatus;
+ }
+
+ foreach (var (absence, date) in ordered)
+ {
+ var match = absence.ExternKey is { } key && byKey.TryGetValue(key, out var byKeyStudent)
+ ? byKeyStudent
+ : byName.GetValueOrDefault(NameKey(absence.StudentName));
+ var row = new WebUntisLessonAbsenceRow
+ {
+ UntisStudentName = absence.StudentName, Date = date!.Value,
+ TimeLabel = TimeLabel(absence.StartTime, absence.EndTime),
+ UntisStatus = DisplayUntisStatus(absence),
+ TargetStatus = MapStatus(absence), Reason = absence.Reason,
+ Candidates = courseStudents, OnAssignmentChanged = ResolveLocalMatch,
+ };
+ Rows.Add(row);
+ row.AssignedStudent = match; // löst OnAssignedStudentChanged aus und setzt SessionId/LocalStatus/Selected
+ }
+
+ var unresolved = Rows.Count(x => x.AssignedStudent is null);
+ Status = $"{absences.Count} Fehlzeiten von WebUntis erhalten, {Rows.Count - unresolved} automatisch zugeordnet" +
+ (unresolved > 0 ? $", {unresolved} bitte manuell zuordnen" : "") +
+ $". {Rows.Count(x => x.CanApply)} sind einer lokalen Kursstunde zuordenbar.";
+ }
+ catch (WebUntisIntegrationException ex) { Status = ex.Message; }
+ finally { Busy = false; }
+ }
+
+ [RelayCommand]
+ private void Apply()
+ {
+ var selected = Rows.Where(x => x.Selected && x.SessionId is not null && x.AssignedStudent is not null).ToList();
+ foreach (var row in selected)
+ {
+ var studentId = row.AssignedStudent!.Id;
+ var entry = _participation.GetBySessionAndStudent(row.SessionId!.Value, studentId)
+ ?? new ParticipationEntry { SessionId = row.SessionId.Value, StudentId = studentId };
+ entry.Attendance = row.TargetStatus;
+ entry.UpdatedAt = DateTime.UtcNow;
+ _participation.Save(entry);
+ }
+ Status = $"{selected.Count} Anwesenheitsstatus übernommen.";
+ foreach (var row in selected) row.Selected = false;
+ }
+
+ private static int? StudentKey(Student student)
+ {
+ student.ExternalIds ??= [];
+ return student.ExternalIds.TryGetValue(StudentImportFormats.MasterDataCsv.Value, out var value)
+ && int.TryParse(value, out var key) ? key : null;
+ }
+
+ // Groß-/Kleinschreibung, Leerraum und - da die tatsächliche WebUntis-Reihenfolge nicht
+ // dokumentiert und schulabhängig unterschiedlich beobachtet wurde - beide Namensreihenfolgen
+ // werden beim Aufbau von `byName` registriert; hier wird nur normalisiert.
+ private static string NameKey(string value) => value.Trim().ToLowerInvariant();
+
+ private const int FullLessonMinutes = 45;
+
+ // Der Bericht liefert keinen Entschuldigungstext, nur Minutenwerte, ein Bearbeitet-Datum und die
+ // (laut Schule) über Klammerung der ENr codierte Entscheidung des Klassenlehrers - ENr in
+ // Klammern bedeutet unentschuldigt, ohne Klammern abgeschlossen/entschuldigt. Reihenfolge ist
+ // wichtig: "nach Hause entlassen" zählt immer als vorzeitige Entlassung, unabhängig von der
+ // Dauer; darunter zählt jede Fehlzeit unter einer vollen Stunde (45 Min.) immer als Verspätung
+ // oder sonstiger Teilverlust, nie als komplette Abwesenheit - der Text "Verspätung" allein ist
+ // laut Schule nicht zuverlässig genug, deshalb primär über die Minutenschwelle erkannt.
+ private static AttendanceStatus MapStatus(UntisLessonAbsenceDto absence)
+ {
+ if (IsEarlyRelease(absence)) return AttendanceStatus.LeftDuringClass;
+ if (absence.AbsentMinutes < FullLessonMinutes) return AttendanceStatus.Late;
+ if (string.IsNullOrWhiteSpace(absence.HandledOn)) return AttendanceStatus.ExcusePending;
+ if (absence.ExternKey is null) return AttendanceStatus.ExcusePending;
+ return absence.ExternKeyInParentheses ? AttendanceStatus.Unexcused : AttendanceStatus.Excused;
+ }
+
+ private static bool IsEarlyRelease(UntisLessonAbsenceDto absence) =>
+ absence.Reason?.Contains("entlassen", StringComparison.OrdinalIgnoreCase) == true;
+
+ // Nutzt dieselben deutschen Bezeichnungen wie die reguläre Mitarbeitserfassung
+ // (AttendanceDisplay.Label), statt eigene Statustexte zu erfinden.
+ private static string DisplayUntisStatus(UntisLessonAbsenceDto absence) =>
+ string.Join(" · ", new[] { AttendanceDisplay.Label(MapStatus(absence)), absence.Reason }
+ .Where(x => !string.IsNullOrWhiteSpace(x)));
+
+ private static bool TryDate(int value, out DateOnly date) => DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
+ private static string TimeLabel(int? start, int? end) =>
+ start is null || end is null ? "" : $"{Time(start.Value)}–{Time(end.Value)}";
+ private static string Time(int value) => $"{value / 100:00}:{value % 100:00}";
+}
diff --git a/LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml b/LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml
index 4c00d2a..fd63de9 100644
--- a/LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml
+++ b/LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml
@@ -77,6 +77,13 @@
+
+
+
+
+
diff --git a/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml b/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml
index 1c45520..085241a 100644
--- a/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml
+++ b/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml
@@ -27,7 +27,7 @@
IsEnabled="{Binding IsEditable}"/>
-
+
diff --git a/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml.cs b/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml.cs
index ecf8483..5d6406c 100644
--- a/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml.cs
+++ b/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml.cs
@@ -153,16 +153,16 @@ public partial class GroupDetailView : UserControl
}
}
- private async void OnCompareWebUntisAbsencesClick(object? sender, RoutedEventArgs e)
+ private async void OnCompareWebUntisLessonAbsencesClick(object? sender, RoutedEventArgs e)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null || DataContext is not GroupDetailViewModel vm || vm.Group is null) return;
- var dialogVm = new WebUntisAbsenceComparisonViewModel(vm.Group,
+ var dialogVm = new WebUntisLessonAbsenceComparisonViewModel(vm.Group,
App.Services.GetRequiredService(),
App.Services.GetRequiredService(),
App.Services.GetRequiredService(),
App.Services.GetRequiredService());
- await new WebUntisAbsenceComparisonDialog { DataContext = dialogVm }.ShowDialog(owner);
+ await new WebUntisLessonAbsenceComparisonDialog { DataContext = dialogVm }.ShowDialog(owner);
vm.ParticipationTab.RefreshCurrentGrid();
}
diff --git a/LehrerApp.Desktop/Views/Groups/WebUntisAbsenceComparisonDialog.axaml b/LehrerApp.Desktop/Views/Groups/WebUntisAbsenceComparisonDialog.axaml
deleted file mode 100644
index bbeb32a..0000000
--- a/LehrerApp.Desktop/Views/Groups/WebUntisAbsenceComparisonDialog.axaml
+++ /dev/null
@@ -1,42 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/LehrerApp.Desktop/Views/Groups/WebUntisLessonAbsenceComparisonDialog.axaml b/LehrerApp.Desktop/Views/Groups/WebUntisLessonAbsenceComparisonDialog.axaml
new file mode 100644
index 0000000..073a12e
--- /dev/null
+++ b/LehrerApp.Desktop/Views/Groups/WebUntisLessonAbsenceComparisonDialog.axaml
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LehrerApp.Desktop/Views/Groups/WebUntisAbsenceComparisonDialog.axaml.cs b/LehrerApp.Desktop/Views/Groups/WebUntisLessonAbsenceComparisonDialog.axaml.cs
similarity index 54%
rename from LehrerApp.Desktop/Views/Groups/WebUntisAbsenceComparisonDialog.axaml.cs
rename to LehrerApp.Desktop/Views/Groups/WebUntisLessonAbsenceComparisonDialog.axaml.cs
index d56ab26..70c0799 100644
--- a/LehrerApp.Desktop/Views/Groups/WebUntisAbsenceComparisonDialog.axaml.cs
+++ b/LehrerApp.Desktop/Views/Groups/WebUntisLessonAbsenceComparisonDialog.axaml.cs
@@ -3,8 +3,8 @@ using Avalonia.Interactivity;
namespace LehrerApp.Desktop.Views.Groups;
-public partial class WebUntisAbsenceComparisonDialog : Window
+public partial class WebUntisLessonAbsenceComparisonDialog : Window
{
- public WebUntisAbsenceComparisonDialog() => InitializeComponent();
+ public WebUntisLessonAbsenceComparisonDialog() => InitializeComponent();
private void OnClose(object? sender, RoutedEventArgs e) => Close();
}
diff --git a/LehrerApp.Tests/GroupRolloverServiceTests.cs b/LehrerApp.Tests/GroupRolloverServiceTests.cs
index 164262c..4011ba9 100644
--- a/LehrerApp.Tests/GroupRolloverServiceTests.cs
+++ b/LehrerApp.Tests/GroupRolloverServiceTests.cs
@@ -14,7 +14,7 @@ public sealed class GroupRolloverServiceTests
{
Name = "8a", SchoolYear = "2025/26", GradeLevel = 8,
Type = GroupType.Class, SubjectId = Guid.NewGuid(), GradingSystem = GradingSystem.Grades1To6,
- HoursPerWeek = 4, IsOwnClass = true, IsDifferentiated = true,
+ HoursPerWeek = 4, IsOwnClass = true, IsDifferentiated = true, WebUntisLessonId = 38262,
};
var oldMembership = new GroupMembership
{
@@ -38,6 +38,7 @@ public sealed class GroupRolloverServiceTests
Assert.Equal(source.HoursPerWeek, target.HoursPerWeek);
Assert.True(target.IsOwnClass);
Assert.True(target.IsDifferentiated);
+ Assert.Null(target.WebUntisLessonId);
Assert.False(source.IsActive);
var copied = Assert.Single(memberships.GetByGroup(target.Id));
diff --git a/LehrerApp.WebUntis.Tests/WebUntisClientTests.cs b/LehrerApp.WebUntis.Tests/WebUntisClientTests.cs
index 39d3321..ea9710b 100644
--- a/LehrerApp.WebUntis.Tests/WebUntisClientTests.cs
+++ b/LehrerApp.WebUntis.Tests/WebUntisClientTests.cs
@@ -143,16 +143,10 @@ public sealed class WebUntisClientTests
}
[Fact]
- public async Task FehlzeitenUndKlassenbuch_WerdenTypisiertAbgerufen()
+ public async Task Klassenbuch_WirdTypisiertAbgerufen()
{
var handler = new QueueHandler(
Json("{\"result\":{\"sessionId\":\"s\"}}"),
- Json("{\"result\":{\"periodsWithAbsences\":[" +
- "{\"studentId\":9001,\"date\":20260901,\"startTime\":800,\"endTime\":845," +
- "\"absentTime\":45,\"checked\":true,\"absenceReason\":\"Krank\"," +
- "\"excuseStatus\":\"entschuldigt\",\"subjectId\":12,\"teacherIds\":[\"7\"]}," +
- "{\"studentId\":9999,\"date\":20260901,\"startTime\":800,\"endTime\":845," +
- "\"absentTime\":45,\"checked\":false}]}}"),
Json("{\"result\":[{\"studentid\":9001,\"surname\":\"Müller\",\"forname\":\"Ada\"," +
"\"date\":20260902,\"subject\":\"MA\",\"categoryId\":3,\"reason\":\"Material\"," +
"\"text\":\"Buch vergessen\"}]}"),
@@ -162,25 +156,18 @@ public sealed class WebUntisClientTests
Json("{\"result\":{}}"));
var client = CreateClient(handler);
- var absences = await client.GetAbsencesAsync(20260801, 20270731, CancellationToken.None);
var entries = await client.GetClassRegisterEntriesAsync(17, 20260801, 20270731,
CancellationToken.None);
var categories = await client.GetClassRegisterCategoriesAsync(CancellationToken.None);
var groups = await client.GetClassRegisterCategoryGroupsAsync(CancellationToken.None);
- Assert.Equal(2, absences.Count);
- var absence = Assert.Single(absences, x => x.StudentKey == 9001);
- Assert.Equal(45, absence.AbsentMinutes);
- Assert.Equal("entschuldigt", absence.ExcuseStatus);
- Assert.Equal(7, Assert.Single(absence.TeacherIds));
var entry = Assert.Single(entries);
Assert.Equal("Ada Müller", entry.DisplayName);
Assert.Equal("Buch vergessen", entry.Text);
Assert.Equal(3, Assert.Single(categories).Id);
Assert.Equal("Organisation", Assert.Single(groups).Name);
- Assert.Contains("\"method\":\"getTimetableWithAbsences\"", handler.Requests[1].Body);
- Assert.Contains("\"method\":\"getClassregEvents\"", handler.Requests[2].Body);
- Assert.Contains("\"id\":17,\"type\":5", handler.Requests[2].Body);
+ Assert.Contains("\"method\":\"getClassregEvents\"", handler.Requests[1].Body);
+ Assert.Contains("\"id\":17,\"type\":5", handler.Requests[1].Body);
Assert.Single(handler.Requests,
request => request.Body.Contains("\"method\":\"authenticate\""));
@@ -188,6 +175,40 @@ public sealed class WebUntisClientTests
Assert.Contains("\"method\":\"logout\"", handler.Requests[^1].Body);
}
+ [Fact]
+ public async Task GetLessonAbsencesAsync_LoestTeacherIdAusDerEigenenSessionAufUndTypisiertDasErgebnis()
+ {
+ var csv = Encoding.UTF8.GetBytes(
+ "Schüler*innen\tDatum\tFehlstd.\tUnentsch. Fehlstd.\tFehlmin.\tUnentsch. Fehlmin.\tZeit\t" +
+ "Abwesenheitsgrund\tENr\tErledigt\tAbwesenheit zählt\r\n" +
+ "Erika Mustermann\t09.12.25\t1\t1\t45\t45\t09:40-10:25\tKrank\t(185120)\t09.12.25\ttrue\r\n");
+ var handler = new QueueHandler(
+ Json("{\"result\":{\"sessionId\":\"s\",\"personId\":89}}"),
+ Json("{\"data\":{\"finished\":true,\"error\":false," +
+ "\"reportParams\":\"get=rpt1.tmp&name=AbsencePerLesson&format=csv\"}}"),
+ new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(csv) },
+ Json("{\"result\":{}}"));
+ var client = CreateClient(handler);
+
+ var absences = await client.GetLessonAbsencesAsync(38262, 20250811, 20260710, CancellationToken.None);
+
+ var absence = Assert.Single(absences);
+ Assert.Equal("Erika Mustermann", absence.StudentName);
+ Assert.Equal(20251209, absence.Date);
+ Assert.Equal(185120, absence.ExternKey);
+ Assert.True(absence.ExternKeyInParentheses);
+ Assert.Equal(940, absence.StartTime);
+ Assert.True(absence.Counts);
+ Assert.Contains("reports.do?name=AbsencePerLesson", handler.Requests[1].Uri);
+ Assert.Contains("lsid=38262", handler.Requests[1].Uri);
+ Assert.Contains("teacherId=89", handler.Requests[1].Uri);
+ Assert.Contains("rpt_sd=20250811", handler.Requests[1].Uri);
+ Assert.Contains("rpt_ed=20260710", handler.Requests[1].Uri);
+ Assert.EndsWith("reports.do?get=rpt1.tmp&name=AbsencePerLesson&format=csv", handler.Requests[2].Uri);
+
+ await client.DisposeAsync();
+ }
+
private static WebUntisClient CreateClient(HttpMessageHandler handler) => new(
new HttpClient(handler),
new WebUntisOptions
diff --git a/LehrerApp.WebUntis.Tests/WebUntisLessonAbsenceParserTests.cs b/LehrerApp.WebUntis.Tests/WebUntisLessonAbsenceParserTests.cs
new file mode 100644
index 0000000..6e7bcaa
--- /dev/null
+++ b/LehrerApp.WebUntis.Tests/WebUntisLessonAbsenceParserTests.cs
@@ -0,0 +1,54 @@
+using Xunit;
+using LehrerApp.WebUntis;
+
+namespace LehrerApp.WebUntis.Tests;
+
+public sealed class WebUntisLessonAbsenceParserTests
+{
+ [Fact]
+ public void Parse_UebernimmtAlleFelderUndZweistelligesJahr()
+ {
+ const string report = "\uFEFFSchüler*innen\tDatum\tFehlstd.\tUnentsch. Fehlstd.\tFehlmin.\tUnentsch. Fehlmin.\tZeit\tAbwesenheitsgrund\tENr\tErledigt\tAbwesenheit zählt\r\n" +
+ "Erika Mustermann\t09.12.25\t1\t1\t45\t45\t09:40-10:25\tKrank\t(185120)\t09.12.25\ttrue\r\n";
+
+ var absence = Assert.Single(WebUntisLessonAbsenceParser.Parse(report));
+
+ Assert.Equal("Erika Mustermann", absence.StudentName);
+ Assert.Equal(20251209, absence.Date);
+ Assert.Equal(1, absence.AbsentPeriods);
+ Assert.Equal(1, absence.UnexcusedAbsentPeriods);
+ Assert.Equal(45, absence.AbsentMinutes);
+ Assert.Equal(45, absence.UnexcusedAbsentMinutes);
+ Assert.Equal(940, absence.StartTime);
+ Assert.Equal(1025, absence.EndTime);
+ Assert.Equal("Krank", absence.Reason);
+ Assert.Equal(185120, absence.ExternKey);
+ Assert.True(absence.ExternKeyInParentheses);
+ Assert.Equal("09.12.25", absence.HandledOn);
+ Assert.True(absence.Counts);
+ }
+
+ [Fact]
+ public void Parse_AkzeptiertExterneSchuelerkennungOhneKlammern()
+ {
+ const string report = "Schüler*innen\tDatum\tFehlstd.\tUnentsch. Fehlstd.\tFehlmin.\tUnentsch. Fehlmin.\tZeit\tAbwesenheitsgrund\tENr\tErledigt\tAbwesenheit zählt\r\n" +
+ "Max Muster\t27.01.26\t1\t0\t45\t0\t10:25-11:10\t\t178156\t\tfalse\r\n";
+
+ var absence = Assert.Single(WebUntisLessonAbsenceParser.Parse(report));
+
+ Assert.Equal(178156, absence.ExternKey);
+ Assert.False(absence.ExternKeyInParentheses);
+ Assert.Null(absence.Reason);
+ Assert.Null(absence.HandledOn);
+ Assert.False(absence.Counts);
+ }
+
+ [Fact]
+ public void Parse_LehntUngueltigesDatumAb()
+ {
+ const string report = "Schüler*innen\tDatum\tFehlstd.\tUnentsch. Fehlstd.\tFehlmin.\tUnentsch. Fehlmin.\tZeit\tAbwesenheitsgrund\tENr\tErledigt\tAbwesenheit zählt\r\n" +
+ "Max Muster\tkein-datum\t1\t0\t45\t0\t10:25-11:10\t\t178156\t\tfalse\r\n";
+
+ Assert.Throws(() => WebUntisLessonAbsenceParser.Parse(report));
+ }
+}
diff --git a/LehrerApp.WebUntis/TabSeparatedTextReader.cs b/LehrerApp.WebUntis/TabSeparatedTextReader.cs
new file mode 100644
index 0000000..f1d3b97
--- /dev/null
+++ b/LehrerApp.WebUntis/TabSeparatedTextReader.cs
@@ -0,0 +1,55 @@
+using System.Text;
+
+namespace LehrerApp.WebUntis;
+
+internal static class TabSeparatedTextReader
+{
+ public static List> ParseRows(string content, char separator)
+ {
+ var rows = new List>();
+ var row = new List();
+ 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;
+ }
+}
diff --git a/LehrerApp.WebUntis/WebUntisClient.cs b/LehrerApp.WebUntis/WebUntisClient.cs
index 74f7ded..c9533a5 100644
--- a/LehrerApp.WebUntis/WebUntisClient.cs
+++ b/LehrerApp.WebUntis/WebUntisClient.cs
@@ -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 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> 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> 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> 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)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> 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 RequestReportAsync(string sessionId, CancellationToken cancellationToken)
+ private async Task 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);
diff --git a/LehrerApp.WebUntis/WebUntisLessonAbsenceParser.cs b/LehrerApp.WebUntis/WebUntisLessonAbsenceParser.cs
new file mode 100644
index 0000000..638d510
--- /dev/null
+++ b/LehrerApp.WebUntis/WebUntisLessonAbsenceParser.cs
@@ -0,0 +1,94 @@
+using System.Globalization;
+
+namespace LehrerApp.WebUntis;
+
+public static class WebUntisLessonAbsenceParser
+{
+ public static IReadOnlyList 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();
+
+ foreach (var row in rows.Skip(1))
+ {
+ if (row.All(string.IsNullOrWhiteSpace)) continue;
+
+ var values = new Dictionary(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 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;
+ }
+}
diff --git a/LehrerApp.WebUntis/WebUntisModels.cs b/LehrerApp.WebUntis/WebUntisModels.cs
index 2c6d54e..881a064 100644
--- a/LehrerApp.WebUntis/WebUntisModels.cs
+++ b/LehrerApp.WebUntis/WebUntisModels.cs
@@ -132,20 +132,20 @@ public sealed record UntisTimetablePeriod(
IReadOnlyList 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 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,
diff --git a/LehrerApp.WebUntis/WebUntisStudentReportParser.cs b/LehrerApp.WebUntis/WebUntisStudentReportParser.cs
index 2896247..709df18 100644
--- a/LehrerApp.WebUntis/WebUntisStudentReportParser.cs
+++ b/LehrerApp.WebUntis/WebUntisStudentReportParser.cs
@@ -1,5 +1,4 @@
using System.Globalization;
-using System.Text;
namespace LehrerApp.WebUntis;
@@ -7,7 +6,7 @@ public static class WebUntisStudentReportParser
{
public static IReadOnlyList 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> ParseSeparatedRows(string content, char separator)
- {
- var rows = new List>();
- var row = new List();
- 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 values, string key) =>
values.TryGetValue(key, out var value) ? value : null;
diff --git a/TODO.md b/TODO.md
index ba17fea..55072c0 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1242,6 +1242,28 @@ Zugangsdaten; Schülerdaten, Fehlzeiten und der unverschlüsselte CSV-Report pas
eine lokale `ParticipationSession` dieses Kurses existiert, können als offen, entschuldigt oder
unentschuldigt übernommen werden; fremde/ganztägige Abwesenheiten erzeugen keine lokale Stunde.
+**Nachtrag zu 4.3, Fehlzeiten je Unterricht (August 2026):** Der ursprüngliche Fehlzeitenabgleich
+rief `getTimetableWithAbsences` ohne Element auf und bekam damit den kompletten Lehrer-Stundenplan
+zurück (einmal pro Kursmitglied, siehe damalige Ineffizienz-Korrektur) — das erfordert mehr
+WebUntis-Rechte, als ein Lehrkraft-Konto standardmäßig hat, und schlug deshalb in der Praxis fehl.
+Ersetzt durch den undokumentierten internen Bericht "Fehlzeiten pro Unterricht pro Schüler\*in"
+(`reports.do?name=AbsencePerLesson`, per Netzwerk-Mitschnitt aus der WebUntis-WebApp
+reverse-engineered, da die offizielle Reports-API eine Partner-Freigabe braucht), der nur den
+eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funktioniert.
+- `LearningGroup.WebUntisLessonId` (die WebUntis-interne Unterrichtsnummer/lsid) muss dafür pro
+ Lerngruppe von Hand hinterlegt werden — sie wird von WebUntis pro Schuljahr neu vergeben (kein
+ Auslesen über JSON-RPC möglich) und wird deshalb beim Schuljahreswechsel (`GroupRolloverService`)
+ bewusst nicht in die Folgegruppe übernommen.
+- Die Lehrkraft-Kennung (`teacherId`) kommt aus `personId` der `authenticate`-Antwort und muss nicht
+ gepflegt werden — der Bericht zeigt ohnehin nur eigenen Unterricht, auch bei Doppelbesetzung.
+- Der Bericht liefert keinen Klartext-Entschuldigungsstatus, nur Minutenwerte und ein
+ Bearbeitet-Datum. Statuszuordnung (Heuristik, nicht durch WebUntis-Dokumentation bestätigt):
+ fehlende Zeit kürzer als die Unterrichtsdauer → Verspätung; sonst ohne Bearbeitet-Datum →
+ ausstehend, mit unentschuldigtem Minutenanteil → unentschuldigt, sonst entschuldigt.
+- Der alte, jetzt entfernte Fehlzeitenabgleich (`WebUntisAbsenceComparisonViewModel`) sowie der
+ zugehörige Client-Aufruf `getTimetableWithAbsences` wurden ersatzlos gestrichen statt behoben,
+ da der neue Bericht denselben Zweck ohne die Rechteproblematik erfüllt.
+
### 4.4 Wochen-/Tagesansicht
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3
("Heute"-Tab: Tagesliste unten angedockt, gruppenübergreifendes Wochenraster darüber, inkl.