Eine auf einem Gerät zurückgestellte Warnung stand auf den anderen weiter rot oben, weil DashboardWarningSnooze bewusst lokal gehalten war. Save löst jetzt db.OnChange aus, EventApplier übernimmt den Typ. Die Id wird aus (StudentId, WarningTag) abgeleitet, damit zwei Geräte denselben Datensatz beschreiben statt am Unique-Index ux_student_tag zu kollidieren; Schema-Migration 7 schlüsselt bestehende lokale Einträge um. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9a84bbaa38
commit
7d8deaf79c
10 files changed
+140
-11
No files matched your search
@@ -218,8 +218,8 @@ public interface IVorgangRepository
|
||||
/// Markiert den Vorgang als gelöscht, statt ihn hart zu entfernen (gleiches Muster wie Documentation).
|
||||
void Delete(Guid id);
|
||||
}
|
||||
/// Rein lokaler "Zurückstellen"-Zeitstempel für Dashboard-Warnungen je (StudentId, WarningTag) —
|
||||
/// kein Sync, siehe DashboardWarningSnooze.
|
||||
/// "Zurückstellen"-Zeitstempel für Dashboard-Warnungen je (StudentId, WarningTag) — wird
|
||||
/// synchronisiert, siehe DashboardWarningSnooze.
|
||||
public interface IDashboardWarningSnoozeRepository
|
||||
{
|
||||
DashboardWarningSnooze? Get(Guid studentId, string warningTag);
|
||||
|
||||
@@ -1,17 +1,30 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace LehrerApp.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// "Zurückstellen" einer klassenübergreifenden Dashboard-Warnung (Fehlzeiten/Hausaufgaben/
|
||||
/// Verspätungen, siehe DashboardViewModel) für eine Person — ein rein lokaler Erinnerungs-
|
||||
/// Zeitstempel je (StudentId, WarningTag), bewusst nicht über den Sync repliziert (siehe
|
||||
/// IDashboardWarningSnoozeRepository): eher ein Job-/Fetch-Zustand als echte Fallakten-Daten wie
|
||||
/// Vorgang/Documentation.
|
||||
/// Verspätungen, siehe DashboardViewModel) für eine Person — ein Erinnerungs-Zeitstempel je
|
||||
/// (StudentId, WarningTag), der über den Sync repliziert wird, damit eine auf Gerät A
|
||||
/// zurückgestellte Warnung auf Gerät B nicht weiter rot oben steht.
|
||||
/// </summary>
|
||||
public class DashboardWarningSnooze
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
/// Aus (StudentId, WarningTag) abgeleitet (siehe <see cref="IdFor"/>; setzt
|
||||
/// IDashboardWarningSnoozeRepository.Save) statt zufällig: stellen
|
||||
/// zwei Geräte dieselbe Warnung unabhängig voneinander zurück, beschreiben sie so denselben
|
||||
/// Datensatz (Last-Write-Wins im Sync), statt zwei Datensätze anzulegen, die beim Anwenden am
|
||||
/// Unique-Index ux_student_tag scheitern würden.
|
||||
public Guid Id { get; set; }
|
||||
public Guid StudentId { get; set; }
|
||||
/// Dieselben Strings wie VorgangTagDisplay.Suggestions ("Absentismus"/"Hausaufgaben"/"Verspätungen").
|
||||
public string WarningTag { get; set; } = "";
|
||||
public DateOnly SnoozedUntil { get; set; }
|
||||
|
||||
public static Guid IdFor(Guid studentId, string warningTag)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes($"{studentId:N}:{warningTag.ToLowerInvariant()}"));
|
||||
return new Guid(hash.AsSpan(0, 16));
|
||||
}
|
||||
}
|
||||
@@ -77,4 +77,26 @@ public sealed class ChangeHookTests
|
||||
Assert.Equal("Save", call.Operation);
|
||||
Assert.Same(state, call.Payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DashboardWarningSnoozeRepository_Save_LoestOnChangeAus()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var calls = new List<(string EntityType, string EntityId, string Operation, object? Payload)>();
|
||||
db.OnChange = (type, id, op, payload) => calls.Add((type, id, op, payload));
|
||||
var repo = new DashboardWarningSnoozeRepository(db);
|
||||
var studentId = Guid.NewGuid();
|
||||
var snooze = new DashboardWarningSnooze
|
||||
{
|
||||
StudentId = studentId, WarningTag = "Absentismus", SnoozedUntil = new DateOnly(2026, 10, 12),
|
||||
};
|
||||
|
||||
repo.Save(snooze);
|
||||
|
||||
var call = Assert.Single(calls);
|
||||
Assert.Equal(nameof(DashboardWarningSnooze), call.EntityType);
|
||||
Assert.Equal(DashboardWarningSnooze.IdFor(studentId, "Absentismus").ToString(), call.EntityId);
|
||||
Assert.Equal("Save", call.Operation);
|
||||
Assert.Same(snooze, call.Payload);
|
||||
}
|
||||
}
|
||||
@@ -121,7 +121,7 @@ public sealed class LiteDbContextTests
|
||||
using var temp = new TempDatabase();
|
||||
using var context = new LiteDbContext(temp.Path);
|
||||
|
||||
Assert.Equal(6, context.SchemaVersion);
|
||||
Assert.Equal(7, context.SchemaVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -132,7 +132,30 @@ public sealed class LiteDbContextTests
|
||||
|
||||
using var second = new LiteDbContext(temp.Path);
|
||||
|
||||
Assert.Equal(6, second.SchemaVersion);
|
||||
Assert.Equal(7, second.SchemaVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MigrateDashboardWarningSnoozeIds_SchluesseltLokaleEintraegeAufAbgeleiteteIdUm()
|
||||
{
|
||||
using var temp = new TempDatabase();
|
||||
var studentId = Guid.NewGuid();
|
||||
|
||||
using (var legacy = new LiteDatabase(temp.Path))
|
||||
{
|
||||
legacy.GetCollection<BsonDocument>("meta").Upsert(new BsonDocument { ["_id"] = 1, ["SchemaVersion"] = 6 });
|
||||
legacy.GetCollection<DashboardWarningSnooze>("dashboard_warning_snoozes").Insert(new DashboardWarningSnooze
|
||||
{
|
||||
Id = Guid.NewGuid(), StudentId = studentId, WarningTag = "Verspätungen",
|
||||
SnoozedUntil = new DateOnly(2026, 10, 12),
|
||||
});
|
||||
}
|
||||
|
||||
using var context = new LiteDbContext(temp.Path);
|
||||
|
||||
var snooze = Assert.Single(context.DashboardWarningSnoozes.FindAll());
|
||||
Assert.Equal(DashboardWarningSnooze.IdFor(studentId, "Verspätungen"), snooze.Id);
|
||||
Assert.Equal(new DateOnly(2026, 10, 12), snooze.SnoozedUntil);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -274,9 +274,31 @@ public class LiteDbContext : IDisposable
|
||||
MigrateCompetencyReferences();
|
||||
version = 6;
|
||||
}
|
||||
if (version < 7)
|
||||
{
|
||||
MigrateDashboardWarningSnoozeIds();
|
||||
version = 7;
|
||||
}
|
||||
WriteSchemaVersion(version);
|
||||
}
|
||||
|
||||
/// DashboardWarningSnooze wird seit Schema 7 synchronisiert und braucht dafür eine aus
|
||||
/// (StudentId, WarningTag) abgeleitete Id (siehe DashboardWarningSnooze.IdFor). Vorher lokal
|
||||
/// angelegte Einträge mit zufälliger Id werden umgeschlüsselt, sonst kollidierte ein
|
||||
/// eingehender Sync-Datensatz für dasselbe Paar am Unique-Index ux_student_tag.
|
||||
private void MigrateDashboardWarningSnoozeIds()
|
||||
{
|
||||
var snoozes = DashboardWarningSnoozes;
|
||||
foreach (var snooze in snoozes.FindAll().ToList())
|
||||
{
|
||||
var id = DashboardWarningSnooze.IdFor(snooze.StudentId, snooze.WarningTag);
|
||||
if (snooze.Id == id) continue;
|
||||
snoozes.Delete(snooze.Id);
|
||||
snooze.Id = id;
|
||||
snoozes.Upsert(snooze);
|
||||
}
|
||||
}
|
||||
|
||||
private int ReadSchemaVersion()
|
||||
{
|
||||
var meta = _db.GetCollection<BsonDocument>("meta").FindById(1);
|
||||
|
||||
@@ -909,7 +909,12 @@ public class DashboardWarningSnoozeRepository(LiteDbContext db) : IDashboardWarn
|
||||
public DashboardWarningSnooze? Get(Guid studentId, string warningTag) =>
|
||||
db.DashboardWarningSnoozes.FindOne(s => s.StudentId == studentId && s.WarningTag == warningTag);
|
||||
public List<DashboardWarningSnooze> GetAll() => db.DashboardWarningSnoozes.FindAll().ToList();
|
||||
public void Save(DashboardWarningSnooze snooze) => db.DashboardWarningSnoozes.Upsert(snooze);
|
||||
public void Save(DashboardWarningSnooze snooze)
|
||||
{
|
||||
snooze.Id = DashboardWarningSnooze.IdFor(snooze.StudentId, snooze.WarningTag);
|
||||
db.DashboardWarningSnoozes.Upsert(snooze);
|
||||
db.OnChange?.Invoke(nameof(DashboardWarningSnooze), snooze.Id.ToString(), "Save", snooze);
|
||||
}
|
||||
}
|
||||
|
||||
public class UntisHubJobStateRepository(LiteDbContext db) : IUntisHubJobStateRepository
|
||||
|
||||
@@ -347,7 +347,12 @@ public class FakeDashboardWarningSnoozeRepository : IDashboardWarningSnoozeRepos
|
||||
public DashboardWarningSnooze? Get(Guid studentId, string warningTag) =>
|
||||
_all.FirstOrDefault(s => s.StudentId == studentId && s.WarningTag == warningTag);
|
||||
public List<DashboardWarningSnooze> GetAll() => _all.ToList();
|
||||
public void Save(DashboardWarningSnooze snooze) { _all.RemoveAll(s => s.Id == snooze.Id); _all.Add(snooze); }
|
||||
public void Save(DashboardWarningSnooze snooze)
|
||||
{
|
||||
snooze.Id = DashboardWarningSnooze.IdFor(snooze.StudentId, snooze.WarningTag);
|
||||
_all.RemoveAll(s => s.Id == snooze.Id);
|
||||
_all.Add(snooze);
|
||||
}
|
||||
}
|
||||
|
||||
public class FakeAttachmentStorage : IAttachmentStorage
|
||||
|
||||
@@ -52,6 +52,31 @@ public sealed class EventApplierTests
|
||||
Assert.Equal(UntisHubJobKind.OffenePeriods, saved!.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_DashboardWarningSnoozeSave_UeberschreibtLokalenEintragFuerDasselbePaar()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var applier = new EventApplier(db, Key);
|
||||
var studentId = Guid.NewGuid();
|
||||
// Beide Geräte stellen dieselbe Warnung unabhängig zurück — dank IdFor dieselbe Id, also
|
||||
// kein Konflikt am Unique-Index ux_student_tag, sondern ein schlichtes Überschreiben.
|
||||
db.DashboardWarningSnoozes.Insert(new DashboardWarningSnooze
|
||||
{
|
||||
Id = DashboardWarningSnooze.IdFor(studentId, "Hausaufgaben"),
|
||||
StudentId = studentId, WarningTag = "Hausaufgaben", SnoozedUntil = new DateOnly(2026, 10, 5),
|
||||
});
|
||||
var remote = new DashboardWarningSnooze
|
||||
{
|
||||
Id = DashboardWarningSnooze.IdFor(studentId, "Hausaufgaben"),
|
||||
StudentId = studentId, WarningTag = "Hausaufgaben", SnoozedUntil = new DateOnly(2026, 10, 26),
|
||||
};
|
||||
|
||||
await applier.ApplyAsync(MakeEvent(nameof(DashboardWarningSnooze), remote.Id.ToString(), "Save", remote));
|
||||
|
||||
var saved = Assert.Single(db.DashboardWarningSnoozes.FindAll());
|
||||
Assert.Equal(new DateOnly(2026, 10, 26), saved.SnoozedUntil);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_CompetencyDomainSave_UebernimmtVerschachtelteKompetenzen()
|
||||
{
|
||||
|
||||
@@ -147,6 +147,7 @@ public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = n
|
||||
Simple<SubstitutionEntry>(context => context.SubstitutionEntries);
|
||||
Simple<Vorgang>(context => context.Vorgaenge);
|
||||
Simple<UntisHubJobState>(context => context.UntisHubJobStates);
|
||||
Simple<DashboardWarningSnooze>(context => context.DashboardWarningSnoozes);
|
||||
|
||||
handlers[nameof(CompetencyCatalogVersion)] = (context, operation, entityId, json) =>
|
||||
{
|
||||
|
||||
@@ -2005,6 +2005,19 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk
|
||||
`DashboardViewModel.Load()`/`RebuildAttention()`. `DashboardView.axaml` bekam dafür einen neuen
|
||||
`.defused`-Stil (nach `.attendance` deklariert, damit er bei gleicher Klassenanzahl gewinnt) für
|
||||
Severity-Balken und Fehlzeiten-Prozent-Text.
|
||||
**Nachtrag, Zurückstellen synchronisiert (2026-09-28):** Nutzer-Feedback: eine auf Gerät A
|
||||
zurückgestellte Warnung stand auf Gerät B weiter rot oben — die ursprüngliche Einordnung als
|
||||
"rein lokaler Erinnerungs-Zeitstempel" passte nicht, weil das Zurückstellen eine bewusste
|
||||
Entscheidung über die Person ist, nicht Geräte-Buchhaltung (gleiche Begründung wie beim
|
||||
Sync von `UntisHubJobState`). `DashboardWarningSnoozeRepository.Save` ruft jetzt `db.OnChange`,
|
||||
`EventApplier` hat einen `Simple<DashboardWarningSnooze>`-Eintrag. Anders als bei
|
||||
`UntisHubJobState` liegt auf (StudentId, WarningTag) ein Unique-Index — zwei Geräte mit
|
||||
zufälligen Ids hätten beim Anwenden einen `LiteException` ausgelöst und das Ereignis
|
||||
übersprungen. Die Id wird deshalb jetzt deterministisch aus dem Paar abgeleitet
|
||||
(`DashboardWarningSnooze.IdFor`, gesetzt im Repository-`Save`), sodass beide Geräte denselben
|
||||
Datensatz beschreiben (Last-Write-Wins). Schema-Migration 7 schlüsselt bereits lokal
|
||||
gespeicherte Einträge auf die abgeleitete Id um. Bestehende lokale Zurückstellungen werden
|
||||
erst beim nächsten erneuten Zurückstellen übertragen (kein automatischer Nach-Push).
|
||||
- **Nachtrag, Detail-Popup und Schnellbausteine (September 2026):** Nutzer-Feedback: (a) im
|
||||
Dashboard-Eintrag mehr erfahren, (b) beim Anlegen von Vorgang/Dokumentation Unterstützung statt
|
||||
alles von Hand. Gemeinsame Grundlage ist der neue `StudentWarningEvidenceService` (Desktop/
|
||||
|
||||
Reference in new issue
Block a user