From 22eceb3ac138cd8383b9476df606ca27b4387acb Mon Sep 17 00:00:00 2001 From: Baddi86 Date: Sun, 20 Sep 2026 00:03:45 +0200 Subject: [PATCH] fix: Kompetenzkataloge im Sync nachreichen --- LehrerApp.Desktop/AppBootstrapper.cs | 8 ++- .../ViewModels/MainWindowViewModel.cs | 2 + LehrerApp.Sync.Tests/EventApplierTests.cs | 48 ++++++++++++++ .../SyncEventPublisherTests.cs | 33 ++++++++++ LehrerApp.Sync/EventApplier.cs | 66 ++++++++++++++++++- LehrerApp.Sync/EventQueue.cs | 6 ++ LehrerApp.Sync/SyncEventPublisher.cs | 29 ++++++++ 7 files changed, 188 insertions(+), 4 deletions(-) diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index 7805dbc..a7df39d 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -375,8 +375,12 @@ public static class AppBootstrapper // Sync-agnostischer Hook auf LiteDbContext (siehe LiteDbContext.OnChange) wird erst hier, // außerhalb der Repository-Registrierung, mit der tatsächlichen Sync-Logik verbunden. if (!string.IsNullOrEmpty(serverUrl)) - provider.GetRequiredService().OnChange = - provider.GetRequiredService().Publish; + { + var context = provider.GetRequiredService(); + var publisher = provider.GetRequiredService(); + context.OnChange = publisher.Publish; + publisher.BackfillCompetencyCatalog(context); + } return provider; } diff --git a/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs b/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs index 499a2be..394fff2 100644 --- a/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs @@ -100,6 +100,8 @@ public partial class MainWindowViewModel : ObservableObject case ClassTeacherOverviewViewModel vm: vm.RefreshFromLocalDataOnly(); break; case ExamsOverviewViewModel vm: vm.LoadCommand.Execute(null); break; case GroupDetailViewModel { Group: { } group } vm: vm.LoadGroup(group.Id); break; + case SettingsViewModel vm when vm.ActiveTabIndex == (int)SettingsTab.Competencies: + vm.RefreshCompetencyCatalogCommand.Execute(null); break; // Inline-Bearbeitung (IsEditing) nicht überschreiben - anders als die Gruppenansicht // laufen Namens-/Geschlechtsänderungen hier nicht über einen Dialog. case StudentDetailViewModel { Student: { } student, IsEditing: false } vm: diff --git a/LehrerApp.Sync.Tests/EventApplierTests.cs b/LehrerApp.Sync.Tests/EventApplierTests.cs index 118b9e8..c807fe4 100644 --- a/LehrerApp.Sync.Tests/EventApplierTests.cs +++ b/LehrerApp.Sync.Tests/EventApplierTests.cs @@ -52,6 +52,54 @@ public sealed class EventApplierTests Assert.Equal(UntisHubJobKind.OffenePeriods, saved!.Kind); } + [Fact] + public async Task ApplyAsync_CompetencyDomainSave_UebernimmtVerschachtelteKompetenzen() + { + using var db = NewInMemoryContext(); + var applier = new EventApplier(db, Key); + var domain = new CompetencyDomain + { + SubjectId = Guid.NewGuid(), GradeLevel = 10, VersionId = Guid.NewGuid(), + Name = "Erkenntnisgewinnung", + Items = [new CompetencyItem { Code = "EG1", Description = "Modelle verwenden" }], + }; + + await applier.ApplyAsync(MakeEvent(nameof(CompetencyDomain), domain.Id.ToString(), "Save", domain)); + + var saved = db.CompetencyDomains.FindById(domain.Id); + Assert.NotNull(saved); + Assert.Equal("EG1", Assert.Single(saved!.Items).Code); + } + + [Fact] + public async Task ApplyAsync_CompetencyDomainSave_BereinigtDoppelteMigrierteStandardversionen() + { + using var db = NewInMemoryContext(); + var applier = new EventApplier(db, Key); + var subjectId = Guid.NewGuid(); + var emptyVersion = new CompetencyCatalogVersion + { + SubjectId = subjectId, Name = "Bestehender Kompetenzkatalog", IsDefault = true, + }; + var catalogVersion = new CompetencyCatalogVersion + { + SubjectId = subjectId, Name = "Bestehender Kompetenzkatalog", IsDefault = true, + }; + db.CompetencyCatalogVersions.Insert(emptyVersion); + db.CompetencyCatalogVersions.Insert(catalogVersion); + var domain = new CompetencyDomain + { + SubjectId = subjectId, GradeLevel = 10, VersionId = catalogVersion.Id, + Name = "Erkenntnisgewinnung", + }; + + await applier.ApplyAsync(MakeEvent(nameof(CompetencyDomain), domain.Id.ToString(), "Save", domain)); + + var versions = db.CompetencyCatalogVersions.Find(v => v.SubjectId == subjectId).ToList(); + Assert.True(versions.Single(v => v.Id == catalogVersion.Id).IsDefault); + Assert.False(versions.Single(v => v.Id == emptyVersion.Id).IsDefault); + } + [Fact] public async Task ApplyAsync_UnbekannterEntityType_TutNichtsUndWirftNicht() { diff --git a/LehrerApp.Sync.Tests/SyncEventPublisherTests.cs b/LehrerApp.Sync.Tests/SyncEventPublisherTests.cs index 89e0098..61f55cf 100644 --- a/LehrerApp.Sync.Tests/SyncEventPublisherTests.cs +++ b/LehrerApp.Sync.Tests/SyncEventPublisherTests.cs @@ -1,4 +1,5 @@ using LehrerApp.Core.Models; +using LehrerApp.Data; using LehrerApp.Sync.Crypto; using Xunit; @@ -49,6 +50,38 @@ public sealed class SyncEventPublisherTests Assert.Empty(temp.Queue.GetPendingAttachmentUploads()); } + [Fact] + public void BackfillCompetencyCatalog_ReihtVersionUndBereichMitItemsGenauEinmalEin() + { + using var temp = new TempEventQueue(); + using var db = new LiteDbContext(new MemoryStream()); + var subjectId = Guid.NewGuid(); + var version = new CompetencyCatalogVersion + { + SubjectId = subjectId, Name = "Bildungsplan 2026", IsDefault = true, + }; + var item = new CompetencyItem { Code = "EG1", Description = "Modelle verwenden" }; + var domain = new CompetencyDomain + { + SubjectId = subjectId, GradeLevel = 10, VersionId = version.Id, + Name = "Erkenntnisgewinnung", Items = [item], + }; + db.CompetencyCatalogVersions.Insert(version); + db.CompetencyDomains.Insert(domain); + var publisher = new SyncEventPublisher(temp.Queue, "desktop-1", Key); + + Assert.Equal(2, publisher.BackfillCompetencyCatalog(db)); + Assert.Equal(0, publisher.BackfillCompetencyCatalog(db)); + + var events = temp.Queue.GetPending(); + Assert.Equal(2, events.Count); + Assert.Contains(events, e => e.EntityType == nameof(CompetencyCatalogVersion)); + var domainEvent = Assert.Single(events, e => e.EntityType == nameof(CompetencyDomain)); + var restored = SyncCrypto.DecryptObject(domainEvent.Payload, Key); + Assert.Equal(domain.Id, restored!.Id); + Assert.Equal("EG1", Assert.Single(restored.Items).Code); + } + private sealed class TempEventQueue : IDisposable { private readonly string _directory = Path.Combine( diff --git a/LehrerApp.Sync/EventApplier.cs b/LehrerApp.Sync/EventApplier.cs index 4671218..28cdab9 100644 --- a/LehrerApp.Sync/EventApplier.cs +++ b/LehrerApp.Sync/EventApplier.cs @@ -145,11 +145,38 @@ public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = n Simple(context => context.SchoolHolidays); Simple(context => context.SupervisionDuties); Simple(context => context.SubstitutionEntries); - Simple(context => context.CompetencyDomains); - Simple(context => context.CompetencyCatalogVersions); Simple(context => context.Vorgaenge); Simple(context => context.UntisHubJobStates); + handlers[nameof(CompetencyCatalogVersion)] = (context, operation, entityId, json) => + { + if (operation == "Delete") + { + context.CompetencyCatalogVersions.Delete(new Guid(entityId)); + return; + } + + var version = JsonSerializer.Deserialize(json)!; + context.CompetencyCatalogVersions.Upsert(version); + NormalizeCompetencyDefaults(context, version.SubjectId, version.Id); + }; + handlers[nameof(CompetencyDomain)] = (context, operation, entityId, json) => + { + Guid? subjectId; + if (operation == "Delete") + { + subjectId = context.CompetencyDomains.FindById(new Guid(entityId))?.SubjectId; + context.CompetencyDomains.Delete(new Guid(entityId)); + } + else + { + var domain = JsonSerializer.Deserialize(json)!; + subjectId = domain.SubjectId; + context.CompetencyDomains.Upsert(domain); + } + if (subjectId is Guid id) NormalizeCompetencyDefaults(context, id); + }; + // Kaskaden-Fälle: dieselben internen LiteDbContext-Hilfsmethoden wie die jeweiligen // Repositories, damit die Kaskade nur an einer Stelle im Code existiert. handlers[nameof(LearningGroup)] = (context, operation, entityId, json) => @@ -177,4 +204,39 @@ public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = n return handlers; } + + /// + /// Alte, auf mehreren Geräten unabhängig erzeugte Standardversionen heißen identisch + /// „Bestehender Kompetenzkatalog“, besitzen aber unterschiedliche IDs. Bei diesem speziellen + /// Migrationsfall gewinnt deterministisch die Version mit den meisten Bereichen. Bei bewusst + /// verschieden benannten Versionen gilt dagegen die zuletzt empfangene Standard-Auswahl. + /// Direkte Collection-Schreibzugriffe verhindern dabei Sync-Ping-Pong. + /// + private static void NormalizeCompetencyDefaults(LiteDbContext context, Guid subjectId, + Guid? incomingDefaultId = null) + { + var defaults = context.CompetencyCatalogVersions + .Find(v => v.SubjectId == subjectId && v.IsDefault).ToList(); + if (defaults.Count <= 1) return; + + CompetencyCatalogVersion winner; + if (defaults.Select(v => v.Name.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).Count() == 1) + { + winner = defaults + .OrderByDescending(v => context.CompetencyDomains.Count(d => d.VersionId == v.Id)) + .ThenBy(v => v.Id) + .First(); + } + else + { + winner = defaults.FirstOrDefault(v => v.Id == incomingDefaultId) + ?? defaults.OrderBy(v => v.Id).First(); + } + + foreach (var version in defaults.Where(v => v.Id != winner.Id)) + { + version.IsDefault = false; + context.CompetencyCatalogVersions.Upsert(version); + } + } } diff --git a/LehrerApp.Sync/EventQueue.cs b/LehrerApp.Sync/EventQueue.cs index 3b1a991..db70ef0 100644 --- a/LehrerApp.Sync/EventQueue.cs +++ b/LehrerApp.Sync/EventQueue.cs @@ -102,7 +102,13 @@ public class EventQueue : IDisposable /// sie an, solange er sie unter der aktuellen userId selbst noch nicht kennt. public void ResetKnownServerSeqs() => _entityVersions.DeleteAll(); + internal bool IsMaintenanceCompleted(string id) => _meta.FindById(MaintenanceKey(id)) is not null; + + internal void MarkMaintenanceCompleted(string id) => + _meta.Upsert(new SyncMeta { Id = MaintenanceKey(id), Value = 1, Timestamp = DateTime.UtcNow }); + private static string EntityVersionKey(string entityType, string entityId) => $"{entityType}:{entityId}"; + private static string MaintenanceKey(string id) => $"maintenance:{id}"; // ── Anhang-Warteliste (getrennt von der JSON-Ereignis-Outbox, siehe AttachmentSyncer) ──── public void QueueAttachmentUpload(string storageId) diff --git a/LehrerApp.Sync/SyncEventPublisher.cs b/LehrerApp.Sync/SyncEventPublisher.cs index d91d1b9..eb4927e 100644 --- a/LehrerApp.Sync/SyncEventPublisher.cs +++ b/LehrerApp.Sync/SyncEventPublisher.cs @@ -13,6 +13,8 @@ namespace LehrerApp.Sync; /// public class SyncEventPublisher(EventQueue queue, string deviceId, byte[] syncKey, AppLogger? logger = null) { + private const string CompetencyCatalogBackfillId = "competency-catalog-v1"; + public void Publish(string entityType, string entityId, string operation, object? payload) { var encrypted = payload is null ? "" : SyncCrypto.EncryptObject(payload, syncKey); @@ -29,4 +31,31 @@ public class SyncEventPublisher(EventQueue queue, string deviceId, byte[] syncKe foreach (var attachment in withAttachments.Attachments) queue.QueueAttachmentUpload(attachment.StorageId); } + + /// + /// Veröffentlicht bestehende Kompetenzkataloge einmalig als vollständige Snapshots. Das + /// repariert Daten, die vor aktivierter Synchronisation oder während einer Migration direkt + /// in LiteDB geschrieben wurden und deshalb nie den Repository-OnChange-Hook durchlaufen + /// haben. Der Marker liegt gerätespezifisch in dessen lokaler EventQueue. + /// + public int BackfillCompetencyCatalog(LiteDbContext db) + { + if (queue.IsMaintenanceCompleted(CompetencyCatalogBackfillId)) return 0; + + var count = 0; + foreach (var version in db.CompetencyCatalogVersions.FindAll()) + { + Publish(nameof(CompetencyCatalogVersion), version.Id.ToString(), "Save", version); + count++; + } + foreach (var domain in db.CompetencyDomains.FindAll()) + { + Publish(nameof(CompetencyDomain), domain.Id.ToString(), "Save", domain); + count++; + } + + queue.MarkMaintenanceCompleted(CompetencyCatalogBackfillId); + logger?.Info($"Sync: einmaliger Kompetenzkatalog-Backfill abgeschlossen ({count} Ereignis(se))."); + return count; + } }