Fix Untis homework comparison and student selection

This commit is contained in:
admin committed 2026-09-25 21:33:54 +02:00
1 parent 1291b24478
commit f3e23ad207
8 files changed
+372 -66

No files matched your search

@@ -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")]);
}
}
@@ -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<HttpResponseMessage> _responses = new(responses);
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken) => Task.FromResult(_responses.Dequeue());
}
}
@@ -1,3 +1,5 @@
using LehrerApp.Core.Models;
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
/// <summary>
@@ -20,4 +22,21 @@ public static class UntisNameMatching
.Where(w => w.Length > 0)
.Select(w => w.ToLowerInvariant())
.OrderBy(w => w, StringComparer.Ordinal));
/// <summary>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.</summary>
public static Dictionary<string, Student> BuildClassNameIndex(IReadOnlyList<Student> candidates,
IEnumerable<Student> classStudents)
{
var classStudentIds = classStudents.Select(s => s.Id).ToHashSet();
return BuildNameIndex(candidates.Where(s => classStudentIds.Contains(s.Id)));
}
public static Dictionary<string, Student> BuildNameIndex(IEnumerable<Student> 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);
}
@@ -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<Student> Candidates { get; init; }
@@ -26,15 +27,39 @@ public partial class WebUntisHomeworkRow : ObservableObject
internal Action<WebUntisHomeworkRow>? 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);
}
/// <summary>Lokal erfasste fehlende Hausaufgabe ohne zugeordnete WebUntis-Zeile. Dient nur als
/// Kopiervorlage; WebUntis wird aus dem Dialog heraus nicht beschrieben.</summary>
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}";
}
/// <summary>Abgleich negativer Klassenbucheinträge zum Stichwort "Hausaufgabe" (eigene, siehe
/// <see cref="WebUntisIntegrationService.GetOwnClassRegisterEventsAsync"/>) gegen
/// <see cref="ParticipationEntry.Homework"/> - 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 <see cref="HomeworkStatus.MissingOpen"/> 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).</summary>
/// Als Ziel ist <see cref="HomeworkStatus.MissingOpen"/> vorbelegt und vor der Übernahme änderbar.
/// Bereits vorhandene lokale Stati werden nicht automatisch zur Übernahme markiert.</summary>
public partial class WebUntisHomeworkComparisonViewModel : ObservableObject
{
private readonly WebUntisIntegrationService _untis;
@@ -59,6 +82,7 @@ public partial class WebUntisHomeworkComparisonViewModel : ObservableObject
private readonly SchoolYearService _schoolYears;
public ObservableCollection<WebUntisHomeworkRow> Rows { get; } = [];
public ObservableCollection<LocalOnlyHomeworkRow> 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, @"(?<![\p{L}\p{Nd}])HA(?![\p{L}\p{Nd}])",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant));
private static Subject? MatchSubject(string? text, IReadOnlyDictionary<string, Subject> 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<string, Student> BuildNameIndex(IReadOnlyList<Student> 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);
}
@@ -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<string, Student> BuildNameIndex(IReadOnlyList<Student> 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);
}
@@ -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">
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto" Margin="24" RowSpacing="8">
Title="Fehlende Hausaufgaben mit WebUntis abgleichen" Width="1250" Height="760"
MinWidth="1000" MinHeight="550" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,Auto,Auto,2*,Auto,Auto,1*,Auto" Margin="24" RowSpacing="8">
<StackPanel Grid.Row="0" Spacing="4">
<TextBlock Text="Fehlende Hausaufgaben mit WebUntis abgleichen" Classes="dialogtitle"/>
<TextBlock TextWrapping="Wrap" FontSize="12" Opacity="0.65"
Text="Nur eigene, negative Klassenbucheinträge zum Stichwort „Hausaufgabe“. Fach wird über das Untis-Fachkürzel im Text erkannt. Zeilen mit bereits vorhandenem lokalem Hausaufgaben-Status (z.B. „Teilweise“, „nachgereicht“) werden nie überschrieben, nur zum Vergleich angezeigt."/>
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."/>
</StackPanel>
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8">
<CalendarDatePicker SelectedDate="{Binding StartDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"/>
@@ -19,20 +19,21 @@
<Button Content="Klassenbucheinträge laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
</StackPanel>
<Grid Grid.Row="2" ColumnDefinitions="Auto,70,60,55,1.1*,1.1*,1.4*,1.2*" ColumnSpacing="8" Margin="4,0">
<Grid Grid.Row="2" ColumnDefinitions="Auto,70,60,60,1*,1*,1.5*,1.1*,1.1*" ColumnSpacing="8" Margin="4,0">
<TextBlock Grid.Column="1" Text="Datum" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="2" Text="Klasse" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="3" Text="Fach" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="4" Text="Name (WebUntis)" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="5" Text="Zuordnung" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="6" Text="Text" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="7" Text="Lokaler Hausaufgaben-Status" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="6" Text="WebUntis-Eintrag" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="7" Text="Übernahme als" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="8" Text="Lokaler Status" FontSize="11" Opacity="0.6"/>
</Grid>
<ScrollViewer Grid.Row="3">
<ItemsControl ItemsSource="{Binding Rows}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:WebUntisHomeworkRow">
<Grid ColumnDefinitions="Auto,70,60,55,1.1*,1.1*,1.4*,1.2*" ColumnSpacing="8" Margin="0,3">
<Grid ColumnDefinitions="Auto,70,60,60,1*,1*,1.5*,1.1*,1.1*" ColumnSpacing="8" Margin="0,3">
<CheckBox Grid.Column="0" IsChecked="{Binding Selected}" IsEnabled="{Binding CanApply}"/>
<TextBlock Grid.Column="1" Text="{Binding DateLabel}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="2" Text="{Binding ClassName}" VerticalAlignment="Center"/>
@@ -41,18 +42,47 @@
<ComboBox Grid.Column="5" ItemsSource="{Binding Candidates}" SelectedItem="{Binding AssignedStudent}"
DisplayMemberBinding="{Binding FullName}" PlaceholderText="Schüler*in wählen…"
HorizontalAlignment="Stretch"/>
<TextBlock Grid.Column="6" Text="{Binding Text}" TextWrapping="Wrap" VerticalAlignment="Center"/>
<TextBlock Grid.Column="7" Text="{Binding LocalStatus}" TextWrapping="Wrap" VerticalAlignment="Center"/>
<StackPanel Grid.Column="6" Spacing="2" VerticalAlignment="Center">
<TextBlock Text="{Binding CategoryName}" FontWeight="SemiBold" TextWrapping="Wrap"/>
<TextBlock Text="{Binding Text}" TextWrapping="Wrap" FontSize="12"/>
</StackPanel>
<ComboBox Grid.Column="7" ItemsSource="{Binding StatusOptions}" SelectedItem="{Binding SelectedStatusName}"
HorizontalAlignment="Stretch" VerticalAlignment="Center"
ToolTip.Tip="{Binding SelectedStatusName}"/>
<TextBlock Grid.Column="8" Text="{Binding LocalStatus}" TextWrapping="Wrap" VerticalAlignment="Center"
ToolTip.Tip="{Binding LocalStatus}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<Grid Grid.Row="4" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
<Grid Grid.Row="4" ColumnDefinitions="*,Auto" ColumnSpacing="8">
<TextBlock Grid.Column="0" Text="{Binding Status}" FontSize="12" TextWrapping="Wrap" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Schließen" Click="OnClose"/>
<Button Grid.Column="2" Content="Markierte übernehmen" Command="{Binding ApplyCommand}" IsEnabled="{Binding !Busy}"/>
<Button Grid.Column="1" Content="Markierte übernehmen" Command="{Binding ApplyCommand}" IsEnabled="{Binding !Busy}"/>
</Grid>
<StackPanel Grid.Row="5" Spacing="2">
<TextBlock Text="Lokal erfasste fehlende Hausaufgaben ohne WebUntis-Gegenstück" FontWeight="SemiBold" FontSize="13"/>
<TextBlock Text="Zum manuellen Nachtragen im Klassenbuch. Bei ungeklärter Fachzuordnung bitte den WebUntis-Eintrag am selben Tag prüfen."
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
</StackPanel>
<ScrollViewer Grid.Row="6">
<ItemsControl ItemsSource="{Binding LocalOnlyRows}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:LocalOnlyHomeworkRow">
<Grid ColumnDefinitions="80,1.3*,1.2*,0.7*,1.6*,1.6*,Auto" ColumnSpacing="8" Margin="0,3">
<TextBlock Grid.Column="0" Text="{Binding DateLabel}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="{Binding StudentName}" TextWrapping="Wrap" VerticalAlignment="Center"/>
<TextBlock Grid.Column="2" Text="{Binding GroupName}" TextWrapping="Wrap" VerticalAlignment="Center"/>
<TextBlock Grid.Column="3" Text="{Binding SubjectName}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="4" Text="{Binding StatusLabel}" TextWrapping="Wrap" VerticalAlignment="Center"/>
<TextBlock Grid.Column="5" Text="{Binding Warning}" TextWrapping="Wrap" Foreground="DarkOrange" VerticalAlignment="Center"/>
<Button Grid.Column="6" Content="In Zwischenablage" Click="OnCopyToClipboardClick"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<Button Grid.Row="7" Content="Schließen" Click="OnClose" HorizontalAlignment="Right"/>
</Grid>
</Window>
@@ -1,5 +1,9 @@
using Avalonia.Controls;
using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Groups;
@@ -8,4 +12,13 @@ public partial class WebUntisHomeworkComparisonDialog : Window
public WebUntisHomeworkComparisonDialog() => InitializeComponent();
private void OnClose(object? sender, RoutedEventArgs e) => Close();
private async void OnCopyToClipboardClick(object? sender, RoutedEventArgs e)
{
if (sender is not Button { DataContext: LocalOnlyHomeworkRow row }) return;
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
if (clipboard is null) return;
await clipboard.SetTextAsync(row.ClipboardText);
App.Services.GetRequiredService<NotificationService>().ShowSuccess("In die Zwischenablage kopiert.");
}
}
+16 -14
View File
@@ -1981,9 +1981,10 @@ Hausaufgabe selbst (`Lesson.Homework`), das bleibt ein offener, separater Punkt.
`WebUntisDocumentationComparisonViewModel` (Dashboard-weit, da der WebUntis-"-alle-"-Bericht
klassenübergreifend ist) und `WebUntisLessonAbsenceComparisonViewModel` (schreibt wie dort über
`IParticipationRepository.GetBySessionAndStudent`/`Save`).
- **Filter:** `CategoryGroup == "Negativ"` und `CategoryName`/`Text` enthält "Hausauf" — dieselbe
Substring-Heuristik wie `ClassTeacherDetailsViewModel.ContainsHomework`, nur zusätzlich auf
negative Einträge eingeschränkt.
- **Filter:** `CategoryGroup == "Negativ"` und `CategoryName` enthält "Hausauf" oder das
eigenständige Kürzel "HA". Nur wenn die Kategorie fehlt, wird der freie Text als Ersatz geprüft.
Das schließt WebUntis-Kategorien wie "Fehlende HA" ein, ohne sonstige negative Einträge wegen
beiläufiger Erwähnungen im freien Text einzubeziehen.
- **Fach-Erkennung:** Der Klassenbuch-Bericht liefert kein zuverlässiges Fach-Feld, aber laut Nutzer
enthält der mitgeschickte Text praktisch immer das Untis-Fachkürzel. Deshalb wird `Text` (Fallback
`CategoryName`) gegen `Subject.ShortName` alle Lerngruppen-Fächer abgeglichen (Wortgrenzen-Split,
@@ -1996,13 +1997,15 @@ Hausaufgabe selbst (`Lesson.Homework`), das bleibt ein offener, separater Punkt.
statt irgendeine zu wählen (gleiches Vorsichtsprinzip wie beim Namensabgleich). Die
`ParticipationSession` der Lerngruppe am Eintragsdatum liefert dann die Stunde für
`IParticipationRepository`.
- **Kein Überschreiben (Kernanforderung):** eine Zeile wird nur vorausgewählt, wenn
`HomeworkDisplay.Effective(entry)` noch `null` ist, also lokal überhaupt noch nichts erfasst wurde
— jeder bereits vorhandene Status (auch ein bereits gesetztes "fehlt", nicht nur "Teilweise"/
"nachgereicht") bleibt unangetastet und wird nur zum Vergleich daneben angezeigt; die Checkbox
bleibt trotzdem bedienbar, falls doch bewusst überschrieben werden soll. Übernommen wird immer nur
`HomeworkStatus.MissingOpen` ("Nachreichen offen") — nie automatisch `MissingOverdue`, die weitere
Nachverfolgung bleibt laut Nutzer App-intern.
- **Übernahme als:** `HomeworkStatus.MissingOpen` ("Nachreichen offen") ist vorbelegt, lässt sich aber
pro Zeile über eine ComboBox auf jeden Hausaufgabenstatus ändern. Eine Zeile wird nur vorausgewählt,
wenn lokal noch kein Status erfasst wurde. Bereits vorhandene Stati bleiben unmarkiert und sichtbar;
nur bewusst markierte Zeilen werden mit dem gewählten Status gespeichert. `HomeworkMissing` wird
passend zum Status gesetzt.
- **Lokal ohne WebUntis-Gegenstück:** Lokal erfasste fehlende Hausaufgaben im geladenen Zeitraum,
denen keine WebUntis-Zeile derselben Schüler*in und lokalen Stunde zugeordnet werden konnte,
erscheinen unten als Kopiervorlage für die manuelle Nacherfassung. Bei ungeklärter Fachzuordnung
eines WebUntis-Eintrags am selben Tag erscheint ein Hinweis zur Prüfung.
- **Bewusst kein Caching:** `GetOwnClassRegisterEventsAsync` wird wie beim bereits bestehenden
`WebUntisDocumentationComparisonViewModel` direkt (ungecacht) aufgerufen statt über
`UntisReportCacheService`, dessen Cache-Tabellen aktuell klassen-/`GetForeignClassRegisterEventsAsync`-
@@ -2010,10 +2013,9 @@ Hausaufgabe selbst (`Lesson.Homework`), das bleibt ein offener, separater Punkt.
eigenständiger, deutlich größerer Umbau gewesen, siehe Nutzer-Rückfrage). Beide "eigene Einträge"-
Dialoge lösen pro Öffnen einen eigenen Live-Abruf aus; falls das in der Praxis stört, wäre eine
gemeinsame Cache-Erweiterung ein sinnvoller separater Nachtrag.
- Aus Konsistenz mit den beiden bestehenden, strukturell identischen Abgleichs-ViewModels bewusst
ungetestet gelassen: `WebUntisIntegrationService` ist `sealed` und nicht auf ein Interface
gemockt, `WebUntisLessonAbsenceComparisonViewModel`/`WebUntisDocumentationComparisonViewModel`
haben aus demselben Grund ebenfalls keine Tests.
- Regressionstests für HA-Kürzel, den Ladevorgang, die Zielstatus-Übernahme und lokale Einträge ohne
WebUntis-Gegenstück mit synthetischen WebUntis-Klassenbuchberichten in
`WebUntisHomeworkComparisonViewModelTests`.
**Nachtrag zu 4.3, Untis-Hub (September 2026):** Nutzer-Feedback: die vier bestehenden WebUntis-
Abgleiche (Fehlzeiten je Lerngruppe, offene Stunden, Klassenbuch-, Hausaufgabenabgleich) liefen