From a3cd57e5d84b31cc7287fae216e8b8ebeb3cdfca Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Thu, 8 Oct 2026 11:20:17 +0200 Subject: [PATCH] Fix class teacher full-day absence classification --- LehrerApp.Core/Models/UntisCache.cs | 1 + .../ClassTeacherViewModelsTests.cs | 18 +++++++++ .../UntisReportCacheServiceTests.cs | 39 +++++++++++++++++++ .../Services/UntisReportCacheService.cs | 8 +++- .../WebUntisClientTests.cs | 1 + LehrerApp.WebUntis/WebUntisClient.cs | 2 +- TODO.md | 6 +++ 7 files changed, 73 insertions(+), 2 deletions(-) diff --git a/LehrerApp.Core/Models/UntisCache.cs b/LehrerApp.Core/Models/UntisCache.cs index 341cbd2..aac97d8 100644 --- a/LehrerApp.Core/Models/UntisCache.cs +++ b/LehrerApp.Core/Models/UntisCache.cs @@ -71,6 +71,7 @@ public enum UntisCacheKind { Absences, ClassRegister, Roster } /// public class UntisCacheFetchState { + public int AbsenceReportVersion { get; set; } public Guid Id { get; set; } = Guid.NewGuid(); public string ClassName { get; set; } = ""; public UntisCacheKind Kind { get; set; } diff --git a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs index e377194..6503e91 100644 --- a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs +++ b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs @@ -367,6 +367,24 @@ public sealed class ClassTeacherViewModelsTests Assert.False(andere.CountsAsFullDay); } + [Fact] + public void GroupByStudentAndDay_ZweiFehlstundenBleibenTeilfehlzeit() + { + var entries = new[] + { + new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260824, 1, 45, "HED", "Che", null, + null, 1, null, true, null, 3, "nicht entsch.", false), + new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260824, 1, 45, "PAN", "Kunst", null, + null, 2, null, true, null, 4, "nicht entsch.", false), + }; + + var row = Assert.Single(ClassAbsenceDaySummaryRow.GroupByStudentAndDay(entries)); + + Assert.False(row.CountsAsFullDay); + Assert.True(row.IsPartialAbsence); + Assert.Equal("2 Fehlstunden", row.KindLabel); + } + [Fact] public void GroupByStudentAndDay_TrenntGleichenNamenAnUnterschiedlichenTagen() { diff --git a/LehrerApp.Desktop.Tests/UntisReportCacheServiceTests.cs b/LehrerApp.Desktop.Tests/UntisReportCacheServiceTests.cs index 4a5ddae..d04e54c 100644 --- a/LehrerApp.Desktop.Tests/UntisReportCacheServiceTests.cs +++ b/LehrerApp.Desktop.Tests/UntisReportCacheServiceTests.cs @@ -187,6 +187,45 @@ public sealed class UntisReportCacheServiceTests Assert.Equal(4, handler.Requests.Count); // keine weiteren Anfragen - aus dem Cache bedient } + [Fact] + public async Task GetAbsencesAsync_AlterHistorischerBerichtWirdErsetztUndDanachGecacht() + { + var day = DateOnly.FromDateTime(DateTime.Today).AddDays(-30); + var csv = Encoding.UTF8.GetBytes( + "Schüler*innen\tKlasse\tDatum\tFehlstd.\tFehlmin.\tFehltage\r\n" + + $"Muster Max\t10c\t{day:dd.MM.yy}\t2\t90\t0\r\n"); + var schoolyears = "{\"result\":[{\"id\":7,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"; + var handler = new QueueHandler( + Json("{\"result\":{\"sessionId\":\"s\"}}"), Json(schoolyears), Json(schoolyears), + Json("{\"result\":[{\"id\":874,\"name\":\"10c\"}]}"), + Json("{\"data\":{\"finished\":true,\"error\":false,\"reportParams\":\"get=rpt.tmp&name=AbsencePerStudent&format=csv\"}}"), + new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(csv) }, + Json("{\"result\":{}}")); + await using var untis = new WebUntisIntegrationService(new HttpClient(handler), TestSupport.BuildWebUntisSettingsService()); + await untis.ConnectAsync(new WebUntisCredentials("bk-ostvest", "arche.webuntis.com", "lehrkraft", "geheim")); + var absences = new FakeUntisAbsenceCache(); + absences.InsertRange([new UntisAbsenceCacheEntry + { + ClassName = "10c", StudentName = "Muster Max", Date = Int(day), + AbsentPeriods = 2, CountsAsFullDay = true, + }]); + var states = new FakeUntisCacheFetchStates(); + states.Save(new UntisCacheFetchState + { + ClassName = "10c", Kind = UntisCacheKind.Absences, + HotWindowFetchedAt = DateTime.UtcNow, ColdCoverageStartDate = Int(day), + }); + var cache = new UntisReportCacheService(untis, absences, new FakeUntisClassRegisterCache(), + new FakeUntisStudentRosterCache(), states); + + var first = Assert.Single(await cache.GetAbsencesAsync("10c", day, day)); + Assert.False(first.CountsAsFullDay); + Assert.Equal(2, first.AbsentPeriods); + var requestCount = handler.Requests.Count; + Assert.False(Assert.Single(await cache.GetAbsencesAsync("10c", day, day)).CountsAsFullDay); + Assert.Equal(requestCount, handler.Requests.Count); + } + private static HttpResponseMessage Json(string json) => new(HttpStatusCode.OK) { Content = new StringContent(json, Encoding.UTF8, "application/json"), diff --git a/LehrerApp.Desktop/Services/UntisReportCacheService.cs b/LehrerApp.Desktop/Services/UntisReportCacheService.cs index 87d735b..274c6a7 100644 --- a/LehrerApp.Desktop/Services/UntisReportCacheService.cs +++ b/LehrerApp.Desktop/Services/UntisReportCacheService.cs @@ -34,6 +34,7 @@ public sealed class UntisReportCacheService( { private static readonly TimeSpan HotWindowRefreshInterval = TimeSpan.FromHours(1); private const int HotWindowDays = 14; + private const int AbsenceReportVersion = 1; /// Roster hat anders als Fehlzeiten/Klassenbuch keine Historie (nur "aktueller Stand einer /// Klasse") - deshalb ohne heißes/kaltes Fenster, nur "gilt der letzte Abruf noch als frisch". @@ -66,6 +67,10 @@ public sealed class UntisReportCacheService( DateOnly start, DateOnly end, bool forceRefresh = false, CancellationToken token = default) { var state = fetchState.Get(className, UntisCacheKind.Absences); + // Ältere Berichte haben Tage mit einzelnen Fehlstunden als Fehltage geliefert. + // Auch die dauerhaft gecachte Historie muss mit der Trennung neu geladen werden. + if (state is not null && state.AbsenceReportVersion != AbsenceReportVersion) + state = new UntisCacheFetchState { Id = state.Id, ClassName = className, Kind = UntisCacheKind.Absences }; var plan = Plan(state, start, end, Today(), forceRefresh, DateTime.UtcNow); if (plan.RefreshHotWindow) @@ -76,7 +81,7 @@ public sealed class UntisReportCacheService( if (plan.FetchColdRange) { var older = await untis.GetClassAbsencesAsync(className, plan.ColdRangeStart, plan.ColdRangeEnd, token); - absenceCache.InsertRange(older.Select(ToEntry)); + absenceCache.ReplaceRange(className, Int(plan.ColdRangeStart), Int(plan.ColdRangeEnd), older.Select(ToEntry)); } if (plan.RefreshHotWindow || plan.FetchColdRange) fetchState.Save(NextState(state, className, UntisCacheKind.Absences, plan)); @@ -137,6 +142,7 @@ public sealed class UntisReportCacheService( Id = previous?.Id ?? Guid.NewGuid(), ClassName = className, Kind = kind, + AbsenceReportVersion = kind == UntisCacheKind.Absences ? AbsenceReportVersion : 0, HotWindowFetchedAt = plan.RefreshHotWindow ? DateTime.UtcNow : previous?.HotWindowFetchedAt, ColdCoverageStartDate = plan.ResultingColdCoverageStartDate, }; diff --git a/LehrerApp.WebUntis.Tests/WebUntisClientTests.cs b/LehrerApp.WebUntis.Tests/WebUntisClientTests.cs index 5ebb3c1..b72c587 100644 --- a/LehrerApp.WebUntis.Tests/WebUntisClientTests.cs +++ b/LehrerApp.WebUntis.Tests/WebUntisClientTests.cs @@ -293,6 +293,7 @@ public sealed class WebUntisClientTests Assert.Contains("reports.do?name=ClassregEventPerStudent", handler.Requests[1].Uri); Assert.Contains("klasseOrStudentgroupId=-1", handler.Requests[1].Uri); Assert.Contains("studentId=-1", handler.Requests[1].Uri); + Assert.Contains("&separateAbsentDays=true&", handler.Requests[1].Uri); Assert.Contains("rpt_sd=20260824", handler.Requests[1].Uri); Assert.Contains("rpt_ed=20260828", handler.Requests[1].Uri); Assert.EndsWith("reports.do?get=rpt2.tmp&name=ClassregEventPerStudent&format=csv", handler.Requests[2].Uri); diff --git a/LehrerApp.WebUntis/WebUntisClient.cs b/LehrerApp.WebUntis/WebUntisClient.cs index 3f657c7..f5f911c 100644 --- a/LehrerApp.WebUntis/WebUntisClient.cs +++ b/LehrerApp.WebUntis/WebUntisClient.cs @@ -179,7 +179,7 @@ public sealed partial class WebUntisClient : IAsyncDisposable "&calendarChange=0&withAbsences=true&_withAbsences=on&withLateness=true&_withLateness=on" + "&excludeNotCountingAbsences=true&_excludeNotCountingAbsences=on&_allStudents=on" + "&absStudGroup=1&studExcuseStatusId=-1&_withoutPageBreaksAbsPerStudent=on&absClassGroup=1" + - "&classExcuseStatusId=-1&_absClassSorted=on&_separateAbsentDays=on&_filterAbsencesKlasse=on" + + "&classExcuseStatusId=-1&_absClassSorted=on&separateAbsentDays=true&_separateAbsentDays=on&_filterAbsencesKlasse=on" + "&absSubjectGroup=7&absSubjectId=-1&absSubjectGroupGroup=7&subjectGroupId=-1&_totalDays=on" + "&absentPeriodsLimit=16&reportWeeks=4&dayLimit=3&excuseGroup=1&_groupPerWeek=on" + "&_excuseStatusAll=on&studEventReasonId=-1&classEventReasonId=-1&_evntClassSorted=on" + diff --git a/TODO.md b/TODO.md index 34b92b6..6eed030 100644 --- a/TODO.md +++ b/TODO.md @@ -1621,6 +1621,12 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk Auch hier keine externe Schülerkennung, nur der Name — Namensabgleich soll deshalb zusätzlich auf Klasse+Fach aus der Zeile scopen (→ genau eine lokale Lerngruppe), nicht global über alle Schüler. Abgleichsdialog (Diff gegen `Documentation`, Übernehmen in beide Richtungen) noch nicht gebaut. +- [x] **Klassenlehrer — einzelne Fehlstunden als ganze Fehltage (Oktober 2026):** + Beim Berichtabruf ist `separateAbsentDays=true` jetzt tatsächlich aktiviert; der bisherige + Parameter `_separateAbsentDays=on` allein aktivierte die Checkbox nicht. Alte Fehlzeiten-Caches + werden über eine Berichtversion neu geladen und auch im historischen Bereich ersetzt. + Regressionen prüfen den Abrufparameter, zwei Fehlstunden sowie das Ersetzen alter Cacheeinträge. + - [x] **"Klassenlehrer"-Feature — Datenschicht (August 2026):** Zwei WebUntis-Berichte liefern, was für einen Klassenlehrer-Überblick fehlte. `WebUntisIntegrationService.GetForeignClassRegisterEventsAsync` ist das Spiegelbild von `GetOwnClassRegisterEventsAsync`: derselbe "-alle-"-Rohbericht