diff --git a/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs b/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs index 6a2a50d..e84047c 100644 --- a/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs +++ b/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs @@ -227,8 +227,9 @@ public class AiUntisStatusResponse public class AiUntisStatusSuggestion { public string Id { get; set; } = ""; - /// Einer von "Present"/"Late"/"LeftDuringClass"/"ExcusePending"/"Excused"/"Unexcused" (siehe - /// Systemprompt in ai-backend/untis-status.php) - wird client-seitig gegen genau diese Menge - /// geprüft, bevor er als AttendanceStatus interpretiert wird. + /// Einer von "Present"/"Late"/"SignificantlyLate"/"LeftDuringClass"/"ExcusePending"/ + /// "Excused"/"Unexcused" (siehe Systemprompt in ai-backend/untis-status.php) - wird + /// client-seitig gegen genau diese Menge geprüft, bevor er als AttendanceStatus interpretiert + /// wird. public string Status { get; set; } = ""; } diff --git a/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs b/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs index bfe0aac..32501c3 100644 --- a/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs @@ -89,6 +89,31 @@ public sealed class DashboardViewModelTests private static DateOnly MissingTimeDate(AttentionItem item) => ((MissingTeachingTimeItem)item.Actions.Single().Parameter!).Date; + [Fact] + public void OpenExcuses_ZeigtNurSchuelerAusEigenerKlasse() + { + var today = DateOnly.FromDateTime(DateTime.Today); + var ownClass = new LearningGroup { Name = "Meine 9c", IsOwnClass = true }; + var otherClass = new LearningGroup { Name = "Fremde 8a", IsOwnClass = false }; + var ownStudent = new Student { FirstName = "Anna", LastName = "Eigen" }; + var otherStudent = new Student { FirstName = "Ben", LastName = "Fremd" }; + var ownSession = new ParticipationSession { GroupId = ownClass.Id, Date = today.AddDays(-1) }; + var otherSession = new ParticipationSession { GroupId = otherClass.Id, Date = today.AddDays(-1) }; + var entries = new FakeEntries(); + entries.Add(new ParticipationEntry + { SessionId = ownSession.Id, StudentId = ownStudent.Id, Attendance = AttendanceStatus.ExcusePending }); + entries.Add(new ParticipationEntry + { SessionId = otherSession.Id, StudentId = otherStudent.Id, Attendance = AttendanceStatus.ExcusePending }); + + var vm = BuildVm(ownClass, new Lesson { GroupId = ownClass.Id, Date = today }, + allGroups: [ownClass, otherClass], students: new FakeStudents([ownStudent, otherStudent]), + sessions: new FakeSessions([ownSession, otherSession]), entries: entries); + + var excuse = Assert.Single(Items(vm, AttentionKind.Excuse)); + Assert.Equal(ownStudent.FullName, excuse.Title); + Assert.DoesNotContain(Items(vm, AttentionKind.Excuse), item => item.Title == otherStudent.FullName); + } + private static PeriodScheduleService NewPeriodSchedule() { var tempPath = System.IO.Path.Combine( @@ -780,6 +805,8 @@ public sealed class DashboardViewModelTests var item = Assert.Single(Items(vm, AttentionKind.Attendance)); Assert.Equal(student.FullName, item.Title); Assert.Equal("30 %", item.TrailingText); + Assert.True(item.IsAttendanceWarning); + Assert.Equal(AttentionKind.Attendance, vm.Attention.First().Kind); } [Fact] diff --git a/LehrerApp.Desktop.Tests/UntisComparisonToolsTests.cs b/LehrerApp.Desktop.Tests/UntisComparisonToolsTests.cs index d92353c..8dfb42c 100644 --- a/LehrerApp.Desktop.Tests/UntisComparisonToolsTests.cs +++ b/LehrerApp.Desktop.Tests/UntisComparisonToolsTests.cs @@ -69,4 +69,16 @@ public sealed class UntisComparisonToolsTests Assert.Contains("row-id", result.Message); Assert.Equal(0, confirmation.CallCount); // erst gar nicht bis zur Bestätigung vorgedrungen } + + [Fact] + public async Task ApplyUntisAbsenceStatus_ErheblichVerspaetet_IstZulaessigerZielstatus() + { + var tool = Build(); + + var result = await tool.ApplyUntisAbsenceStatus( + "nie-vergeben", nameof(AttendanceStatus.SignificantlyLate)); + + Assert.False(result.Applied); + Assert.Contains("row-id", result.Message); // Statusvalidierung wurde erfolgreich passiert + } } diff --git a/LehrerApp.Desktop.Tests/UntisLessonAbsenceHelperTests.cs b/LehrerApp.Desktop.Tests/UntisLessonAbsenceHelperTests.cs new file mode 100644 index 0000000..7eb7f3d --- /dev/null +++ b/LehrerApp.Desktop.Tests/UntisLessonAbsenceHelperTests.cs @@ -0,0 +1,61 @@ +using LehrerApp.Core.Models; +using LehrerApp.Desktop.Services; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class UntisLessonAbsenceHelperTests +{ + [Theory] + [InlineData(0, AttendanceStatus.Late)] + [InlineData(9, AttendanceStatus.Late)] + [InlineData(10, AttendanceStatus.SignificantlyLate)] + [InlineData(44, AttendanceStatus.SignificantlyLate)] + public void MapStatus_StaffeltVerspaetungAbZehnMinuten( + int absentMinutes, AttendanceStatus expected) + { + Assert.Equal(expected, UntisLessonAbsenceHelper.MapStatus(Absence(absentMinutes))); + } + + [Fact] + public void MatchesUntisStatus_ErheblichVerspaetetAkzeptiertAllgemeinesUntisVerspaetet() + { + var matches = UntisLessonAbsenceHelper.MatchesUntisStatus( + AttendanceStatus.SignificantlyLate, AttendanceStatus.Late); + + Assert.True(matches); + } + + [Fact] + public void MatchesUntisStatus_NachgereichteEntschuldigungBleibtAenderung() + { + var matches = UntisLessonAbsenceHelper.MatchesUntisStatus( + AttendanceStatus.SignificantlyLate, AttendanceStatus.Excused); + + Assert.False(matches); + } + + [Fact] + public void MatchesUntisStatus_IstFuerVerspaetungsPraezisierungGerichtet() + { + var matches = UntisLessonAbsenceHelper.MatchesUntisStatus( + AttendanceStatus.Late, AttendanceStatus.SignificantlyLate); + + Assert.False(matches); + } + + [Theory] + [InlineData(AttendanceStatus.Late)] + [InlineData(AttendanceStatus.SignificantlyLate)] + [InlineData(AttendanceStatus.ExcusePending)] + [InlineData(AttendanceStatus.Excused)] + [InlineData(AttendanceStatus.Unexcused)] + public void MatchesUntisStatus_GleicherStatusPasst(AttendanceStatus status) + { + Assert.True(UntisLessonAbsenceHelper.MatchesUntisStatus(status, status)); + } + + private static UntisLessonAbsenceDto Absence(int absentMinutes) => new( + "Testschüler", 20260925, 1, 0, absentMinutes, 0, 800, 845, + "Verspätung", 1, false, null, true); +} diff --git a/LehrerApp.Desktop/Services/Mcp/Tools/UntisComparisonTools.cs b/LehrerApp.Desktop/Services/Mcp/Tools/UntisComparisonTools.cs index 33f4f3b..dce5478 100644 --- a/LehrerApp.Desktop/Services/Mcp/Tools/UntisComparisonTools.cs +++ b/LehrerApp.Desktop/Services/Mcp/Tools/UntisComparisonTools.cs @@ -36,7 +36,8 @@ public class UntisComparisonTools( private static readonly AttendanceStatus[] SelectableStatuses = [ AttendanceStatus.ExcusePending, AttendanceStatus.Excused, AttendanceStatus.Unexcused, - AttendanceStatus.Late, AttendanceStatus.LeftDuringClass, AttendanceStatus.Present, + AttendanceStatus.Late, AttendanceStatus.SignificantlyLate, + AttendanceStatus.LeftDuringClass, AttendanceStatus.Present, ]; // In-Memory, pro Prozesslaufzeit - eine row-id aus GetUntisAbsenceRows ist nur bis zum nächsten @@ -102,7 +103,7 @@ public class UntisComparisonTools( [Description("Übernimmt einen Statusvorschlag für eine über get_untis_absence_rows gelieferte row-id in den lokalen Anwesenheitsstatus. Muss der Nutzer erst in einem Dialog in LehrerApp bestätigen; die Bestätigungsmeldung nennt bewusst KEINEN Schülernamen (nur Datum, Lerngruppe, Zielstatus).")] public async Task ApplyUntisAbsenceStatus( [Description("row-id aus get_untis_absence_rows.")] string rowId, - [Description("Zielstatus: Present, Late, LeftDuringClass, ExcusePending, Excused oder Unexcused.")] string status, + [Description("Zielstatus: Present, Late, SignificantlyLate, LeftDuringClass, ExcusePending, Excused oder Unexcused.")] string status, CancellationToken ct = default) { if (!Enum.TryParse(status, out var target) || !SelectableStatuses.Contains(target)) @@ -202,6 +203,7 @@ public class UntisComparisonTools( AttendanceStatus.Excused => "Krank, entschuldigt", AttendanceStatus.Unexcused => "Krank, unentschuldigt", AttendanceStatus.Late => "Verspätet", + AttendanceStatus.SignificantlyLate => "Erheblich verspätet", AttendanceStatus.LeftDuringClass => "Während des Unterrichts abgängig", _ => s.ToString()!, }; diff --git a/LehrerApp.Desktop/Services/UntisLessonAbsenceHelper.cs b/LehrerApp.Desktop/Services/UntisLessonAbsenceHelper.cs index ba2faed..86668dd 100644 --- a/LehrerApp.Desktop/Services/UntisLessonAbsenceHelper.cs +++ b/LehrerApp.Desktop/Services/UntisLessonAbsenceHelper.cs @@ -13,23 +13,41 @@ namespace LehrerApp.Desktop.Services; public static class UntisLessonAbsenceHelper { private const int FullLessonMinutes = 45; + // Bewusst zentral gehalten: Die Einstufung ist nur eine überprüfbare Vorauswahl und kann hier + // leicht an eine geänderte schulische Praxis angepasst werden. + public const int SignificantLatenessThresholdMinutes = 10; // Der Bericht liefert keinen Entschuldigungstext, nur Minutenwerte, ein Bearbeitet-Datum und die // (laut Schule) über Klammerung der ENr codierte Entscheidung des Klassenlehrers - ENr in // Klammern bedeutet unentschuldigt, ohne Klammern abgeschlossen/entschuldigt. Reihenfolge ist // wichtig: "nach Hause entlassen" zählt immer als vorzeitige Entlassung, unabhängig von der // Dauer; darunter zählt jede Fehlzeit unter einer vollen Stunde (45 Min.) immer als Verspätung - // oder sonstiger Teilverlust, nie als komplette Abwesenheit - der Text "Verspätung" allein ist - // laut Schule nicht zuverlässig genug, deshalb primär über die Minutenschwelle erkannt. + // oder sonstiger Teilverlust, nie als komplette Abwesenheit. Ab zehn Minuten wird für die + // überprüfbare Vorauswahl die lokale Präzisierung "erheblich verspätet" verwendet. Der Text + // "Verspätung" allein ist laut Schule nicht zuverlässig genug, deshalb primär über die + // Minutenschwellen erkannt. public static AttendanceStatus MapStatus(UntisLessonAbsenceDto absence) { if (IsEarlyRelease(absence)) return AttendanceStatus.LeftDuringClass; - if (absence.AbsentMinutes < FullLessonMinutes) return AttendanceStatus.Late; + if (absence.AbsentMinutes < SignificantLatenessThresholdMinutes) return AttendanceStatus.Late; + if (absence.AbsentMinutes < FullLessonMinutes) return AttendanceStatus.SignificantlyLate; if (string.IsNullOrWhiteSpace(absence.HandledOn)) return AttendanceStatus.ExcusePending; if (absence.ExternKey is null) return AttendanceStatus.ExcusePending; return absence.ExternKeyInParentheses ? AttendanceStatus.Unexcused : AttendanceStatus.Excused; } + /// + /// Prüft, ob der lokale Status die Aussage des WebUntis-Status bereits erfüllt. WebUntis kennt + /// nur die allgemeine Kategorie "verspätet"; die lokale Kategorie "erheblich verspätet" ist + /// eine bewusst genauere Einstufung und darf deshalb nicht auf "verspätet" zurückgestuft + /// werden. Die Regel ist absichtlich gerichtet: Ergibt die Minutenschwelle umgekehrt eine + /// erhebliche Verspätung, bleibt die Hochstufung von lokal "verspätet" erkennbar. Andere + /// Änderungen (z.B. eine nachgereichte Entschuldigung) bleiben echte Abweichungen. + /// + public static bool MatchesUntisStatus(AttendanceStatus? localStatus, AttendanceStatus untisStatus) => + localStatus == untisStatus || + localStatus == AttendanceStatus.SignificantlyLate && untisStatus == AttendanceStatus.Late; + private static bool IsEarlyRelease(UntisLessonAbsenceDto absence) => absence.Reason?.Contains("entlassen", StringComparison.OrdinalIgnoreCase) == true; diff --git a/LehrerApp.Desktop/ViewModels/AttentionItem.cs b/LehrerApp.Desktop/ViewModels/AttentionItem.cs index da08f01..399c41e 100644 --- a/LehrerApp.Desktop/ViewModels/AttentionItem.cs +++ b/LehrerApp.Desktop/ViewModels/AttentionItem.cs @@ -73,6 +73,7 @@ public sealed class AttentionItem public bool HasSeverity => Severity is not null; public bool IsHighSeverity => Severity == AlertSeverity.High; public bool IsMediumSeverity => Severity == AlertSeverity.Medium; + public bool IsAttendanceWarning => Kind == AttentionKind.Attendance; } /// Eine Kind-Gruppe innerhalb der zusammengefassten Handlungsbedarf-Liste. Items behalten diff --git a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs index fcbec60..804fa9c 100644 --- a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs @@ -119,11 +119,14 @@ public partial class DashboardViewModel : ObservableObject private static readonly (AttentionKind Kind, string Header)[] AttentionGroupOrder = [ (AttentionKind.MissingTeachingTime, "Unterrichtszeit nacherfassen"), + // Die Fehlzeitenquote ist auch in fremden Klassen unmittelbar relevant, waehrend offene + // Entschuldigungen nur fuer die eigene Klasse bearbeitet werden koennen. Deshalb steht sie + // im Dashboard vor den Entschuldigungen statt weiter unten zwischen den sonstigen Hinweisen. + (AttentionKind.Attendance, "Fehlzeiten-Warnung"), (AttentionKind.Excuse, "Offene Entschuldigungen"), (AttentionKind.Correction, "Offene Korrekturen"), (AttentionKind.Unplanned, "Ungeplante Stunden"), (AttentionKind.Alert, "Auffälligkeiten"), - (AttentionKind.Attendance, "Fehlzeiten-Warnung"), (AttentionKind.SupportPlan, "Förderplan-Wiedervorlage"), ]; @@ -837,7 +840,10 @@ public partial class DashboardViewModel : ObservableObject var cutoff = today.AddDays(-OpenExcuseMaxAgeDays); var items = new List(); - foreach (var g in groups) + // Nur in der eigenen Klasse darf die Lehrkraft Entschuldigungen selbst entscheiden. In + // allen anderen Lerngruppen wird der vom Klassenlehrer gesetzte Status lediglich ueber den + // WebUntis-Abgleich uebernommen und gehoert deshalb nicht in den eigenen Handlungsbedarf. + foreach (var g in groups.Where(g => g.IsOwnClass)) { foreach (var session in _participationSessions.GetByGroup(g.Id).Where(s => s.Date >= cutoff && s.Date <= today)) { diff --git a/LehrerApp.Desktop/ViewModels/Groups/WebUntisLessonAbsenceComparisonViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/WebUntisLessonAbsenceComparisonViewModel.cs index 1e84bcc..36e4779 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/WebUntisLessonAbsenceComparisonViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/WebUntisLessonAbsenceComparisonViewModel.cs @@ -45,7 +45,8 @@ public partial class WebUntisLessonAbsenceRow : ObservableObject internal static readonly AttendanceStatus[] SelectableStatuses = [ AttendanceStatus.ExcusePending, AttendanceStatus.Excused, AttendanceStatus.Unexcused, - AttendanceStatus.Late, AttendanceStatus.LeftDuringClass, AttendanceStatus.Present, + AttendanceStatus.Late, AttendanceStatus.SignificantlyLate, + AttendanceStatus.LeftDuringClass, AttendanceStatus.Present, ]; public static string[] StatusOptions { get; } = SelectableStatuses.Select(s => AttendanceDisplay.Label(s)).ToArray(); @@ -145,7 +146,12 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject var entry = session is null ? null : _participation.GetBySessionAndStudent(session.Id, student.Id); row.SessionId = session?.Id; row.LocalStatus = session is null ? "keine lokale Stunde" : AttendanceDisplay.Label(entry?.Attendance); - row.Selected = session is not null && entry?.Attendance != row.TargetStatus; + // WebUntis unterscheidet nicht zwischen "verspätet" und der lokal genaueren + // Einstufung "erheblich verspätet". Eine solche Präzisierung ist daher bereits + // passend und darf nicht automatisch zur Rückstufung markiert werden. Andere + // Statusänderungen (etwa eine nachgereichte Entschuldigung) bleiben markiert. + row.Selected = session is not null && + !UntisLessonAbsenceHelper.MatchesUntisStatus(entry?.Attendance, row.TargetStatus); } foreach (var (absence, date) in ordered) diff --git a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml index 452c79e..82bb904 100644 --- a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml +++ b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml @@ -33,12 +33,17 @@ + @@ -492,7 +497,8 @@ + Classes.medium="{Binding IsMediumSeverity}" + Classes.attendance="{Binding IsAttendanceWarning}" Margin="0,0,9,0"/>