This commit is contained in:
1 parent
960d52686a
commit
1291b24478
11 files changed
+156
-15
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user