diff --git a/LehrerApp.Desktop/Services/WebUntisIntegrationService.cs b/LehrerApp.Desktop/Services/WebUntisIntegrationService.cs
index ae2fc05..ae6b444 100644
--- a/LehrerApp.Desktop/Services/WebUntisIntegrationService.cs
+++ b/LehrerApp.Desktop/Services/WebUntisIntegrationService.cs
@@ -30,6 +30,8 @@ public sealed record UntisStudentReportDto(int Count, string? ClassNameFilter, I
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);
+public sealed record UntisClassRegisterEventDto(string ClassName, int Date, string? Subject,
+ string StudentName, string? CategoryName, string? CategoryGroup, string? Text);
/// Direkter WebUntis-Client des Desktops. Personenbezogene Antworten und der
/// unverschlüsselte CSV-Report passieren zu keinem Zeitpunkt den LehrerApp-Server.
@@ -117,6 +119,22 @@ public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettings
x.HandledOn, x.Counts)).ToList();
}, token);
+ // "-alle-"-Bericht, hier auf eigene Einträge gefiltert (Benutzer == eigener WebUntis-Login) - Einträge
+ // anderer Lehrkräfte zu Schülern der eigenen Klasse gehören zu einem eigenständigen, noch nicht
+ // gebauten "Klassenlehrer"-Feature (siehe TODO.md), nicht zum reinen Dokumentations-Abgleich.
+ public Task> GetOwnClassRegisterEventsAsync(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)entries
+ .Where(x => ownUsername is not null
+ && string.Equals(x.TeacherUsername, ownUsername, StringComparison.OrdinalIgnoreCase))
+ .Select(x => new UntisClassRegisterEventDto(x.ClassName, x.Date, x.Subject, x.StudentName,
+ x.CategoryName, x.CategoryGroup, x.Text))
+ .ToList();
+ }, token);
+
private async Task ExecuteAsync(Func> operation, CancellationToken token)
{
try { return await operation(await GetClientAsync(token)); }
diff --git a/LehrerApp.Desktop/ViewModels/Groups/WebUntisLessonAbsenceComparisonViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/WebUntisLessonAbsenceComparisonViewModel.cs
index 0d65aa4..41ea98a 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/WebUntisLessonAbsenceComparisonViewModel.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/WebUntisLessonAbsenceComparisonViewModel.cs
@@ -43,11 +43,16 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
private readonly IParticipationSessionRepository _sessions;
private readonly IParticipationRepository _participation;
+ private IReadOnlyList _loadedStudents = [];
+ private IReadOnlyDictionary _loadedSessions =
+ new Dictionary();
+
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;
+ [ObservableProperty] private bool _markUnknownAsPresent;
public WebUntisLessonAbsenceComparisonViewModel(LearningGroup group, WebUntisIntegrationService untis,
IStudentRepository students, IParticipationSessionRepository sessions,
@@ -78,6 +83,8 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
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());
+ _loadedStudents = courseStudents;
+ _loadedSessions = localSessions;
// 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.
@@ -152,10 +159,39 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
entry.UpdatedAt = DateTime.UtcNow;
_participation.Save(entry);
}
- Status = $"{selected.Count} Anwesenheitsstatus übernommen.";
+
+ var presentCount = MarkUnknownAsPresent ? FillUnknownAsPresent() : 0;
+ Status = $"{selected.Count} Anwesenheitsstatus übernommen." +
+ (MarkUnknownAsPresent ? $" {presentCount} unbekannte Status auf anwesend gesetzt." : "");
foreach (var row in selected) row.Selected = false;
}
+ // "Identifiziert" heißt hier: WebUntis hat für diesen Schüler an diesem Tag überhaupt eine Zeile
+ // gemeldet - unabhängig davon, ob die Zeile markiert/übernommen wurde. Nur wer für den geladenen
+ // Zeitraum weder von WebUntis gemeldet noch lokal schon kontrolliert wurde, gilt als "unbekannt"
+ // und wird auf anwesend gesetzt; bereits erfasste Einträge (auch ohne Anwesenheitsstatus, z.B. nur
+ // mit Notiz) werden nicht überschrieben, wenn ihr Anwesenheitsstatus schon gesetzt ist.
+ private int FillUnknownAsPresent()
+ {
+ var identified = Rows.Where(x => x.AssignedStudent is not null)
+ .Select(x => (x.Date, StudentId: x.AssignedStudent!.Id))
+ .ToHashSet();
+ var filled = 0;
+ foreach (var session in _loadedSessions.Values)
+ foreach (var student in _loadedStudents)
+ {
+ if (identified.Contains((session.Date, student.Id))) continue;
+ var entry = _participation.GetBySessionAndStudent(session.Id, student.Id);
+ if (entry?.Attendance is not null) continue;
+ entry ??= new ParticipationEntry { SessionId = session.Id, StudentId = student.Id };
+ entry.Attendance = AttendanceStatus.Present;
+ entry.UpdatedAt = DateTime.UtcNow;
+ _participation.Save(entry);
+ filled++;
+ }
+ return filled;
+ }
+
private static int? StudentKey(Student student)
{
student.ExternalIds ??= [];
diff --git a/LehrerApp.Desktop/ViewModels/Students/WebUntisDocumentationComparisonViewModel.cs b/LehrerApp.Desktop/ViewModels/Students/WebUntisDocumentationComparisonViewModel.cs
new file mode 100644
index 0000000..7e3a385
--- /dev/null
+++ b/LehrerApp.Desktop/ViewModels/Students/WebUntisDocumentationComparisonViewModel.cs
@@ -0,0 +1,191 @@
+using System.Collections.ObjectModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using LehrerApp.Core.Interfaces;
+using LehrerApp.Core.Models;
+using LehrerApp.Core.Services;
+using LehrerApp.Desktop.Services;
+
+namespace LehrerApp.Desktop.ViewModels.Students;
+
+/// Eine Zeile bleibt auch ohne automatische Zuordnung sichtbar -
+/// kann manuell per Auswahlliste gesetzt werden, wenn der Name nicht eindeutig auf ein/e Schüler*in
+/// passt (siehe WebUntisLessonAbsenceComparisonViewModel für dasselbe Muster).
+public partial class WebUntisDocumentationRow : ObservableObject
+{
+ public required string ClassName { get; init; }
+ public required DateOnly Date { get; init; }
+ public required string UntisStudentName { get; init; }
+ public string? Subject { get; init; }
+ public string? CategoryName { get; init; }
+ public string? CategoryGroup { get; init; }
+ public string? Text { get; init; }
+ public required IReadOnlyList Candidates { get; init; }
+ public string DateLabel => Date.ToString("dd.MM.yyyy");
+ public bool CanApply => AssignedStudent is not null;
+
+ [ObservableProperty] private Student? _assignedStudent;
+ /// Titel/Text eines lokalen Eintrags, der am selben Tag für diese/n Schüler*in schon
+ /// existiert - nur nach Datum+Schüler*in erkannt, nicht nach Wortlaut (der unterscheidet sich oft
+ /// von der WebUntis-Kategorie). Deshalb Anzeige zum Vergleichen statt automatischem Ausblenden.
+ [ObservableProperty] private string? _existingLocalEntry;
+ [ObservableProperty] private bool _selected;
+}
+
+/// Lokaler Dokumentationseintrag im geladenen Zeitraum ohne passenden WebUntis-Eintrag - kann
+/// nicht automatisch nach WebUntis geschrieben werden (bewusst keine schreibenden Aufrufe gegen die
+/// undokumentierte API), deshalb nur als Kopiervorlage für die manuelle Nacherfassung dort.
+public sealed record LocalOnlyDocumentationRow(
+ string StudentName, DateOnly Date, string? GroupName, string Title, string Content)
+{
+ public string DateLabel => Date.ToString("dd.MM.yyyy");
+ public string ClipboardText =>
+ $"{DateLabel} – {StudentName}" + (GroupName is null ? "" : $" ({GroupName})") +
+ $"\n{Title}\n{Content}";
+}
+
+/// Abgleich der WebUntis-Klassenbucheinträge (eigene, siehe
+/// ) gegen die lokale
+/// - Dashboard-weit statt pro Lerngruppe, weil der WebUntis-"-alle-"-
+/// Bericht ebenfalls klassenübergreifend ist (siehe TODO.md, Nachtrag zu 4.3).
+public partial class WebUntisDocumentationComparisonViewModel : ObservableObject
+{
+ private readonly WebUntisIntegrationService _untis;
+ private readonly IStudentRepository _students;
+ private readonly IGroupRepository _groups;
+ private readonly IDocumentationRepository _documentation;
+ private readonly SchoolYearService _schoolYears;
+
+ public ObservableCollection Rows { get; } = [];
+ public ObservableCollection LocalOnlyRows { get; } = [];
+ [ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddDays(-7);
+ [ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now;
+ [ObservableProperty] private string _status = "Zeitraum wählen und Klassenbucheinträge laden.";
+ [ObservableProperty] private bool _busy;
+
+ public WebUntisDocumentationComparisonViewModel(WebUntisIntegrationService untis,
+ IStudentRepository students, IGroupRepository groups, IDocumentationRepository documentation,
+ SchoolYearService schoolYears)
+ {
+ _untis = untis; _students = students; _groups = groups; _documentation = documentation;
+ _schoolYears = schoolYears;
+ }
+
+ [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(); LocalOnlyRows.Clear();
+ try
+ {
+ var ownStudents = _students.GetAll();
+ var globalIndex = BuildNameIndex(ownStudents);
+ // Zusätzlich pro Klasse (aktuelles Schuljahr) indiziert: löst den Fall "gleicher Name in
+ // verschiedenen Klassen" auf, den ein rein globaler Namensabgleich nicht unterscheiden könnte.
+ var currentSchoolYear = _schoolYears.CurrentSchoolYear();
+ var classIndexes = _groups.GetAll()
+ .Where(g => g.Type == GroupType.Class && g.SchoolYear == currentSchoolYear)
+ .GroupBy(g => g.Name, StringComparer.OrdinalIgnoreCase)
+ .ToDictionary(
+ g => g.Key,
+ g => BuildNameIndex(g.SelectMany(x => _students.GetByGroup(x.Id)).Distinct().ToList()),
+ StringComparer.OrdinalIgnoreCase);
+
+ var entries = await _untis.GetOwnClassRegisterEventsAsync(start, end);
+ var localDocs = _documentation.GetAll().Where(d => d.Date >= start && d.Date <= end).ToList();
+
+ var ordered = entries
+ .Select(e => (Entry: e, Date: TryDate(e.Date, out var d) ? d : (DateOnly?)null))
+ .Where(x => x.Date is not null)
+ .OrderBy(x => x.Date).ThenBy(x => x.Entry.StudentName);
+
+ // Nur nach Schüler*in+Datum erkannt, nicht nach Wortlaut: ein lokaler Eintrag von vor dieser
+ // Funktion (oder frei formuliert) hat selten denselben Titel wie die WebUntis-Kategorie.
+ List LocalDocsFor(Guid studentId, DateOnly date) =>
+ localDocs.Where(d => d.StudentId == studentId && d.Date == date).ToList();
+
+ foreach (var (entry, date) in ordered)
+ {
+ var nameKey = NameKey(entry.StudentName);
+ var match = (classIndexes.TryGetValue(entry.ClassName, out var classIndex)
+ ? classIndex.GetValueOrDefault(nameKey)
+ : null)
+ ?? globalIndex.GetValueOrDefault(nameKey);
+ var existing = match is null ? [] : LocalDocsFor(match.Id, date!.Value);
+
+ Rows.Add(new WebUntisDocumentationRow
+ {
+ ClassName = entry.ClassName, Date = date!.Value, UntisStudentName = entry.StudentName,
+ Subject = entry.Subject, CategoryName = entry.CategoryName, CategoryGroup = entry.CategoryGroup,
+ Text = entry.Text, Candidates = ownStudents, AssignedStudent = match,
+ ExistingLocalEntry = existing.Count == 0 ? null
+ : string.Join(" | ", existing.Select(d => $"{d.Title}: {d.Content}")),
+ Selected = match is not null && existing.Count == 0,
+ });
+ }
+
+ foreach (var doc in localDocs)
+ {
+ var coveredByReport = Rows.Any(r => r.AssignedStudent?.Id == doc.StudentId && r.Date == doc.Date);
+ if (coveredByReport) continue;
+ var student = ownStudents.FirstOrDefault(s => s.Id == doc.StudentId);
+ if (student is null) continue;
+ LocalOnlyRows.Add(new LocalOnlyDocumentationRow(student.FullName, doc.Date,
+ doc.GroupId is { } groupId ? _groups.GetById(groupId)?.Name : null, doc.Title, doc.Content));
+ }
+
+ var unresolved = Rows.Count(x => x.AssignedStudent is null);
+ var possibleDuplicates = Rows.Count(x => x.ExistingLocalEntry is not null);
+ Status = $"{Rows.Count} WebUntis-Einträge erhalten, {possibleDuplicates} mit lokalem Eintrag am " +
+ "selben Tag (bitte vergleichen)" +
+ (unresolved > 0 ? $", {unresolved} bitte manuell zuordnen" : "") +
+ $". {LocalOnlyRows.Count} lokale Einträge ohne WebUntis-Gegenstück.";
+ }
+ catch (WebUntisIntegrationException ex) { Status = ex.Message; }
+ finally { Busy = false; }
+ }
+
+ [RelayCommand]
+ private void Apply()
+ {
+ var selected = Rows.Where(x => x.Selected && x.CanApply).ToList();
+ foreach (var row in selected)
+ {
+ _documentation.Save(new Documentation
+ {
+ StudentId = row.AssignedStudent!.Id,
+ Date = row.Date,
+ Type = DocumentationType.Incident,
+ Title = row.CategoryName ?? "WebUntis-Klassenbucheintrag",
+ Content = row.Text ?? "",
+ Tags = row.CategoryGroup is { Length: > 0 } group ? [group] : [],
+ });
+ }
+ foreach (var row in selected)
+ {
+ row.ExistingLocalEntry = $"{row.CategoryName}: {row.Text}";
+ row.Selected = false;
+ }
+ Status = $"{selected.Count} Einträge aus WebUntis übernommen.";
+ }
+
+ private static string NameKey(string value) => value.Trim().ToLowerInvariant();
+
+ // Wie beim Fehlzeiten-Abgleich: WebUntis liefert Namen nicht einheitlich in einer Reihenfolge,
+ // deshalb werden beide Reihenfolgen registriert, aber nur falls innerhalb der Kandidaten eindeutig.
+ private static Dictionary BuildNameIndex(IReadOnlyList candidates) =>
+ candidates
+ .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);
+
+ private static bool TryDate(int value, out DateOnly date) =>
+ DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
+}
diff --git a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml
index 673a171..476e631 100644
--- a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml
+++ b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml
@@ -25,8 +25,12 @@
-
+
+
+
+
(),
+ App.Services.GetRequiredService(),
+ App.Services.GetRequiredService(),
+ App.Services.GetRequiredService(),
+ App.Services.GetRequiredService());
+ await new WebUntisDocumentationComparisonDialog { DataContext = dialogVm }.ShowDialog(owner);
+ }
}
diff --git a/LehrerApp.Desktop/Views/Groups/WebUntisLessonAbsenceComparisonDialog.axaml b/LehrerApp.Desktop/Views/Groups/WebUntisLessonAbsenceComparisonDialog.axaml
index 073a12e..7339d58 100644
--- a/LehrerApp.Desktop/Views/Groups/WebUntisLessonAbsenceComparisonDialog.axaml
+++ b/LehrerApp.Desktop/Views/Groups/WebUntisLessonAbsenceComparisonDialog.axaml
@@ -5,7 +5,7 @@
x:DataType="vm:WebUntisLessonAbsenceComparisonViewModel"
Title="Fehlzeiten je Unterricht mit WebUntis abgleichen" Width="1000" Height="640"
MinWidth="800" MinHeight="450" WindowStartupLocation="CenterOwner">
-
+
-
+
+
diff --git a/LehrerApp.Desktop/Views/Students/WebUntisDocumentationComparisonDialog.axaml b/LehrerApp.Desktop/Views/Students/WebUntisDocumentationComparisonDialog.axaml
new file mode 100644
index 0000000..93e4f4d
--- /dev/null
+++ b/LehrerApp.Desktop/Views/Students/WebUntisDocumentationComparisonDialog.axaml
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LehrerApp.Desktop/Views/Students/WebUntisDocumentationComparisonDialog.axaml.cs b/LehrerApp.Desktop/Views/Students/WebUntisDocumentationComparisonDialog.axaml.cs
new file mode 100644
index 0000000..ef58091
--- /dev/null
+++ b/LehrerApp.Desktop/Views/Students/WebUntisDocumentationComparisonDialog.axaml.cs
@@ -0,0 +1,24 @@
+using Avalonia.Controls;
+using Avalonia.Input.Platform;
+using Avalonia.Interactivity;
+using LehrerApp.Desktop.Services;
+using LehrerApp.Desktop.ViewModels.Students;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace LehrerApp.Desktop.Views.Students;
+
+public partial class WebUntisDocumentationComparisonDialog : Window
+{
+ public WebUntisDocumentationComparisonDialog() => InitializeComponent();
+
+ private void OnClose(object? sender, RoutedEventArgs e) => Close();
+
+ private async void OnCopyToClipboardClick(object? sender, RoutedEventArgs e)
+ {
+ if (sender is not Button { DataContext: LocalOnlyDocumentationRow row }) return;
+ var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
+ if (clipboard is null) return;
+ await clipboard.SetTextAsync(row.ClipboardText);
+ App.Services.GetRequiredService().ShowSuccess("In die Zwischenablage kopiert.");
+ }
+}
diff --git a/LehrerApp.WebUntis.Tests/WebUntisClassRegisterEventReportParserTests.cs b/LehrerApp.WebUntis.Tests/WebUntisClassRegisterEventReportParserTests.cs
new file mode 100644
index 0000000..2d45e20
--- /dev/null
+++ b/LehrerApp.WebUntis.Tests/WebUntisClassRegisterEventReportParserTests.cs
@@ -0,0 +1,45 @@
+using Xunit;
+using LehrerApp.WebUntis;
+
+namespace LehrerApp.WebUntis.Tests;
+
+public sealed class WebUntisClassRegisterEventReportParserTests
+{
+ [Fact]
+ public void Parse_UebernimmtAlleFelderUndZweistelligesJahr()
+ {
+ const string report = "\uFEFFKlasse\tDatum\tFach\tName\tBenutzer\tEintragskategorie\tKategoriegruppe\tText\r\n" +
+ "10c\t24.08.26\tEng_G\tMuster Erika\ttownsend\tFehlende HA\tNegativ\tKeine Hausaufgaben gemacht.\r\n";
+
+ var entry = Assert.Single(WebUntisClassRegisterEventReportParser.Parse(report));
+
+ Assert.Equal("10c", entry.ClassName);
+ Assert.Equal(20260824, entry.Date);
+ Assert.Equal("Eng_G", entry.Subject);
+ Assert.Equal("Muster Erika", entry.StudentName);
+ Assert.Equal("townsend", entry.TeacherUsername);
+ Assert.Equal("Fehlende HA", entry.CategoryName);
+ Assert.Equal("Negativ", entry.CategoryGroup);
+ Assert.Equal("Keine Hausaufgaben gemacht.", entry.Text);
+ }
+
+ [Fact]
+ public void Parse_UeberspringtLeereZeilen()
+ {
+ const string report = "Klasse\tDatum\tFach\tName\tBenutzer\tEintragskategorie\tKategoriegruppe\tText\r\n" +
+ "6a\t24.08.26\tNAT\tMuster Max\thedtrich\tMitarb. über Erwart.\tPositiv\tGut.\r\n" +
+ "\t\t\t\t\t\t\t\r\n";
+
+ var entry = Assert.Single(WebUntisClassRegisterEventReportParser.Parse(report));
+ Assert.Equal("Muster Max", entry.StudentName);
+ }
+
+ [Fact]
+ public void Parse_LehntUngueltigesDatumAb()
+ {
+ const string report = "Klasse\tDatum\tFach\tName\tBenutzer\tEintragskategorie\tKategoriegruppe\tText\r\n" +
+ "6a\tkein-datum\tNAT\tMuster Max\thedtrich\tMitarb. über Erwart.\tPositiv\tGut.\r\n";
+
+ Assert.Throws(() => WebUntisClassRegisterEventReportParser.Parse(report));
+ }
+}
diff --git a/LehrerApp.WebUntis.Tests/WebUntisClientTests.cs b/LehrerApp.WebUntis.Tests/WebUntisClientTests.cs
index ea9710b..63617e1 100644
--- a/LehrerApp.WebUntis.Tests/WebUntisClientTests.cs
+++ b/LehrerApp.WebUntis.Tests/WebUntisClientTests.cs
@@ -209,6 +209,41 @@ public sealed class WebUntisClientTests
await client.DisposeAsync();
}
+ [Fact]
+ public async Task GetClassRegisterEventsReportAsync_BautDenBerichtsAbrufAufUndTypisiertDasErgebnis()
+ {
+ var csv = Encoding.UTF8.GetBytes(
+ "Klasse\tDatum\tFach\tName\tBenutzer\tEintragskategorie\tKategoriegruppe\tText\r\n" +
+ "6a\t24.08.26\tNAT\tMuster Max\thedtrich\tMitarb. über Erwart.\tPositiv\tArbeitet gut mit.\r\n");
+ var handler = new QueueHandler(
+ Json("{\"result\":{\"sessionId\":\"s\",\"personId\":89}}"),
+ Json("{\"data\":{\"finished\":true,\"error\":false," +
+ "\"reportParams\":\"get=rpt2.tmp&name=ClassregEventPerStudent&format=csv\"}}"),
+ new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(csv) },
+ Json("{\"result\":{}}"));
+ var client = CreateClient(handler);
+
+ var report = await client.GetClassRegisterEventsReportAsync(20260824, 20260828, CancellationToken.None);
+
+ var entry = Assert.Single(report);
+ Assert.Equal("6a", entry.ClassName);
+ Assert.Equal(20260824, entry.Date);
+ Assert.Equal("NAT", entry.Subject);
+ Assert.Equal("Muster Max", entry.StudentName);
+ Assert.Equal("hedtrich", entry.TeacherUsername);
+ Assert.Equal("Mitarb. über Erwart.", entry.CategoryName);
+ Assert.Equal("Positiv", entry.CategoryGroup);
+ Assert.Equal("Arbeitet gut mit.", entry.Text);
+ Assert.Contains("reports.do?name=ClassregEventPerStudent", handler.Requests[1].Uri);
+ Assert.Contains("klasseOrStudentgroupId=-1", handler.Requests[1].Uri);
+ Assert.Contains("studentId=-1", handler.Requests[1].Uri);
+ Assert.Contains("rpt_sd=20260824", handler.Requests[1].Uri);
+ Assert.Contains("rpt_ed=20260828", handler.Requests[1].Uri);
+ Assert.EndsWith("reports.do?get=rpt2.tmp&name=ClassregEventPerStudent&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/WebUntisClassRegisterEventReportParser.cs b/LehrerApp.WebUntis/WebUntisClassRegisterEventReportParser.cs
new file mode 100644
index 0000000..004c7e7
--- /dev/null
+++ b/LehrerApp.WebUntis/WebUntisClassRegisterEventReportParser.cs
@@ -0,0 +1,53 @@
+using System.Globalization;
+
+namespace LehrerApp.WebUntis;
+
+public static class WebUntisClassRegisterEventReportParser
+{
+ 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;
+
+ result.Add(new UntisClassRegisterEventReportEntry(
+ Get(values, "Klasse")?.Trim() ?? "",
+ GermanShortDate(Get(values, "Datum")) ?? throw new InvalidDataException(
+ "Ungültiges Datum in Spalte \"Datum\"."),
+ Optional(Get(values, "Fach")),
+ Get(values, "Name")?.Trim() ?? "",
+ Optional(Get(values, "Benutzer")),
+ Optional(Get(values, "Eintragskategorie")),
+ Optional(Get(values, "Kategoriegruppe")),
+ Optional(Get(values, "Text"))));
+ }
+
+ return result;
+ }
+
+ 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? 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/WebUntisClient.cs b/LehrerApp.WebUntis/WebUntisClient.cs
index c9533a5..667605a 100644
--- a/LehrerApp.WebUntis/WebUntisClient.cs
+++ b/LehrerApp.WebUntis/WebUntisClient.cs
@@ -148,6 +148,33 @@ public sealed class WebUntisClient : IAsyncDisposable
return WebUntisLessonAbsenceParser.Parse(reportText);
}, cancellationToken);
+ // Undokumentierter interner Bericht der WebUntis-WebApp für Klassenbucheinträge ("-alle-"-Bereich,
+ // damit auch Einträge zu fremden Klassen aus dem eigenen Unterricht mit dabei sind - die
+ // Oberfläche bietet sonst nur "eigene Klasse" oder "-alle-" an). klasseOrStudentgroupId/
+ // studentId=-1 entspricht "-alle-" und wird lokal auf eigene Lerngruppen gefiltert.
+ // Query-Parameter 1:1 aus einem echten Browser-Request übernommen (bis auf das nicht benötigte
+ // selectedDateRange/_csrf, siehe GetLessonAbsencesAsync). Anders als AbsencePerLesson liefert
+ // dieser Bericht keine externe Schülerkennung, nur den Namen (siehe UntisClassRegisterEventReportEntry).
+ public Task> GetClassRegisterEventsReportAsync(
+ int startDate, int endDate, CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
+ {
+ var query = "name=ClassregEventPerStudent&format=csv&klasseOrStudentgroupId=-1&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 WebUntisClassRegisterEventReportParser.Parse(reportText);
+ }, cancellationToken);
+
public Task> GetSubstitutionsAsync(int startDate, int endDate,
int? departmentId, CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
{
diff --git a/LehrerApp.WebUntis/WebUntisModels.cs b/LehrerApp.WebUntis/WebUntisModels.cs
index 881a064..4582b3c 100644
--- a/LehrerApp.WebUntis/WebUntisModels.cs
+++ b/LehrerApp.WebUntis/WebUntisModels.cs
@@ -147,6 +147,18 @@ public sealed record UntisLessonAbsence(
string? HandledOn,
bool Counts);
+// "-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(
+ string ClassName,
+ int Date,
+ string? Subject,
+ string StudentName,
+ string? TeacherUsername,
+ string? CategoryName,
+ string? CategoryGroup,
+ string? Text);
+
public sealed record UntisClassRegisterEntry(
int? StudentKey,
string? Surname,
diff --git a/TODO.md b/TODO.md
index 55072c0..0128208 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1256,13 +1256,32 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk
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 Bericht liefert keinen Klartext-Entschuldigungsstatus, nur Minutenwerte, ein
+ Bearbeitet-Datum und die externe Schülerkennung. Statuszuordnung (Heuristik, an echten Daten der
+ Schule korrigiert, nicht durch WebUntis-Dokumentation bestätigt): Abwesenheitsgrund enthält
+ "entlassen" → vorzeitig entlassen, unabhängig von der Dauer; sonst Fehlminuten unter 45 (eine volle
+ Stunde) → Verspätung/Teilverlust; sonst ohne Bearbeitet-Datum → ausstehend; sonst externe
+ Schülerkennung in Klammern → unentschuldigt, ohne Klammern → entschuldigt (das Vorzeichen der
+ Kennung war die ursprüngliche, falsche Annahme — es gibt kein Minuszeichen in den echten Daten).
- 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.
+- Namensabgleich als Fallback, wenn ein Schüler keine externe WebUntis-Kennung hat: WebUntis liefert
+ Namen teils als "Nachname Vorname", teils uneinheitlich — deshalb werden lokal beide Reihenfolgen
+ registriert, aber nur falls eindeutig innerhalb der Kandidaten; sonst manuelle Zuordnung per Auswahl
+ in der Vergleichszeile, statt zu raten.
+- Zweiter, undokumentierter Bericht `reports.do?name=ClassregEventPerStudent` liefert Klassenbuch-
+ einträge (Klasse, Datum, Fach, Name, Benutzer, Eintragskategorie, Kategoriegruppe, Text) im
+ "-alle-"-Bereich, da die Oberfläche sonst nur "eigene Klasse" oder "-alle-" anbietet
+ (`WebUntisClient.GetClassRegisterEventsReportAsync`, `WebUntisClassRegisterEventReportParser`).
+ Auch hier keine externe Schülerkennung, nur der Name — Namensabgleich soll deshalb zusätzlich auf
+ Klasse+Fach aus der Zeile scopen (→ genau eine lokale Lerngruppe), nicht global über alle Schüler.
+ Abgleichsdialog (Diff gegen `Documentation`, Übernehmen in beide Richtungen) noch nicht gebaut.
+- **Idee, noch nicht geplant ("Klassenlehrer"-Feature):** Der "-alle-"-Bericht enthält für die
+ eigene Klasse auch Einträge, die andere Lehrkräfte in deren Fächern angelegt haben (`Benutzer`
+ ≠ eigener Login) — für den reinen Dokumentations-Abgleich (eigene Einträge synchron halten) werden
+ die herausgefiltert. Eine Klassenlehrer-Übersicht, die stattdessen genau diese fremden Einträge zur
+ eigenen Klasse zeigt, wäre ein eigenständiges, größeres Feature und ist bewusst zurückgestellt.
### 4.4 Wochen-/Tagesansicht
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3