fix: Kompetenzkataloge im Sync nachreichen

This commit is contained in:
admin committed 2026-09-20 00:03:45 +02:00
1 parent 0f2a25feb5
commit 22eceb3ac1
7 files changed
+188 -4

No files matched your search

+6 -2
View File
@@ -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<LiteDbContext>().OnChange =
provider.GetRequiredService<SyncEventPublisher>().Publish;
{
var context = provider.GetRequiredService<LiteDbContext>();
var publisher = provider.GetRequiredService<SyncEventPublisher>();
context.OnChange = publisher.Publish;
publisher.BackfillCompetencyCatalog(context);
}
return provider;
}
@@ -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:
+48
View File
@@ -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()
{
@@ -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<CompetencyDomain>(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(
+64 -2
View File
@@ -145,11 +145,38 @@ public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = n
Simple<SchoolHoliday>(context => context.SchoolHolidays);
Simple<SupervisionDuty>(context => context.SupervisionDuties);
Simple<SubstitutionEntry>(context => context.SubstitutionEntries);
Simple<CompetencyDomain>(context => context.CompetencyDomains);
Simple<CompetencyCatalogVersion>(context => context.CompetencyCatalogVersions);
Simple<Vorgang>(context => context.Vorgaenge);
Simple<UntisHubJobState>(context => context.UntisHubJobStates);
handlers[nameof(CompetencyCatalogVersion)] = (context, operation, entityId, json) =>
{
if (operation == "Delete")
{
context.CompetencyCatalogVersions.Delete(new Guid(entityId));
return;
}
var version = JsonSerializer.Deserialize<CompetencyCatalogVersion>(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<CompetencyDomain>(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;
}
/// <summary>
/// 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.
/// </summary>
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);
}
}
}
+6
View File
@@ -102,7 +102,13 @@ public class EventQueue : IDisposable
/// sie an, solange er sie unter der aktuellen userId selbst noch nicht kennt.</summary>
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)
+29
View File
@@ -13,6 +13,8 @@ namespace LehrerApp.Sync;
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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;
}
}