fix: Kompetenzkataloge im Sync nachreichen
This commit is contained in:
1 parent
0f2a25feb5
commit
22eceb3ac1
7 files changed
+188
-4
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user