CI / build-and-test (push) Canceled after 0s
Ein Zwischenstand ist ein Durchgang aus Stichtag, Anlass und Schülerauswahl (ganze Gruppe, Teilgruppe oder Einzelfall, mit Schnellfilter für gefährdete Schüler). Der Zeugnisnoten-Assistent geht die gewählten Schüler im Zwischenstand-Modus durch und notiert vorläufige schriftliche, mündliche und Gesamtnote samt Notiz. Gespeichert wird das als eigenes Modell InterimGrade, getrennt von der Zeugnisnote. Die Berechnung endet am Stichtag und rechnet den laufenden Mitarbeits-Abschnitt mit ein. Zwischenstände erscheinen im Assistenten als Liste und Marker im Verlauf sowie als graue Spalte in der Notenübersicht (nicht in "Gesamt", nicht im PDF). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
246 lines
13 KiB
C#
246 lines
13 KiB
C#
using System.Text;
|
|
using JsonSerializer = System.Text.Json.JsonSerializer;
|
|
using LehrerApp.Core.Models;
|
|
using LehrerApp.Core.Services;
|
|
using LehrerApp.Data;
|
|
using LehrerApp.Sync.Crypto;
|
|
using LehrerApp.Sync.Models;
|
|
using LiteDB;
|
|
|
|
namespace LehrerApp.Sync;
|
|
|
|
/// <summary>
|
|
/// Wendet ein von <see cref="SyncEngine"/> empfangenes (und nicht durch einen Konflikt
|
|
/// verlorenes) Ereignis auf die lokale Datenbank an.
|
|
///
|
|
/// Schreibt IMMER direkt auf die rohe LiteDB-Collection, nie über eine Repository-
|
|
/// Save/Delete-Methode — sonst würde <see cref="LiteDbContext.OnChange"/> erneut feuern und die
|
|
/// gerade angewendete Änderung als neues ausgehendes Ereignis re-enqueuen (Sync-Ping-Pong).
|
|
/// Ein gemeinsames Suppress-Flag wurde bewusst verworfen: SyncEngine läuft per Timer nebenläufig
|
|
/// zum UI-Thread, ein Flag könnte während eines laufenden Pulls einen echten Nutzer-Save
|
|
/// verschlucken. Der direkte Collection-Zugriff ist zustandslos und dadurch korrekt.
|
|
///
|
|
/// Weiche Geschäftsregeln (z.B. ArchivedGroupWriteGuard, Namens-Eindeutigkeit) werden auf diesem
|
|
/// Pfad bewusst NICHT geprüft (v1-Einschränkung, siehe TODO.md 10.3) — nur harte LiteDB-Unique-
|
|
/// Constraints greifen noch und führen zum Überspringen des einzelnen Ereignisses.
|
|
/// </summary>
|
|
public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = null, AppLogger? logger = null,
|
|
EventQueue? versions = null)
|
|
{
|
|
private static readonly Dictionary<string, EntityHandler> Handlers = BuildHandlers();
|
|
private static readonly Dictionary<string, Func<string, IHasAttachments?>> AttachmentDeserializers = new()
|
|
{
|
|
[nameof(Documentation)] = json => JsonSerializer.Deserialize<Documentation>(json),
|
|
[nameof(Lesson)] = json => JsonSerializer.Deserialize<Lesson>(json),
|
|
};
|
|
|
|
public async Task ApplyAsync(SyncEvent evt)
|
|
{
|
|
if (!Handlers.TryGetValue(evt.EntityType, out var handler))
|
|
{
|
|
logger?.Warn($"Sync: kein Handler für Entitätstyp '{evt.EntityType}' (EntityId={evt.EntityId}, " +
|
|
$"Operation={evt.Operation}) - Ereignis übersprungen.");
|
|
return;
|
|
}
|
|
try
|
|
{
|
|
var json = evt.Payload.Length == 0 ? "" : Decrypt(evt.Payload);
|
|
handler(db, evt.Operation, evt.EntityId, json);
|
|
if (evt.Operation != "Delete" && http is not null &&
|
|
AttachmentDeserializers.TryGetValue(evt.EntityType, out var deserialize))
|
|
await DownloadMissingAttachmentsAsync(deserialize(json));
|
|
// evt.SequenceNr trägt bei einem vom Server empfangenen Ereignis immer dessen
|
|
// ServerSeq (siehe EventStore.Pull) — Grundlage für BasedOnServerSeq beim nächsten
|
|
// eigenen Push dieser Entität (optimistische Nebenläufigkeitskontrolle, TODO 10.3.4).
|
|
versions?.SetKnownServerSeq(evt.EntityType, evt.EntityId, evt.SequenceNr);
|
|
logger?.Info($"Sync: Ereignis angewendet - {evt.EntityType} {evt.Operation} EntityId={evt.EntityId}");
|
|
}
|
|
catch (SyncProtocolMismatchException)
|
|
{
|
|
// Anders als ein einzelnes korruptes Ereignis betrifft dies den gesamten Batch. Der
|
|
// Pull-Cursor darf nicht vorrücken, solange Client und Server inkompatibel sind.
|
|
throw;
|
|
}
|
|
catch (LiteException ex)
|
|
{
|
|
// Harte Constraint-Verletzung (z.B. Unique-Index) - dieses eine Ereignis
|
|
// überspringen, statt den gesamten Sync-Lauf abzubrechen.
|
|
logger?.Error($"Sync: Ereignis übersprungen (LiteDB-Constraint) - {evt.EntityType} " +
|
|
$"{evt.Operation} EntityId={evt.EntityId}", ex);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Jede andere Ausnahme (z.B. Entschlüsselung/Deserialisierung fehlgeschlagen) darf
|
|
// NICHT aus ApplyAsync herausfallen: SyncEngine.PullAsync ruft dies in einer Schleife
|
|
// über einen ganzen Ereignis-Batch auf, ohne eigenes try/catch - eine unbehandelte
|
|
// Ausnahme hier würde den kompletten Pull-Lauf abbrechen, BEVOR SetLastServerSeq
|
|
// aufgerufen wird. Der Pull würde beim nächsten Versuch denselben Batch erneut laden
|
|
// und an genau demselben Ereignis wieder scheitern - ein dauerhaft blockierter Sync,
|
|
// bei dem selbst bereits erfolgreich angewendete Ereignisse im selben Batch nie als
|
|
// erledigt markiert werden.
|
|
logger?.Error($"Sync: Ereignis konnte nicht angewendet werden - {evt.EntityType} " +
|
|
$"{evt.Operation} EntityId={evt.EntityId}", ex);
|
|
}
|
|
}
|
|
|
|
// Anhang-Bytes reisen nicht im JSON-Ereignis mit (siehe SyncEventPublisher) - nach dem
|
|
// Anwenden der Metadaten fehlende, lokal noch nicht vorhandene Anhänge einzeln nachladen.
|
|
// Gegenstück zum Upload in AttachmentSyncer.
|
|
private async Task DownloadMissingAttachmentsAsync(IHasAttachments? entity)
|
|
{
|
|
if (entity is null) return;
|
|
foreach (var attachment in entity.Attachments)
|
|
{
|
|
if (db.Attachments.Exists(attachment.StorageId)) continue;
|
|
using var request = SyncProtocol.CreateRequest(HttpMethod.Get,
|
|
$"/api/sync/attachments/{attachment.StorageId}");
|
|
using var resp = await http!.SendAsync(request);
|
|
if (resp.StatusCode == System.Net.HttpStatusCode.UpgradeRequired)
|
|
await SyncProtocol.EnsureCompatibleSuccessAsync(resp);
|
|
if (!resp.IsSuccessStatusCode) continue;
|
|
var encrypted = await resp.Content.ReadAsByteArrayAsync();
|
|
var decrypted = SyncCrypto.Decrypt(encrypted, syncKey);
|
|
using var stream = new MemoryStream(decrypted);
|
|
// Über die rohe Collection statt IAttachmentStorage.Upload, da dieses immer eine
|
|
// neue Id vergibt - hier muss die Original-StorageId erhalten bleiben.
|
|
db.Attachments.Upload(attachment.StorageId, attachment.FileName, stream);
|
|
}
|
|
}
|
|
|
|
private string Decrypt(string payloadBase64) =>
|
|
Encoding.UTF8.GetString(SyncCrypto.Decrypt(Convert.FromBase64String(payloadBase64), syncKey));
|
|
|
|
private delegate void EntityHandler(LiteDbContext db, string operation, string entityId, string json);
|
|
|
|
private static Dictionary<string, EntityHandler> BuildHandlers()
|
|
{
|
|
var handlers = new Dictionary<string, EntityHandler>();
|
|
|
|
void Simple<T>(Func<LiteDbContext, ILiteCollection<T>> collection) where T : class =>
|
|
handlers[typeof(T).Name] = (context, operation, entityId, json) =>
|
|
{
|
|
if (operation == "Delete") collection(context).Delete(new Guid(entityId));
|
|
else collection(context).Upsert(JsonSerializer.Deserialize<T>(json)!);
|
|
};
|
|
|
|
Simple<Student>(context => context.Students);
|
|
Simple<SeatingPlan>(context => context.SeatingPlans);
|
|
Simple<StudentGroupPlan>(context => context.StudentGroupPlans);
|
|
Simple<GroupMembership>(context => context.Memberships);
|
|
Simple<GradingKeyTemplate>(context => context.GradingKeyTemplates);
|
|
Simple<Grade>(context => context.Grades);
|
|
Simple<GradingScheme>(context => context.GradingSchemes);
|
|
Simple<ReportGrade>(context => context.ReportGrades);
|
|
Simple<InterimGrade>(context => context.InterimGrades);
|
|
Simple<Unit>(context => context.Units);
|
|
Simple<Lesson>(context => context.Lessons);
|
|
Simple<WorkTask>(context => context.Tasks);
|
|
Simple<TimeEntry>(context => context.TimeEntries);
|
|
Simple<ExamResult>(context => context.ExamResults);
|
|
Simple<ParticipationEntry>(context => context.ParticipationEntries);
|
|
Simple<ParticipationAspect>(context => context.ParticipationAspects);
|
|
Simple<ParticipationSection>(context => context.ParticipationSections);
|
|
Simple<Subject>(context => context.Subjects);
|
|
Simple<ShorthandCode>(context => context.ShorthandCodes);
|
|
Simple<AlternativeLessonPath>(context => context.AlternativeLessonPaths);
|
|
Simple<TimetableSlot>(context => context.TimetableSlots);
|
|
Simple<SchoolHoliday>(context => context.SchoolHolidays);
|
|
Simple<SupervisionDuty>(context => context.SupervisionDuties);
|
|
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) =>
|
|
{
|
|
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) =>
|
|
{
|
|
if (operation == "Delete") context.CascadeDeleteGroup(new Guid(entityId));
|
|
else context.Groups.Upsert(JsonSerializer.Deserialize<LearningGroup>(json)!);
|
|
};
|
|
handlers[nameof(Exam)] = (context, operation, entityId, json) =>
|
|
{
|
|
if (operation == "Delete") context.CascadeDeleteExam(new Guid(entityId));
|
|
else context.Exams.Upsert(JsonSerializer.Deserialize<Exam>(json)!);
|
|
};
|
|
handlers[nameof(ParticipationSession)] = (context, operation, entityId, json) =>
|
|
{
|
|
if (operation == "Delete") context.CascadeDeleteParticipationSession(new Guid(entityId));
|
|
else context.ParticipationSessions.Upsert(JsonSerializer.Deserialize<ParticipationSession>(json)!);
|
|
};
|
|
// "Delete" ist hier das harte Löschen (samt Anhängen) - das weiche Löschen kommt als
|
|
// "Save" mit IsDeleted=true und läuft über den generischen Upsert-Zweig.
|
|
handlers[nameof(Documentation)] = (context, operation, entityId, json) =>
|
|
{
|
|
if (operation == "Delete") context.CascadeHardDeleteDocumentation(new Guid(entityId));
|
|
else context.Documentation.Upsert(JsonSerializer.Deserialize<Documentation>(json)!);
|
|
};
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|