Untis-Fehlzeiten und Dashboard-Hinweise verfeinern
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
admin committed 2026-09-25 12:09:32 +02:00
1 parent 960d52686a
commit 1291b24478
11 files changed
+156 -15

No files matched your search

+4 -3
View File
@@ -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; } = "";
}
@@ -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]
@@ -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
}
}
@@ -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);
}
@@ -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<WriteResultDto> 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<AttendanceStatus>(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()!,
};
@@ -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;
}
/// <summary>
/// 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.
/// </summary>
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;
@@ -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;
}
/// <summary>Eine Kind-Gruppe innerhalb der zusammengefassten Handlungsbedarf-Liste. Items behalten
@@ -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<OpenExcuseItem>();
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))
{
@@ -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)
@@ -33,12 +33,17 @@
<Style Selector="Border.severitybar.medium">
<Setter Property="Background" Value="{DynamicResource AppStatusWarningBrush}"/>
</Style>
<Style Selector="Border.severitybar.attendance">
<Setter Property="Width" Value="5"/>
<Setter Property="Background" Value="{DynamicResource AppStatusDangerBrush}"/>
</Style>
<Style Selector="TextBlock.attentionTrailing">
<Setter Property="FontSize" Value="10"/>
<Setter Property="Opacity" Value="0.6"/>
</Style>
<Style Selector="TextBlock.attentionTrailing.warning">
<Setter Property="FontSize" Value="12"/>
<Setter Property="FontSize" Value="16"/>
<Setter Property="FontWeight" Value="Bold"/>
<Setter Property="Opacity" Value="1"/>
<Setter Property="Foreground" Value="{DynamicResource AppStatusDangerBrush}"/>
</Style>
@@ -492,7 +497,8 @@
<Grid ColumnDefinitions="4,*">
<Border Grid.Column="0" Classes="severitybar"
Classes.high="{Binding IsHighSeverity}"
Classes.medium="{Binding IsMediumSeverity}" Margin="0,0,9,0"/>
Classes.medium="{Binding IsMediumSeverity}"
Classes.attendance="{Binding IsAttendanceWarning}" Margin="0,0,9,0"/>
<!-- Bewusst immer als Button (statt je nach IsClickable ein anderes Root-Element):
ein einheitliches Template für alle sieben Arten. Bei Excuse/MissingTeachingTime
ist Navigate null, der Klick auf die Zeile selbst ist dann folgenlos — die
+2 -1
View File
@@ -48,7 +48,8 @@ KEINE Klassen, KEINE Daten) und sollst je Zeile den passendsten internen Status
## Möglicher Zielstatus (exakt einer dieser Schlüssel je Zeile)
- "Present": eigentlich anwesend (z.B. wenn der Freitext klar nahelegt, dass die Fehlzeit ein Irrtum war)
- "Late": Verspätung, kein vollständiges Fehlen der Stunde
- "Late": weniger als 10 Minuten verspätet
- "SignificantlyLate": mindestens 10, aber weniger als 45 Minuten verspätet
- "LeftDuringClass": während des Unterrichts nach Hause entlassen/gegangen
- "ExcusePending": krank gemeldet, Entschuldigung noch offen/unbearbeitet
- "Excused": krank, entschuldigt