From f3e23ad2076e67f54442b85524036638d179ab34 Mon Sep 17 00:00:00 2001 From: Baddi86 Date: Fri, 25 Sep 2026 21:33:54 +0200 Subject: [PATCH] Fix Untis homework comparison and student selection --- .../UntisNameMatchingTests.cs | 29 +++ ...ebUntisHomeworkComparisonViewModelTests.cs | 167 ++++++++++++++++++ .../ClassTeacher/UntisNameMatching.cs | 19 ++ .../WebUntisHomeworkComparisonViewModel.cs | 110 +++++++++--- ...ebUntisDocumentationComparisonViewModel.cs | 14 +- .../WebUntisHomeworkComparisonDialog.axaml | 56 ++++-- .../WebUntisHomeworkComparisonDialog.axaml.cs | 13 ++ TODO.md | 30 ++-- 8 files changed, 372 insertions(+), 66 deletions(-) create mode 100644 LehrerApp.Desktop.Tests/WebUntisHomeworkComparisonViewModelTests.cs diff --git a/LehrerApp.Desktop.Tests/UntisNameMatchingTests.cs b/LehrerApp.Desktop.Tests/UntisNameMatchingTests.cs index ccee2b6..b4bda08 100644 --- a/LehrerApp.Desktop.Tests/UntisNameMatchingTests.cs +++ b/LehrerApp.Desktop.Tests/UntisNameMatchingTests.cs @@ -1,4 +1,5 @@ using LehrerApp.Desktop.ViewModels.ClassTeacher; +using LehrerApp.Core.Models; using Xunit; namespace LehrerApp.Desktop.Tests; @@ -28,4 +29,32 @@ public sealed class UntisNameMatchingTests { Assert.True(UntisNameMatching.NamesMatch("Gerste, Amelia", "Gerste Amelia")); } + + [Fact] + public void BuildClassNameIndex_VerwendetDieselbeInstanzWieDieAuswahlliste() + { + var candidate = new Student { FirstName = "Amelia", LastName = "Gerste" }; + var repositoryCopy = new Student + { + Id = candidate.Id, FirstName = candidate.FirstName, LastName = candidate.LastName, + }; + + var index = UntisNameMatching.BuildClassNameIndex([candidate], [repositoryCopy]); + + Assert.Same(candidate, index[UntisNameMatching.NameKey("Gerste Amelia")]); + } + + [Fact] + public void BuildClassNameIndex_GleicheNamenInVerschiedenenKlassenBleibenEindeutig() + { + var classStudent = new Student { FirstName = "Amelia", LastName = "Gerste" }; + var otherStudent = new Student { FirstName = "Amelia", LastName = "Gerste" }; + var candidates = new[] { classStudent, otherStudent }; + + Assert.Empty(UntisNameMatching.BuildNameIndex(candidates)); + var index = UntisNameMatching.BuildClassNameIndex(candidates, + [new Student { Id = classStudent.Id, FirstName = "Amelia", LastName = "Gerste" }]); + + Assert.Same(classStudent, index[UntisNameMatching.NameKey("Gerste Amelia")]); + } } diff --git a/LehrerApp.Desktop.Tests/WebUntisHomeworkComparisonViewModelTests.cs b/LehrerApp.Desktop.Tests/WebUntisHomeworkComparisonViewModelTests.cs new file mode 100644 index 0000000..76b226b --- /dev/null +++ b/LehrerApp.Desktop.Tests/WebUntisHomeworkComparisonViewModelTests.cs @@ -0,0 +1,167 @@ +using System.Net; +using System.Text; +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using LehrerApp.Desktop.Services; +using LehrerApp.Desktop.ViewModels.Groups; +using LehrerApp.WebUntis; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class WebUntisHomeworkComparisonViewModelTests +{ + [Theory] + [InlineData("Fehlende HA", "Buch vergessen", "Negativ", true)] + [InlineData("Sonstiges", "HA nicht dabei", "Negativ", false)] + [InlineData(null, "HA nicht dabei", "Negativ", true)] + [InlineData("Hausaufgaben fehlen", null, "Negativ", true)] + [InlineData("Verhalten", "Schüler war unruhig", "Negativ", false)] + [InlineData("Hausaufgaben", "HA gemacht", "Positiv", false)] + [InlineData("Sonstiges", "Schach gespielt", "Negativ", false)] + public void IsMissingHomeworkEntry_ErkenntAuchDasWebUntisKuerzel( + string? category, string? entryText, string group, bool expected) + { + var entry = new UntisClassRegisterEventDto("10c", 20260925, "Che", "Gerste Amelia", + category, group, entryText); + + Assert.Equal(expected, WebUntisHomeworkComparisonViewModel.IsMissingHomeworkEntry(entry)); + } + + [Fact] + public async Task Load_ZeigtFehlendeHaAuchOhneHausaufgabeImFreitext() + { + const string report = "Klasse\tDatum\tFach\tName\tBenutzer\tEintragskategorie\tKategoriegruppe\tText\r\n" + + "10c\t25.09.26\tChe\tGerste Amelia\tlehrkraft\tFehlende HA\tNegativ\tChe: Buch vergessen\r\n" + + "10c\t25.09.26\tChe\tGerste Amelia\tlehrkraft\tHausaufgaben\tPositiv\tChe: alles erledigt\r\n"; + var handler = new QueueHandler( + Json("{\"result\":{\"sessionId\":\"s\"}}"), + Json("{\"result\":[{\"id\":1,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"), + Json("{\"data\":{\"finished\":true,\"error\":false," + + "\"reportParams\":\"get=rpt.tmp&name=ClassregEventPerStudent&format=csv\"}}"), + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(Encoding.UTF8.GetBytes(report)), + }); + var settings = TestSupport.BuildWebUntisSettingsService(); + var credentials = new WebUntisCredentials("schule", "arche.webuntis.com", "lehrkraft", "geheim"); + settings.SetApiCredentials(credentials); + await using var untis = new WebUntisIntegrationService(new HttpClient(handler), settings); + await untis.ConnectAsync(credentials); + var vm = new WebUntisHomeworkComparisonViewModel(untis, new FakeStudents([]), + new FakeGroups([]), new FakeSubjects([]), new FakeMemberships([]), + new FakeSessions([]), new FakeEntries(), new SchoolYearService()) + { + StartDate = new DateTimeOffset(2026, 9, 24, 12, 0, 0, TimeSpan.Zero), + EndDate = new DateTimeOffset(2026, 9, 26, 12, 0, 0, TimeSpan.Zero), + }; + + await vm.LoadCommand.ExecuteAsync(null); + + Assert.Equal("Gerste Amelia", Assert.Single(vm.Rows).UntisStudentName); + Assert.Contains("1 passende Einträge von 2", vm.Status); + } + + [Fact] + public async Task LoadUndApply_TrenntBestehendenStatus_ZielstatusUndLokaleEintraege() + { + const string report = "Klasse\tDatum\tFach\tName\tBenutzer\tEintragskategorie\tKategoriegruppe\tText\r\n" + + "10c\t25.09.26\tChe\tGerste Amelia\tlehrkraft\tFehlende HA\tNegativ\tChe: nicht gemacht\r\n" + + "10c\t25.09.26\tChe\tMuster Ben\tlehrkraft\tFehlende HA\tNegativ\tChe: nicht gemacht\r\n"; + var handler = new QueueHandler( + Json("{\"result\":{\"sessionId\":\"s\"}}"), + Json("{\"result\":[{\"id\":1,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"), + Json("{\"data\":{\"finished\":true,\"error\":false," + + "\"reportParams\":\"get=rpt.tmp&name=ClassregEventPerStudent&format=csv\"}}"), + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(Encoding.UTF8.GetBytes(report)), + }); + var settings = TestSupport.BuildWebUntisSettingsService(); + var credentials = new WebUntisCredentials("schule", "arche.webuntis.com", "lehrkraft", "geheim"); + settings.SetApiCredentials(credentials); + await using var untis = new WebUntisIntegrationService(new HttpClient(handler), settings); + await untis.ConnectAsync(credentials); + + var date = new DateOnly(2026, 9, 25); + var schoolYear = new SchoolYearService(); + var chemistry = new Subject { Name = "Chemie", ShortName = "Che" }; + var biology = new Subject { Name = "Biologie", ShortName = "Bio" }; + var classGroup = new LearningGroup { Name = "10c", Type = GroupType.Class, + SchoolYear = schoolYear.CurrentSchoolYear(date) }; + var chemistryGroup = new LearningGroup { Name = "Chemie 10c", Type = GroupType.Course, + SubjectId = chemistry.Id, SchoolYear = classGroup.SchoolYear }; + var biologyGroup = new LearningGroup { Name = "Biologie 10c", Type = GroupType.Course, + SubjectId = biology.Id, SchoolYear = classGroup.SchoolYear }; + var amelia = new Student { FirstName = "Amelia", LastName = "Gerste" }; + var ben = new Student { FirstName = "Ben", LastName = "Muster" }; + var carl = new Student { FirstName = "Carl", LastName = "Beispiel" }; + var chemistrySession = new ParticipationSession { GroupId = chemistryGroup.Id, Date = date }; + var biologySession = new ParticipationSession { GroupId = biologyGroup.Id, Date = date }; + var memberships = new FakeMemberships([ + new GroupMembership { StudentId = amelia.Id, GroupId = chemistryGroup.Id }, + new GroupMembership { StudentId = amelia.Id, GroupId = biologyGroup.Id }, + new GroupMembership { StudentId = ben.Id, GroupId = chemistryGroup.Id }, + new GroupMembership { StudentId = carl.Id, GroupId = chemistryGroup.Id }, + ]); + var participation = new FakeEntries(); + participation.Add(new ParticipationEntry { SessionId = chemistrySession.Id, StudentId = amelia.Id, + Homework = HomeworkStatus.MissingOpen, HomeworkMissing = true }); + participation.Add(new ParticipationEntry { SessionId = biologySession.Id, StudentId = amelia.Id, + Homework = HomeworkStatus.PartiallyCompleted, HomeworkMissing = true }); + participation.Add(new ParticipationEntry { SessionId = chemistrySession.Id, StudentId = carl.Id, + Homework = HomeworkStatus.MissingOverdue, HomeworkMissing = true }); + + var vm = new WebUntisHomeworkComparisonViewModel(untis, + new FakeStudents([amelia, ben, carl]), + new FakeGroups([classGroup, chemistryGroup, biologyGroup]), + new FakeSubjects([chemistry, biology]), memberships, + new FakeSessions([chemistrySession, biologySession]), participation, schoolYear) + { + StartDate = new DateTimeOffset(2026, 9, 24, 12, 0, 0, TimeSpan.Zero), + EndDate = new DateTimeOffset(2026, 9, 26, 12, 0, 0, TimeSpan.Zero), + }; + + await vm.LoadCommand.ExecuteAsync(null); + + var existing = Assert.Single(vm.Rows, r => r.AssignedStudent?.Id == amelia.Id); + Assert.False(existing.Selected); + Assert.Equal(HomeworkDisplay.Label(HomeworkStatus.MissingOpen), existing.LocalStatus); + Assert.Equal(HomeworkDisplay.Label(HomeworkStatus.MissingOpen), existing.SelectedStatusName); + var newRow = Assert.Single(vm.Rows, r => r.AssignedStudent?.Id == ben.Id); + Assert.True(newRow.Selected); + Assert.Equal("Noch kein Status erfasst", newRow.LocalStatus); + Assert.Equal(2, vm.LocalOnlyRows.Count); + Assert.Contains(vm.LocalOnlyRows, r => r.StudentName == amelia.FullName && r.GroupName == biologyGroup.Name); + Assert.Contains(vm.LocalOnlyRows, r => r.StudentName == carl.FullName && r.GroupName == chemistryGroup.Name); + + newRow.AssignedStudent = carl; + Assert.Single(vm.LocalOnlyRows); + Assert.False(newRow.Selected); + newRow.AssignedStudent = ben; + Assert.Equal(2, vm.LocalOnlyRows.Count); + newRow.SelectedStatusName = HomeworkDisplay.Label(HomeworkStatus.SubmittedLate); + vm.ApplyCommand.Execute(null); + + var saved = participation.GetBySessionAndStudent(chemistrySession.Id, ben.Id); + Assert.Equal(HomeworkStatus.SubmittedLate, saved?.Homework); + Assert.False(saved?.HomeworkMissing); + Assert.Equal(HomeworkDisplay.Label(HomeworkStatus.SubmittedLate), newRow.LocalStatus); + Assert.False(newRow.Selected); + Assert.Equal(HomeworkStatus.MissingOpen, + participation.GetBySessionAndStudent(chemistrySession.Id, amelia.Id)?.Homework); + } + + private static HttpResponseMessage Json(string json) => new(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }; + + private sealed class QueueHandler(params HttpResponseMessage[] responses) : HttpMessageHandler + { + private readonly Queue _responses = new(responses); + + protected override Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) => Task.FromResult(_responses.Dequeue()); + } +} diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/UntisNameMatching.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/UntisNameMatching.cs index 6275acc..22acbb6 100644 --- a/LehrerApp.Desktop/ViewModels/ClassTeacher/UntisNameMatching.cs +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/UntisNameMatching.cs @@ -1,3 +1,5 @@ +using LehrerApp.Core.Models; + namespace LehrerApp.Desktop.ViewModels.ClassTeacher; /// @@ -20,4 +22,21 @@ public static class UntisNameMatching .Where(w => w.Length > 0) .Select(w => w.ToLowerInvariant()) .OrderBy(w => w, StringComparer.Ordinal)); + + /// Verwendet die Instanzen aus der Auswahlliste. Repository-Abfragen für eine Klasse + /// liefern neue Student-Objekte mit derselben Id, die ein ComboBox.SelectedItem nicht erkennt. + public static Dictionary BuildClassNameIndex(IReadOnlyList candidates, + IEnumerable classStudents) + { + var classStudentIds = classStudents.Select(s => s.Id).ToHashSet(); + return BuildNameIndex(candidates.Where(s => classStudentIds.Contains(s.Id))); + } + + public static Dictionary BuildNameIndex(IEnumerable candidates) => + candidates + .Select(student => (Key: NameKey(student.FullName), Student: student)) + .Where(x => x.Key.Length > 0) + .GroupBy(x => x.Key) + .Where(group => group.Select(x => x.Student.Id).Distinct().Count() == 1) + .ToDictionary(group => group.Key, group => group.First().Student); } diff --git a/LehrerApp.Desktop/ViewModels/Groups/WebUntisHomeworkComparisonViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/WebUntisHomeworkComparisonViewModel.cs index 072ef17..77caf2b 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/WebUntisHomeworkComparisonViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/WebUntisHomeworkComparisonViewModel.cs @@ -19,6 +19,7 @@ public partial class WebUntisHomeworkRow : ObservableObject public required string ClassName { get; init; } public required DateOnly Date { get; init; } public required string UntisStudentName { get; init; } + public string? CategoryName { get; init; } public string? SubjectLabel { get; init; } public string? Text { get; init; } public required IReadOnlyList Candidates { get; init; } @@ -26,15 +27,39 @@ public partial class WebUntisHomeworkRow : ObservableObject internal Action? OnAssignmentChanged { get; init; } public string DateLabel => Date.ToString("dd.MM.yyyy"); public bool CanApply => SessionId is not null; + private static readonly HomeworkStatus[] SelectableStatuses = + [ + HomeworkStatus.MissingOpen, HomeworkStatus.MissingOverdue, + HomeworkStatus.PartiallyCompleted, HomeworkStatus.PartialMissingOverdue, + HomeworkStatus.PartialSubmittedLate, HomeworkStatus.SubmittedLate, + HomeworkStatus.Completed, + ]; + public static string[] StatusOptions { get; } = SelectableStatuses.Select(s => HomeworkDisplay.Label(s)).ToArray(); + public HomeworkStatus SelectedStatus => + SelectableStatuses.FirstOrDefault(s => HomeworkDisplay.Label(s) == SelectedStatusName, + HomeworkStatus.MissingOpen); [ObservableProperty] private Student? _assignedStudent; [ObservableProperty] private string _localStatus = "ohne Zuordnung"; [ObservableProperty] private Guid? _sessionId; [ObservableProperty] private bool _selected; + [ObservableProperty] private string _selectedStatusName = HomeworkDisplay.Label(HomeworkStatus.MissingOpen); partial void OnAssignedStudentChanged(Student? value) => OnAssignmentChanged?.Invoke(this); } +/// Lokal erfasste fehlende Hausaufgabe ohne zugeordnete WebUntis-Zeile. Dient nur als +/// Kopiervorlage; WebUntis wird aus dem Dialog heraus nicht beschrieben. +public sealed record LocalOnlyHomeworkRow(string StudentName, DateOnly Date, string GroupName, + string? SubjectName, HomeworkStatus HomeworkStatus, bool PossibleUntisMatch) +{ + public string DateLabel => Date.ToString("dd.MM.yyyy"); + public string StatusLabel => HomeworkDisplay.Label(HomeworkStatus); + public string? Warning => PossibleUntisMatch ? "WebUntis-Eintrag am selben Tag ohne Fachzuordnung prüfen" : null; + public string ClipboardText => $"{DateLabel} – {StudentName} ({GroupName})\n" + + $"Fehlende HA{(string.IsNullOrWhiteSpace(SubjectName) ? "" : $" ({SubjectName})")}\n{StatusLabel}"; +} + /// Abgleich negativer Klassenbucheinträge zum Stichwort "Hausaufgabe" (eigene, siehe /// ) gegen /// - Dashboard-weit statt pro Lerngruppe, aus demselben @@ -43,10 +68,8 @@ public partial class WebUntisHomeworkRow : ObservableObject /// Schülerkennung noch eine feste Fach-/Lerngruppenzuordnung, aber der Lehrkraft-eigene Text enthält /// praktisch immer das Untis-Fachkürzel - darüber wird die passende Lerngruppe (SubjectId + aktive /// Mitgliedschaft am Eintragsdatum) aufgelöst, mehrdeutige Treffer bleiben unaufgelöst statt zu raten. -/// Ein Eintrag setzt lokal ausschließlich vor, und auch nur, -/// wenn dort noch gar kein Status hinterlegt ist - bereits vorhandene, feinere Erfassungen ("Teilweise -/// angefertigt", "nachgereicht" usw.) werden nie automatisch überschrieben, sondern nur zum Vergleich -/// danebengestellt (Nutzerwunsch). +/// Als Ziel ist vorbelegt und vor der Übernahme änderbar. +/// Bereits vorhandene lokale Stati werden nicht automatisch zur Übernahme markiert. public partial class WebUntisHomeworkComparisonViewModel : ObservableObject { private readonly WebUntisIntegrationService _untis; @@ -59,6 +82,7 @@ public partial class WebUntisHomeworkComparisonViewModel : ObservableObject 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."; @@ -80,11 +104,11 @@ public partial class WebUntisHomeworkComparisonViewModel : ObservableObject var start = DateOnly.FromDateTime((StartDate ?? DateTimeOffset.Now).LocalDateTime); var end = DateOnly.FromDateTime((EndDate ?? DateTimeOffset.Now).LocalDateTime); if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; } - Busy = true; Rows.Clear(); + Busy = true; Rows.Clear(); LocalOnlyRows.Clear(); try { var ownStudents = _students.GetAll(); - var globalIndex = BuildNameIndex(ownStudents); + var globalIndex = UntisNameMatching.BuildNameIndex(ownStudents); // Wie beim Dokumentations-Abgleich zusätzlich pro Klasse indiziert, um gleiche Namen in // verschiedenen Klassen unterscheiden zu können. var classIndexes = _groups.GetAll() @@ -92,7 +116,8 @@ public partial class WebUntisHomeworkComparisonViewModel : ObservableObject .GroupBy(g => g.Name, StringComparer.OrdinalIgnoreCase) .ToDictionary( g => g.Key, - g => BuildNameIndex(g.SelectMany(x => _students.GetByGroup(x.Id)).Distinct().ToList()), + g => UntisNameMatching.BuildClassNameIndex(ownStudents, + g.SelectMany(x => _students.GetByGroup(x.Id))), StringComparer.OrdinalIgnoreCase); var shortNameIndex = _subjects.GetAll() .Where(s => !string.IsNullOrWhiteSpace(s.ShortName)) @@ -107,11 +132,39 @@ public partial class WebUntisHomeworkComparisonViewModel : ObservableObject .Where(x => x.Date is not null) .OrderBy(x => x.Date).ThenBy(x => x.Entry.StudentName); + var rowsLoaded = false; + + void RefreshLocalOnlyRows() + { + LocalOnlyRows.Clear(); + var covered = Rows.Where(r => r.AssignedStudent is not null && r.SessionId is not null) + .Select(r => (StudentId: r.AssignedStudent!.Id, SessionId: r.SessionId!.Value)) + .ToHashSet(); + foreach (var student in _students.GetAll(includeInactive: true)) + foreach (var localEntry in _participation.GetByStudent(student.Id)) + { + var homework = HomeworkDisplay.Effective(localEntry); + if (homework is null || !HomeworkDisplay.CountsAsMissing(homework)) continue; + var session = _sessions.GetById(localEntry.SessionId); + if (session is null || session.Date < start || session.Date > end || + covered.Contains((student.Id, session.Id))) continue; + var group = _groups.GetById(session.GroupId); + if (group is null) continue; + var subjectName = group.SubjectId is { } subjectId ? _subjects.GetById(subjectId)?.ShortName : null; + var possibleMatch = Rows.Any(r => r.Date == session.Date && r.SessionId is null && + (r.AssignedStudent?.Id == student.Id || + UntisNameMatching.NamesMatch(r.UntisStudentName, student.FullName))); + LocalOnlyRows.Add(new LocalOnlyHomeworkRow(student.FullName, session.Date, group.Name, + subjectName, homework.Value, possibleMatch)); + } + } + void ResolveLocalMatch(WebUntisHomeworkRow row) { if (row.AssignedStudent is not { } student) { row.SessionId = null; row.LocalStatus = "ohne Zuordnung"; row.Selected = false; + if (rowsLoaded) RefreshLocalOnlyRows(); return; } var group = ResolveGroup(student, row.MatchedSubjectId, row.Date); @@ -123,10 +176,11 @@ public partial class WebUntisHomeworkComparisonViewModel : ObservableObject row.LocalStatus = session is null ? row.MatchedSubjectId is null ? "Fachkürzel nicht erkannt" : group is null ? "kein passender Kurs gefunden" : "keine lokale Stunde" - : HomeworkDisplay.Label(current); + : current is null ? "Noch kein Status erfasst" : HomeworkDisplay.Label(current); // Nur vorbelegen, wenn lokal noch überhaupt nichts erfasst ist - jeder vorhandene // Status (auch ein bereits gesetztes "fehlt") bleibt unangetastet, siehe Klassenkommentar. row.Selected = session is not null && current is null; + if (rowsLoaded) RefreshLocalOnlyRows(); } foreach (var (entry, date) in ordered) @@ -141,6 +195,7 @@ public partial class WebUntisHomeworkComparisonViewModel : ObservableObject var row = new WebUntisHomeworkRow { ClassName = entry.ClassName, Date = date!.Value, UntisStudentName = entry.StudentName, + CategoryName = entry.CategoryName, SubjectLabel = subject?.ShortName, Text = entry.Text, Candidates = ownStudents, MatchedSubjectId = subject?.Id, OnAssignmentChanged = ResolveLocalMatch, }; @@ -148,12 +203,16 @@ public partial class WebUntisHomeworkComparisonViewModel : ObservableObject row.AssignedStudent = match; // löst OnAssignedStudentChanged aus und setzt SessionId/LocalStatus/Selected } + rowsLoaded = true; + RefreshLocalOnlyRows(); + var unresolvedStudent = Rows.Count(x => x.AssignedStudent is null); var unresolvedSubject = Rows.Count(x => x.MatchedSubjectId is null); - Status = $"{Rows.Count} Einträge \"fehlende Hausaufgabe\" erhalten" + + Status = $"{Rows.Count} passende Einträge von {entries.Count} eigenen Klassenbucheinträgen" + (unresolvedStudent > 0 ? $", {unresolvedStudent} bitte manuell zuordnen" : "") + (unresolvedSubject > 0 ? $", bei {unresolvedSubject} kein Fachkürzel im Text erkannt" : "") + - $". {Rows.Count(x => x.CanApply)} einer lokalen Stunde zuordenbar."; + $". {Rows.Count(x => x.CanApply)} einer lokalen Stunde zuordenbar." + + $" {LocalOnlyRows.Count} lokale fehlende Hausaufgaben ohne zugeordneten WebUntis-Eintrag."; } catch (WebUntisIntegrationException ex) { Status = ex.Message; } finally { Busy = false; } @@ -168,11 +227,11 @@ public partial class WebUntisHomeworkComparisonViewModel : ObservableObject var studentId = row.AssignedStudent!.Id; var entry = _participation.GetBySessionAndStudent(row.SessionId!.Value, studentId) ?? new ParticipationEntry { SessionId = row.SessionId.Value, StudentId = studentId }; - entry.Homework = HomeworkStatus.MissingOpen; - entry.HomeworkMissing = true; + entry.Homework = row.SelectedStatus; + entry.HomeworkMissing = HomeworkDisplay.CountsAsMissing(row.SelectedStatus); entry.UpdatedAt = DateTime.UtcNow; _participation.Save(entry); - row.LocalStatus = HomeworkDisplay.Label(HomeworkStatus.MissingOpen); + row.LocalStatus = HomeworkDisplay.Label(row.SelectedStatus); row.Selected = false; } Status = $"{selected.Count} Hausaufgaben-Status übernommen."; @@ -194,12 +253,16 @@ public partial class WebUntisHomeworkComparisonViewModel : ObservableObject return candidates.Count == 1 ? candidates[0] : null; } - // Gleiche Heuristik wie ClassTeacherDetailsViewModel.ContainsHomework, zusätzlich auf negative - // Einträge eingeschränkt (eine positive "Hausaufgabe"-Kategorie wäre kein Fehlen-Signal). - private static bool IsMissingHomeworkEntry(UntisClassRegisterEventDto entry) => - string.Equals(entry.CategoryGroup, "Negativ", StringComparison.OrdinalIgnoreCase) && - (entry.CategoryName?.Contains("Hausauf", StringComparison.OrdinalIgnoreCase) == true || - entry.Text?.Contains("Hausauf", StringComparison.OrdinalIgnoreCase) == true); + // Die Kategorie ist das verlässliche Signal. Der freie Text kann andere Hinweise enthalten und + // wird nur verwendet, wenn WebUntis gar keine Kategorie geliefert hat. "HA" muss ein Wort sein. + public static bool IsMissingHomeworkEntry(UntisClassRegisterEventDto entry) => + string.Equals(entry.CategoryGroup?.Trim(), "Negativ", StringComparison.OrdinalIgnoreCase) && + MentionsHomework(string.IsNullOrWhiteSpace(entry.CategoryName) ? entry.Text : entry.CategoryName); + + private static bool MentionsHomework(string? value) => + value?.Contains("Hausauf", StringComparison.OrdinalIgnoreCase) == true || + (value is not null && Regex.IsMatch(value, @"(? shortNameIndex) { @@ -210,15 +273,6 @@ public partial class WebUntisHomeworkComparisonViewModel : ObservableObject return null; } - // Wie bei den übrigen WebUntis-Abgleichen: Reihenfolge und Satzzeichen werden normalisiert, - // aber nur innerhalb der Kandidaten eindeutige Namen automatisch zugeordnet. - private static Dictionary BuildNameIndex(IReadOnlyList candidates) => - candidates - .Select(student => (Key: UntisNameMatching.NameKey(student.FullName), 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/ViewModels/Students/WebUntisDocumentationComparisonViewModel.cs b/LehrerApp.Desktop/ViewModels/Students/WebUntisDocumentationComparisonViewModel.cs index 430a36c..898eaff 100644 --- a/LehrerApp.Desktop/ViewModels/Students/WebUntisDocumentationComparisonViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Students/WebUntisDocumentationComparisonViewModel.cs @@ -82,7 +82,7 @@ public partial class WebUntisDocumentationComparisonViewModel : ObservableObject try { var ownStudents = _students.GetAll(); - var globalIndex = BuildNameIndex(ownStudents); + var globalIndex = UntisNameMatching.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(); @@ -91,7 +91,8 @@ public partial class WebUntisDocumentationComparisonViewModel : ObservableObject .GroupBy(g => g.Name, StringComparer.OrdinalIgnoreCase) .ToDictionary( g => g.Key, - g => BuildNameIndex(g.SelectMany(x => _students.GetByGroup(x.Id)).Distinct().ToList()), + g => UntisNameMatching.BuildClassNameIndex(ownStudents, + g.SelectMany(x => _students.GetByGroup(x.Id))), StringComparer.OrdinalIgnoreCase); var entries = await _untis.GetOwnClassRegisterEventsAsync(start, end); @@ -173,15 +174,6 @@ public partial class WebUntisDocumentationComparisonViewModel : ObservableObject Status = $"{selected.Count} Einträge aus WebUntis übernommen."; } - // Die gemeinsame Untis-Normalisierung ignoriert Reihenfolge, Komma und abweichende Leerzeichen. - // Mehrdeutige Namen bleiben weiterhin bewusst ohne automatische Vorauswahl. - private static Dictionary BuildNameIndex(IReadOnlyList candidates) => - candidates - .Select(student => (Key: UntisNameMatching.NameKey(student.FullName), 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/Groups/WebUntisHomeworkComparisonDialog.axaml b/LehrerApp.Desktop/Views/Groups/WebUntisHomeworkComparisonDialog.axaml index 842d84b..62ae5e5 100644 --- a/LehrerApp.Desktop/Views/Groups/WebUntisHomeworkComparisonDialog.axaml +++ b/LehrerApp.Desktop/Views/Groups/WebUntisHomeworkComparisonDialog.axaml @@ -4,13 +4,13 @@ xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters" x:Class="LehrerApp.Desktop.Views.Groups.WebUntisHomeworkComparisonDialog" x:DataType="vm:WebUntisHomeworkComparisonViewModel" - Title="Fehlende Hausaufgaben mit WebUntis abgleichen" Width="1100" Height="680" - MinWidth="900" MinHeight="480" WindowStartupLocation="CenterOwner"> - + Title="Fehlende Hausaufgaben mit WebUntis abgleichen" Width="1250" Height="760" + MinWidth="1000" MinHeight="550" WindowStartupLocation="CenterOwner"> + + Text="Eigene negative Klassenbucheinträge mit der Kategorie „Hausaufgabe“ oder „HA“. Der Zielstatus ist vor der Übernahme änderbar. Bereits lokal erfasste Stati bleiben unmarkiert; unten stehen lokale fehlende Hausaufgaben ohne zugeordneten WebUntis-Eintrag."/> @@ -19,20 +19,21 @@