From ba52109eaa06933b991cf066572b99839e3f06f7 Mon Sep 17 00:00:00 2001 From: Baddi86 Date: Thu, 10 Sep 2026 23:41:12 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Untis-Hub=20f=C3=BCr=20WebUntis-Sync-Ge?= =?UTF-8?q?sundheitsstatus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zeigt die Fälligkeit der vier bestehenden, manuell ausgelösten WebUntis-Abgleiche (Fehlzeiten je Lerngruppe, offene Stunden, Klassenbuch-, Hausaufgabenabgleich) in einem neuen Hub-Fenster, ohne selbst WebUntis anzufragen - nur gespeicherte Zeitstempel werden ausgewertet. Konsolidiert die bisher verstreuten Einstiegspunkte (Sidebar-Button, Dashboard-Buttons) in ein neues WebUntis-Menü plus einen kompakten Gesundheits-Indikator auf dem Dashboard. Co-Authored-By: Claude Sonnet 5 --- LehrerApp.Core/Interfaces/IRepositories.cs | 8 ++ LehrerApp.Core/Models/UntisHub.cs | 24 ++++ LehrerApp.Data/LiteDbContext.cs | 1 + .../Repositories/AllRepositories.cs | 10 ++ .../DashboardViewModelTests.cs | 3 +- LehrerApp.Desktop.Tests/Fakes.cs | 21 ++++ .../UntisHubServiceTests.cs | 111 ++++++++++++++++++ LehrerApp.Desktop/AppBootstrapper.cs | 2 + LehrerApp.Desktop/Services/UntisHubActions.cs | 88 ++++++++++++++ LehrerApp.Desktop/Services/UntisHubService.cs | 97 +++++++++++++++ .../ViewModels/DashboardViewModel.cs | 23 ++++ .../ViewModels/UntisHub/UntisHubViewModel.cs | 78 ++++++++++++ .../Views/Dashboard/DashboardView.axaml | 14 ++- .../Views/Dashboard/DashboardView.axaml.cs | 34 +----- LehrerApp.Desktop/Views/MainWindow.axaml | 20 ++-- LehrerApp.Desktop/Views/MainWindow.axaml.cs | 19 ++- .../Views/OpenUntisPeriodsDialog.cs | 4 + .../Views/UntisHub/UntisHubDialog.axaml | 61 ++++++++++ .../Views/UntisHub/UntisHubDialog.axaml.cs | 58 +++++++++ TODO.md | 28 +++++ 20 files changed, 660 insertions(+), 44 deletions(-) create mode 100644 LehrerApp.Core/Models/UntisHub.cs create mode 100644 LehrerApp.Desktop.Tests/UntisHubServiceTests.cs create mode 100644 LehrerApp.Desktop/Services/UntisHubActions.cs create mode 100644 LehrerApp.Desktop/Services/UntisHubService.cs create mode 100644 LehrerApp.Desktop/ViewModels/UntisHub/UntisHubViewModel.cs create mode 100644 LehrerApp.Desktop/Views/UntisHub/UntisHubDialog.axaml create mode 100644 LehrerApp.Desktop/Views/UntisHub/UntisHubDialog.axaml.cs diff --git a/LehrerApp.Core/Interfaces/IRepositories.cs b/LehrerApp.Core/Interfaces/IRepositories.cs index 0c8b83c..f829963 100644 --- a/LehrerApp.Core/Interfaces/IRepositories.cs +++ b/LehrerApp.Core/Interfaces/IRepositories.cs @@ -170,6 +170,14 @@ public interface IUntisCacheFetchStateRepository UntisCacheFetchState? Get(string className, UntisCacheKind kind); void Save(UntisCacheFetchState state); } +/// Zuletzt-Lauf-Status der Untis-Hub-Jobs (siehe TODO.md) - je (Kind, GroupId), GroupId null bei +/// den dashboard-weiten Jobs. +public interface IUntisHubJobStateRepository +{ + UntisHubJobState? Get(UntisHubJobKind kind, Guid? groupId); + List GetAll(); + void Save(UntisHubJobState state); +} /// Vom Nutzer bestätigte Zuordnungen WebUntis-Wochenmuster → LearningGroup. public interface IUntisSlotMappingRepository { diff --git a/LehrerApp.Core/Models/UntisHub.cs b/LehrerApp.Core/Models/UntisHub.cs new file mode 100644 index 0000000..371bb88 --- /dev/null +++ b/LehrerApp.Core/Models/UntisHub.cs @@ -0,0 +1,24 @@ +namespace LehrerApp.Core.Models; + +public enum UntisHubJobKind +{ + FehlzeitenKurz, + FehlzeitenLang, + OffenePeriods, + Klassenbuchabgleich, + Hausaufgabenabgleich, +} + +/// Wann welcher WebUntis-Abgleich ("Untis-Hub"-Job, siehe TODO.md) zuletzt lief und mit +/// welchem Ergebnis - ein Datensatz pro (, ). Bei den drei +/// dashboard-weiten Jobs (, +/// , ) +/// ist null; die beiden Fehlzeiten-Kadenzen sind je Lerngruppe getrennt. +public class UntisHubJobState +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public UntisHubJobKind Kind { get; set; } + public Guid? GroupId { get; set; } + public DateTime? LastRunAt { get; set; } + public string? LastResultSummary { get; set; } +} diff --git a/LehrerApp.Data/LiteDbContext.cs b/LehrerApp.Data/LiteDbContext.cs index d8ecd85..e00646f 100644 --- a/LehrerApp.Data/LiteDbContext.cs +++ b/LehrerApp.Data/LiteDbContext.cs @@ -72,6 +72,7 @@ public class LiteDbContext : IDisposable public ILiteCollection UntisAbsenceCache => _db.GetCollection("untis_absence_cache"); public ILiteCollection UntisClassRegisterCache => _db.GetCollection("untis_classregister_cache"); public ILiteCollection UntisCacheFetchStates => _db.GetCollection("untis_cache_fetch_state"); + public ILiteCollection UntisHubJobStates => _db.GetCollection("untis_hub_job_states"); public ILiteCollection UntisStudentRosterCache => _db.GetCollection("untis_student_roster_cache"); public ILiteCollection AnnualPlanEvents => _db.GetCollection("annual_plan_events"); public ILiteCollection TrashedItems => _db.GetCollection("trash"); diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs index dcf5ff3..88e8637 100644 --- a/LehrerApp.Data/Repositories/AllRepositories.cs +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -899,6 +899,16 @@ public class UntisCacheFetchStateRepository(LiteDbContext db) : IUntisCacheFetch public void Save(UntisCacheFetchState state) => db.UntisCacheFetchStates.Upsert(state); } +public class UntisHubJobStateRepository(LiteDbContext db) : IUntisHubJobStateRepository +{ + public UntisHubJobState? Get(UntisHubJobKind kind, Guid? groupId) => + db.UntisHubJobStates.FindOne(s => s.Kind == kind && s.GroupId == groupId); + + public List GetAll() => db.UntisHubJobStates.FindAll().ToList(); + + public void Save(UntisHubJobState state) => db.UntisHubJobStates.Upsert(state); +} + public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository { public List GetBySubjectAndGrade(Guid subjectId, int gradeLevel) => diff --git a/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs b/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs index 95c1209..79cbc59 100644 --- a/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs @@ -74,7 +74,8 @@ public sealed class DashboardViewModelTests slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(), new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(), schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(), - substitutions ?? new FakeSubstitutionEntries(), timeEntries ?? new FakeTimeEntries(), annualPlanEvents); + substitutions ?? new FakeSubstitutionEntries(), timeEntries ?? new FakeTimeEntries(), + TestSupport.BuildUntisHubService(), TestSupport.BuildWebUntisIntegrationService(), annualPlanEvents); } /// Montag einer Woche, die garantiert in der Zukunft liegt und innerhalb der diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs index 3cc31fc..b9fbf8e 100644 --- a/LehrerApp.Desktop.Tests/Fakes.cs +++ b/LehrerApp.Desktop.Tests/Fakes.cs @@ -39,6 +39,14 @@ public static class TestSupport return new WebUntisSettingsService(tempPath); } + /// Nicht konfiguriert (kein API-Login hinterlegt) - genügt für Tests, die nur den Konstruktor + /// bedienen müssen und keinen echten WebUntis-Zugriff auslösen. + public static WebUntisIntegrationService BuildWebUntisIntegrationService() => + new(new HttpClient(), BuildWebUntisSettingsService()); + + public static UntisHubService BuildUntisHubService(List? groups = null) => + new(new FakeGroups(groups ?? []), new FakeUntisHubJobStates(), new SchoolYearService()); + /// Analog zu den übrigen dateibasierten Feed-Einstellungen: eigenes Temp-Verzeichnis. public static AnnualPlanSettingsService BuildAnnualPlanSettingsService() { @@ -525,6 +533,19 @@ public class FakeUntisCacheFetchStates : IUntisCacheFetchStateRepository } } +public class FakeUntisHubJobStates : IUntisHubJobStateRepository +{ + private readonly List _all = []; + public UntisHubJobState? Get(UntisHubJobKind kind, Guid? groupId) => + _all.FirstOrDefault(s => s.Kind == kind && s.GroupId == groupId); + public List GetAll() => _all.ToList(); + public void Save(UntisHubJobState state) + { + _all.RemoveAll(s => s.Kind == state.Kind && s.GroupId == state.GroupId); + _all.Add(state); + } +} + public class FakeWorkTasks : IWorkTaskRepository { private readonly List _all = []; diff --git a/LehrerApp.Desktop.Tests/UntisHubServiceTests.cs b/LehrerApp.Desktop.Tests/UntisHubServiceTests.cs new file mode 100644 index 0000000..b877f7a --- /dev/null +++ b/LehrerApp.Desktop.Tests/UntisHubServiceTests.cs @@ -0,0 +1,111 @@ +using LehrerApp.Core.Models; +using LehrerApp.Desktop.Services; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class UntisHubServiceTests +{ + private static readonly DateTime UtcNow = new(2026, 9, 10, 8, 0, 0, DateTimeKind.Utc); + + private static LearningGroup Group(string name, int? webUntisLessonId, bool isActive = true) => + new() { Name = name, WebUntisLessonId = webUntisLessonId, IsActive = isActive }; + + [Fact] + public void BuildRows_ProGruppeMitLessonId_ZweiFehlzeitenzeilenPlusDreiGlobaleZeilen() + { + var group = Group("9a", 42); + + var rows = UntisHubService.BuildRows([group], [], UtcNow); + + Assert.Equal(5, rows.Count); + Assert.Equal(2, rows.Count(r => r.GroupId == group.Id)); + Assert.Contains(rows, r => r.Kind == UntisHubJobKind.FehlzeitenKurz && r.GroupId == group.Id); + Assert.Contains(rows, r => r.Kind == UntisHubJobKind.FehlzeitenLang && r.GroupId == group.Id); + Assert.Contains(rows, r => r.Kind == UntisHubJobKind.OffenePeriods && r.GroupId == null); + Assert.Contains(rows, r => r.Kind == UntisHubJobKind.Klassenbuchabgleich && r.GroupId == null); + Assert.Contains(rows, r => r.Kind == UntisHubJobKind.Hausaufgabenabgleich && r.GroupId == null); + } + + [Fact] + public void BuildRows_NieGeprueft_GiltAlsUeberfaellig() + { + var rows = UntisHubService.BuildRows([], [], UtcNow); + + Assert.All(rows, r => Assert.Equal(UntisHubDueState.Overdue, r.DueState)); + } + + [Fact] + public void BuildRows_FehlzeitenKurzVorSechsTagen_GiltAlsOk() + { + var group = Group("9a", 42); + var state = new UntisHubJobState + { + Kind = UntisHubJobKind.FehlzeitenKurz, GroupId = group.Id, LastRunAt = UtcNow.AddDays(-6), + }; + + var rows = UntisHubService.BuildRows([group], [state], UtcNow); + + var row = rows.Single(r => r.Kind == UntisHubJobKind.FehlzeitenKurz); + Assert.Equal(UntisHubDueState.Ok, row.DueState); + } + + [Fact] + public void BuildRows_FehlzeitenKurzVorSechzehnTagen_GiltAlsFaellig() + { + var group = Group("9a", 42); + var state = new UntisHubJobState + { + Kind = UntisHubJobKind.FehlzeitenKurz, GroupId = group.Id, LastRunAt = UtcNow.AddDays(-16), + }; + + var rows = UntisHubService.BuildRows([group], [state], UtcNow); + + var row = rows.Single(r => r.Kind == UntisHubJobKind.FehlzeitenKurz); + Assert.Equal(UntisHubDueState.Due, row.DueState); + } + + [Fact] + public void BuildRows_FehlzeitenKurzVorZweiundzwanzigTagen_GiltAlsUeberfaellig() + { + var group = Group("9a", 42); + var state = new UntisHubJobState + { + Kind = UntisHubJobKind.FehlzeitenKurz, GroupId = group.Id, LastRunAt = UtcNow.AddDays(-22), + }; + + var rows = UntisHubService.BuildRows([group], [state], UtcNow); + + var row = rows.Single(r => r.Kind == UntisHubJobKind.FehlzeitenKurz); + Assert.Equal(UntisHubDueState.Overdue, row.DueState); + } + + [Fact] + public void BuildRows_FehlzeitenLangHatDeutlichLaengereKadenzAlsKurz() + { + var group = Group("9a", 42); + var lastRunAt = UtcNow.AddDays(-40); + var states = new List + { + new() { Kind = UntisHubJobKind.FehlzeitenKurz, GroupId = group.Id, LastRunAt = lastRunAt }, + new() { Kind = UntisHubJobKind.FehlzeitenLang, GroupId = group.Id, LastRunAt = lastRunAt }, + }; + + var rows = UntisHubService.BuildRows([group], states, UtcNow); + + Assert.Equal(UntisHubDueState.Overdue, rows.Single(r => r.Kind == UntisHubJobKind.FehlzeitenKurz).DueState); + Assert.Equal(UntisHubDueState.Ok, rows.Single(r => r.Kind == UntisHubJobKind.FehlzeitenLang).DueState); + } + + [Fact] + public void BuildRows_GruppeOhneWebUntisLessonId_WirdVonAufrufseiteAusgeschlossen() + { + // GetRows() filtert vorab auf WebUntisLessonId != null (siehe UntisHubService.GetRows) - + // BuildRows selbst bekommt bereits nur die eligible-Gruppen übergeben. + var eligible = new List { Group("9a", 42) }; + + var rows = UntisHubService.BuildRows(eligible, [], UtcNow); + + Assert.Equal(2, rows.Count(r => r.GroupId != null)); + } +} diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index ebac69d..35b4e74 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -185,6 +185,7 @@ public static class AppBootstrapper services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // ── Services ────────────────────────────────────────────────────────── services.AddSingleton(); @@ -248,6 +249,7 @@ public static class AppBootstrapper services.AddSingleton(sp => new SchoolWeatherService(new HttpClient(), syncSettings)); services.AddSingleton(sp => new WebUntisIntegrationService(new HttpClient(), untisSettings)); services.AddSingleton(); + services.AddSingleton(); // War dieses Gerät schon eingeloggt, aber sync.key fehlt(e), wurde gerade eben (unten) // stillschweigend ein neuer, unabhängiger Schlüssel erzeugt - bisher unter dem ALTEN // Schlüssel synchronisierte Server-Daten sind für dieses Gerät dann nicht mehr lesbar. diff --git a/LehrerApp.Desktop/Services/UntisHubActions.cs b/LehrerApp.Desktop/Services/UntisHubActions.cs new file mode 100644 index 0000000..8ead1f9 --- /dev/null +++ b/LehrerApp.Desktop/Services/UntisHubActions.cs @@ -0,0 +1,88 @@ +using Avalonia.Controls; +using CommunityToolkit.Mvvm.ComponentModel; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using LehrerApp.Desktop.ViewModels.Groups; +using LehrerApp.Desktop.ViewModels.Students; +using LehrerApp.Desktop.Views; +using LehrerApp.Desktop.Views.Groups; +using LehrerApp.Desktop.Views.Students; +using Microsoft.Extensions.DependencyInjection; + +namespace LehrerApp.Desktop.Services; + +/// Führt einen der vier bestehenden WebUntis-Abgleiche (unverändert, über ihre bestehenden +/// Dialoge) aus und vermerkt das Ergebnis im - genutzt sowohl vom +/// Untis-Hub-Dialog ("Jetzt prüfen" je Zeile) als auch von den WebUntis-Menüpunkten in +/// MainWindow, damit beide Einstiegspunkte denselben Fälligkeitsstand pflegen. +public static class UntisHubActions +{ + public static async Task RunFehlzeitenAsync(Window owner, LearningGroup group, UntisHubJobKind kind, + DateOnly start, DateOnly end, UntisHubService hub) + { + var vm = new WebUntisLessonAbsenceComparisonViewModel(group, + App.Services.GetRequiredService(), + App.Services.GetRequiredService(), + App.Services.GetRequiredService(), + App.Services.GetRequiredService()) + { StartDate = start.ToDateTime(TimeOnly.MinValue), EndDate = end.ToDateTime(TimeOnly.MinValue) }; + var loaded = TrackLoad(vm, v => v.Busy); + await new WebUntisLessonAbsenceComparisonDialog { DataContext = vm }.ShowDialog(owner); + if (loaded()) hub.RecordRun(kind, group.Id, vm.Status); + } + + public static async Task RunKlassenbuchAsync(Window owner, UntisHubService hub) + { + var vm = new WebUntisDocumentationComparisonViewModel( + App.Services.GetRequiredService(), + App.Services.GetRequiredService(), + App.Services.GetRequiredService(), + App.Services.GetRequiredService(), + App.Services.GetRequiredService()); + var loaded = TrackLoad(vm, v => v.Busy); + await new WebUntisDocumentationComparisonDialog { DataContext = vm }.ShowDialog(owner); + if (loaded()) hub.RecordRun(UntisHubJobKind.Klassenbuchabgleich, null, vm.Status); + } + + public static async Task RunHausaufgabenAsync(Window owner, UntisHubService hub) + { + var vm = new WebUntisHomeworkComparisonViewModel( + App.Services.GetRequiredService(), + App.Services.GetRequiredService(), + App.Services.GetRequiredService(), + App.Services.GetRequiredService(), + App.Services.GetRequiredService(), + App.Services.GetRequiredService(), + App.Services.GetRequiredService(), + App.Services.GetRequiredService()); + var loaded = TrackLoad(vm, v => v.Busy); + await new WebUntisHomeworkComparisonDialog { DataContext = vm }.ShowDialog(owner); + if (loaded()) hub.RecordRun(UntisHubJobKind.Hausaufgabenabgleich, null, vm.Status); + } + + public static async Task RunOffenePeriodsAsync(Window owner, UntisHubService hub) + { + var dialog = new OpenUntisPeriodsDialog(); + await dialog.ShowDialog(owner); + hub.RecordRun(UntisHubJobKind.OffenePeriods, null, dialog.LastStatus); + } + + /// Erkennt nicht-invasiv (ohne die Vergleichs-ViewModels zu ändern), ob im Dialog + /// tatsächlich ein Ladeversuch stattfand: Busy wechselt in Load() immer erst auf + /// true und im finally-Block zurück auf false, egal ob erfolgreich oder mit Fehler + /// abgebrochen - genau dieser Übergang wird hier beobachtet. + private static Func TrackLoad(T vm, Func isBusy) where T : ObservableObject + { + var loaded = false; + var wasBusy = false; + vm.PropertyChanged += (_, e) => + { + if (e.PropertyName != "Busy") return; + var busy = isBusy(vm); + if (wasBusy && !busy) loaded = true; + wasBusy = busy; + }; + return () => loaded; + } +} diff --git a/LehrerApp.Desktop/Services/UntisHubService.cs b/LehrerApp.Desktop/Services/UntisHubService.cs new file mode 100644 index 0000000..0285d79 --- /dev/null +++ b/LehrerApp.Desktop/Services/UntisHubService.cs @@ -0,0 +1,97 @@ +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; + +namespace LehrerApp.Desktop.Services; + +public enum UntisHubDueState { Ok, Due, Overdue } + +/// Eine Zeile im Untis-Hub: eine Job-Instanz (Fehlzeiten-Kadenz je Lerngruppe, oder einer +/// der drei dashboard-weiten Jobs) mit ihrer aktuellen Fälligkeit. +public sealed record UntisHubJobRow( + UntisHubJobKind Kind, Guid? GroupId, string GroupName, + DateTime? LastRunAt, string? LastResultSummary, UntisHubDueState DueState, string DueLabel); + +/// +/// Zeigt, welche der bestehenden, rein manuell ausgelösten WebUntis-Abgleiche (Fehlzeiten pro +/// Lerngruppe, offene Stunden, Klassenbuch-/Hausaufgabenabgleich) fällig sind - ohne selbst +/// WebUntis anzufragen (Nutzer-Feedback: "nicht bei WebUntis auffallen", siehe +/// ). Die eigentlichen Abgleiche laufen weiterhin über die +/// bestehenden Vergleichsdialoge (siehe ); dieser Service verwaltet +/// nur die Fälligkeits-Zeitstempel dazu. +/// +public sealed class UntisHubService( + IGroupRepository groups, IUntisHubJobStateRepository jobStates, SchoolYearService schoolYears) +{ + private static readonly TimeSpan FehlzeitenKurzDue = TimeSpan.FromDays(14); + private static readonly TimeSpan FehlzeitenKurzOverdue = TimeSpan.FromDays(21); + private static readonly TimeSpan FehlzeitenLangDue = TimeSpan.FromDays(60); + private static readonly TimeSpan FehlzeitenLangOverdue = TimeSpan.FromDays(90); + private static readonly TimeSpan GlobalDue = TimeSpan.FromDays(14); + private static readonly TimeSpan GlobalOverdue = TimeSpan.FromDays(21); + + private static readonly (UntisHubJobKind Kind, string Label)[] GlobalJobs = + [ + (UntisHubJobKind.OffenePeriods, "Offene Stunden"), + (UntisHubJobKind.Klassenbuchabgleich, "Klassenbuchabgleich"), + (UntisHubJobKind.Hausaufgabenabgleich, "Hausaufgabenabgleich"), + ]; + + public List GetRows() + { + var eligibleGroups = groups.GetBySchoolYear(schoolYears.CurrentSchoolYear()) + .Where(g => g.WebUntisLessonId is not null) + .OrderBy(g => g.Name) + .ToList(); + return BuildRows(eligibleGroups, jobStates.GetAll(), DateTime.UtcNow); + } + + public void RecordRun(UntisHubJobKind kind, Guid? groupId, string? summary) => + jobStates.Save(new UntisHubJobState + { + Id = jobStates.Get(kind, groupId)?.Id ?? Guid.NewGuid(), + Kind = kind, GroupId = groupId, LastRunAt = DateTime.UtcNow, LastResultSummary = summary, + }); + + /// Reine Entscheidungslogik ohne Repository-Zugriff (gleiches Muster wie + /// ): aus den fälligkeitsrelevanten Lerngruppen und den + /// zuletzt gespeicherten Job-Zuständen wird die vollständige Hub-Zeilenliste gebaut - zwei + /// Fehlzeiten-Zeilen je Gruppe plus die drei dashboard-weiten Zeilen. + public static List BuildRows( + IReadOnlyList eligibleGroups, IReadOnlyList states, DateTime utcNow) + { + UntisHubJobState? State(UntisHubJobKind kind, Guid? groupId) => + states.FirstOrDefault(s => s.Kind == kind && s.GroupId == groupId); + + var rows = new List(); + foreach (var group in eligibleGroups) + { + rows.Add(Row(UntisHubJobKind.FehlzeitenKurz, group.Id, group.Name, + State(UntisHubJobKind.FehlzeitenKurz, group.Id), FehlzeitenKurzDue, FehlzeitenKurzOverdue, utcNow)); + rows.Add(Row(UntisHubJobKind.FehlzeitenLang, group.Id, group.Name, + State(UntisHubJobKind.FehlzeitenLang, group.Id), FehlzeitenLangDue, FehlzeitenLangOverdue, utcNow)); + } + foreach (var (kind, label) in GlobalJobs) + rows.Add(Row(kind, null, label, State(kind, null), GlobalDue, GlobalOverdue, utcNow)); + return rows; + } + + private static UntisHubJobRow Row(UntisHubJobKind kind, Guid? groupId, string groupName, + UntisHubJobState? state, TimeSpan due, TimeSpan overdue, DateTime utcNow) + { + var (dueState, dueLabel) = DueStatus(state?.LastRunAt, due, overdue, utcNow); + return new UntisHubJobRow(kind, groupId, groupName, state?.LastRunAt, state?.LastResultSummary, + dueState, dueLabel); + } + + private static (UntisHubDueState, string) DueStatus( + DateTime? lastRunAt, TimeSpan due, TimeSpan overdue, DateTime utcNow) + { + if (lastRunAt is null) return (UntisHubDueState.Overdue, "noch nie geprüft"); + var age = utcNow - lastRunAt.Value; + var days = (int)age.TotalDays; + if (age >= overdue) return (UntisHubDueState.Overdue, $"fällig seit {days} Tagen"); + if (age >= due) return (UntisHubDueState.Due, $"fällig seit {days} Tagen"); + return (UntisHubDueState.Ok, days == 0 ? "gerade eben geprüft" : $"vor {days} Tag(en) geprüft"); + } +} diff --git a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs index c93f237..4e8ec57 100644 --- a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs @@ -38,6 +38,8 @@ public partial class DashboardViewModel : ObservableObject private readonly ITimeEntryRepository _timeEntries; private readonly IAnnualPlanEventRepository? _annualPlanEvents; private readonly SchoolWeatherService? _schoolWeather; + private readonly UntisHubService _untisHub; + private readonly WebUntisIntegrationService _webUntis; private const int OpenExcuseMaxAgeDays = 21; private const int SupportPlanDueWithinDays = 14; @@ -143,6 +145,10 @@ public partial class DashboardViewModel : ObservableObject public string AttentionSummary => AttentionCount == 1 ? "1 offener Punkt" : $"{AttentionCount} offene Punkte"; public string UpcomingSummary => UpcomingCount == 1 ? "1 Termin" : $"{UpcomingCount} Termine"; + [ObservableProperty] private string _webUntisHealthLabel = ""; + [ObservableProperty] private bool _isWebUntisHealthWarning; + [ObservableProperty] private bool _isWebUntisHealthVisible; + public DashboardViewModel(IGroupRepository groups, ISubjectRepository subjects, ILessonRepository lessons, IExamRepository exams, IExamResultRepository examResults, IGradeRepository grades, IReportGradeRepository reportGrades, IGroupMembershipRepository memberships, @@ -153,6 +159,7 @@ public partial class DashboardViewModel : ObservableObject DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays, PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings, ISubstitutionEntryRepository substitutions, ITimeEntryRepository timeEntries, + UntisHubService untisHub, WebUntisIntegrationService webUntis, IAnnualPlanEventRepository? annualPlanEvents = null, AnnualPlanSyncService? annualPlanSync = null, SchoolWeatherService? schoolWeather = null) { @@ -167,12 +174,28 @@ public partial class DashboardViewModel : ObservableObject _substitutions = substitutions; _annualPlanEvents = annualPlanEvents; _schoolWeather = schoolWeather; + _untisHub = untisHub; + _webUntis = webUntis; if (annualPlanSync is not null) { annualPlanSync.DataChanged += () => Avalonia.Threading.Dispatcher.UIThread.Post(LoadCalendar); } LoadDashboardCards(); Load(); + RefreshWebUntisHealth(); + } + + /// Liest nur den gespeicherten Fälligkeitsstand der Untis-Hub-Jobs (kein + /// WebUntis-Zugriff, siehe ) - aufgerufen bei jedem + /// Dashboard-Refresh und erneut, nachdem der Nutzer den Hub geöffnet/einen Abgleich gemacht hat. + public void RefreshWebUntisHealth() + { + IsWebUntisHealthVisible = _webUntis.IsAvailable; + if (!IsWebUntisHealthVisible) return; + var rows = _untisHub.GetRows(); + var due = rows.Count(r => r.DueState != UntisHubDueState.Ok); + IsWebUntisHealthWarning = due > 0; + WebUntisHealthLabel = due > 0 ? $"WebUntis ⚠ {due} fällig" : "WebUntis ✓"; } private DashboardCardOption Card(string key) => DashboardCards.First(c => c.Key == key); diff --git a/LehrerApp.Desktop/ViewModels/UntisHub/UntisHubViewModel.cs b/LehrerApp.Desktop/ViewModels/UntisHub/UntisHubViewModel.cs new file mode 100644 index 0000000..1b6341a --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/UntisHub/UntisHubViewModel.cs @@ -0,0 +1,78 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using LehrerApp.Desktop.Services; + +namespace LehrerApp.Desktop.ViewModels.UntisHub; + +/// Eine Zeile im Untis-Hub-Fenster - reine Anzeige-Projektion von +/// , neu aufgebaut bei jedem . +public sealed class UntisHubRowViewModel +{ + public UntisHubJobKind Kind { get; } + public Guid? GroupId { get; } + public string GroupName { get; } + public string JobLabel { get; } + public string DueLabel { get; } + public string? LastResultSummary { get; } + public bool IsWarning { get; } + public bool IsDanger { get; } + + public UntisHubRowViewModel(UntisHubJobRow row) + { + Kind = row.Kind; GroupId = row.GroupId; GroupName = row.GroupName; + JobLabel = Label(row.Kind); DueLabel = row.DueLabel; LastResultSummary = row.LastResultSummary; + IsWarning = row.DueState == UntisHubDueState.Due; + IsDanger = row.DueState == UntisHubDueState.Overdue; + } + + private static string Label(UntisHubJobKind kind) => kind switch + { + UntisHubJobKind.FehlzeitenKurz => "Fehlzeiten (kurzfristig)", + UntisHubJobKind.FehlzeitenLang => "Fehlzeiten (seit Schuljahresbeginn)", + UntisHubJobKind.OffenePeriods => "Offene Stunden", + UntisHubJobKind.Klassenbuchabgleich => "Klassenbuchabgleich", + UntisHubJobKind.Hausaufgabenabgleich => "Hausaufgabenabgleich", + _ => kind.ToString(), + }; +} + +/// ViewModel des Untis-Hub-Fensters (siehe TODO.md) - zeigt nur den gespeicherten +/// Fälligkeitsstand an (, rein lesend aus LiteDB). Das +/// tatsächliche Ausführen eines Jobs (inkl. WebUntis-Anfrage) übernimmt die Code-Behind-Klasse über +/// , weil dafür ein Fenster-Owner für ShowDialog gebraucht wird. +public partial class UntisHubViewModel : ObservableObject +{ + private readonly UntisHubService _hub; + private readonly IGroupRepository _groups; + + public ObservableCollection Rows { get; } = []; + [ObservableProperty] private bool _isAvailable; + [ObservableProperty] private string _status = ""; + + public UntisHubViewModel(UntisHubService hub, IGroupRepository groups, WebUntisIntegrationService untis) + { + _hub = hub; _groups = groups; + IsAvailable = untis.IsAvailable; + Load(); + } + + public void Load() + { + Rows.Clear(); + if (!IsAvailable) + { + Status = "WebUntis ist nicht konfiguriert (siehe Einstellungen)."; + return; + } + foreach (var row in _hub.GetRows()) Rows.Add(new UntisHubRowViewModel(row)); + var overdue = Rows.Count(r => r.IsDanger); + var due = Rows.Count(r => r.IsWarning); + Status = overdue > 0 || due > 0 + ? $"{overdue + due} von {Rows.Count} Prüfungen fällig ({overdue} überfällig)." + : $"Alle {Rows.Count} Prüfungen aktuell."; + } + + public LearningGroup? FindGroup(Guid id) => _groups.GetById(id); +} diff --git a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml index 3c4122f..2eb49df 100644 --- a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml +++ b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml @@ -9,6 +9,12 @@ + + + + + + + + + + +