Neuer Tab "Planung" in GroupDetailView ersetzt den Platzhalter: Einheiten anlegen/bearbeiten/ als Vorlage in andere Gruppe kopieren, Stunden je Einheit mit Verschieben (inkl. Nachrücken der Folgestunden). Stundeneditor als tabellarischer Verlaufsplan (Phase/Dauer/Tätigkeit/ Material/Kurzsymbol je Zeile, Uhrzeit aus optionalem Stundenbeginn abgeleitet) statt eines einzelnen Phase-Felds mit Methoden-/Materialien-Chips — Kurzsymbol als Freitext mit Vorschlägen aus neuem Kürzel-Katalog (Einstellungen) plus bisher verwendeten Werten. Schema-Migrationen v1-v3 überführen bestehende Daten verlustfrei. 4.2.5 bewusst offen gelassen (hängt an Stundenplan, Kapitel 4.3). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
329 lines
14 KiB
C#
329 lines
14 KiB
C#
using LiteDB;
|
||
using LehrerApp.Core.Models;
|
||
|
||
namespace LehrerApp.Data;
|
||
|
||
/// <summary>
|
||
/// Zentrale LiteDB-Verbindung. Singleton – eine Datei = ein Nutzer.
|
||
/// </summary>
|
||
public class LiteDbContext : IDisposable
|
||
{
|
||
/// Aktuelle Schema-Version. Migrationsschritte werden versioniert unter
|
||
/// <see cref="RunVersionedMigrations"/> ergänzt, statt bei jedem Start erneut
|
||
/// (idempotent, aber unnötig) über alle Daten zu laufen.
|
||
private const int CurrentSchemaVersion = 3;
|
||
|
||
private readonly LiteDatabase _db;
|
||
|
||
public LiteDbContext(string databasePath, string? password = null)
|
||
{
|
||
_db = new LiteDatabase(new ConnectionString(databasePath)
|
||
{
|
||
Connection = ConnectionType.Shared,
|
||
Password = password,
|
||
});
|
||
RunVersionedMigrations();
|
||
EnsureIndexes();
|
||
}
|
||
|
||
/// Für Tests: In-Memory-Datenbank ohne Datei auf der Festplatte.
|
||
public LiteDbContext(Stream stream)
|
||
{
|
||
_db = new LiteDatabase(stream);
|
||
RunVersionedMigrations();
|
||
EnsureIndexes();
|
||
}
|
||
|
||
public ILiteCollection<Student> Students => _db.GetCollection<Student>("students");
|
||
public ILiteCollection<LearningGroup> Groups => _db.GetCollection<LearningGroup>("groups");
|
||
public ILiteCollection<GroupMembership> Memberships => _db.GetCollection<GroupMembership>("group_memberships");
|
||
public ILiteCollection<Exam> Exams => _db.GetCollection<Exam>("exams");
|
||
public ILiteCollection<ExamResult> ExamResults => _db.GetCollection<ExamResult>("exam_results");
|
||
public ILiteCollection<Grade> Grades => _db.GetCollection<Grade>("grades");
|
||
public ILiteCollection<GradingScheme> GradingSchemes => _db.GetCollection<GradingScheme>("grading_schemes");
|
||
public ILiteCollection<ReportGrade> ReportGrades => _db.GetCollection<ReportGrade>("report_grades");
|
||
public ILiteCollection<GradingKeyTemplate> GradingKeyTemplates => _db.GetCollection<GradingKeyTemplate>("grading_key_templates");
|
||
public ILiteCollection<Unit> Units => _db.GetCollection<Unit>("units");
|
||
public ILiteCollection<Lesson> Lessons => _db.GetCollection<Lesson>("lessons");
|
||
public ILiteCollection<Documentation> Documentation => _db.GetCollection<Documentation>("documentation");
|
||
public ILiteStorage<string> Attachments => _db.GetStorage<string>("attachments", "attachments_chunks");
|
||
public ILiteCollection<WorkTask> Tasks => _db.GetCollection<WorkTask>("tasks");
|
||
public ILiteCollection<TimeEntry> TimeEntries => _db.GetCollection<TimeEntry>("time_entries");
|
||
public ILiteCollection<ParticipationSession> ParticipationSessions => _db.GetCollection<ParticipationSession>("participation_sessions");
|
||
public ILiteCollection<ParticipationEntry> ParticipationEntries => _db.GetCollection<ParticipationEntry>("participation");
|
||
public ILiteCollection<ParticipationAspect> ParticipationAspects => _db.GetCollection<ParticipationAspect>("participation_aspects");
|
||
public ILiteCollection<ParticipationSection> ParticipationSections => _db.GetCollection<ParticipationSection>("participation_sections");
|
||
public ILiteCollection<Subject> Subjects => _db.GetCollection<Subject>("subjects");
|
||
public ILiteCollection<CompetencyDomain> CompetencyDomains => _db.GetCollection<CompetencyDomain>("competency_domains");
|
||
public ILiteCollection<ShorthandCode> ShorthandCodes => _db.GetCollection<ShorthandCode>("shorthand_codes");
|
||
|
||
public void Checkpoint() => _db.Checkpoint();
|
||
|
||
public int SchemaVersion => ReadSchemaVersion();
|
||
|
||
internal void ExecuteInTransaction(Action action)
|
||
{
|
||
_db.BeginTrans();
|
||
try
|
||
{
|
||
action();
|
||
_db.Commit();
|
||
}
|
||
catch
|
||
{
|
||
_db.Rollback();
|
||
throw;
|
||
}
|
||
}
|
||
|
||
private void RunVersionedMigrations()
|
||
{
|
||
var version = ReadSchemaVersion();
|
||
if (version < 1)
|
||
{
|
||
MigrateExistingData();
|
||
version = 1;
|
||
}
|
||
if (version < 2)
|
||
{
|
||
MigrateLessonPhases();
|
||
version = 2;
|
||
}
|
||
if (version < 3)
|
||
{
|
||
MigrateLessonShorthand();
|
||
version = 3;
|
||
}
|
||
WriteSchemaVersion(version);
|
||
}
|
||
|
||
private int ReadSchemaVersion()
|
||
{
|
||
var meta = _db.GetCollection<BsonDocument>("meta").FindById(1);
|
||
return meta?["SchemaVersion"].AsInt32 ?? 0;
|
||
}
|
||
|
||
private void WriteSchemaVersion(int version) =>
|
||
_db.GetCollection<BsonDocument>("meta").Upsert(new BsonDocument
|
||
{
|
||
["_id"] = 1,
|
||
["SchemaVersion"] = version,
|
||
});
|
||
|
||
private void MigrateExistingData()
|
||
{
|
||
MigrateMemberships();
|
||
RemoveRedundantLegacyFields();
|
||
|
||
var groups = _db.GetCollection<BsonDocument>("groups");
|
||
var subjects = _db.GetCollection<Subject>("subjects");
|
||
var missingArchiveState = groups.FindAll()
|
||
.Where(g => !g.ContainsKey(nameof(LearningGroup.IsActive)))
|
||
.ToList();
|
||
foreach (var group in missingArchiveState)
|
||
{
|
||
group[nameof(LearningGroup.IsActive)] = true;
|
||
groups.Update(group);
|
||
}
|
||
|
||
// Subject war anfangs nur als Text in der Gruppe gespeichert. Alte
|
||
// Werte werden einmalig an die Fachstammdaten angebunden und danach
|
||
// entfernt, damit es nur noch eine führende Fachbezeichnung gibt.
|
||
foreach (var group in groups.FindAll().ToList())
|
||
{
|
||
var changed = false;
|
||
var legacyName = group.TryGetValue("Subject", out var value) && value.IsString
|
||
? value.AsString.Trim()
|
||
: "";
|
||
var hasSubjectId = group.TryGetValue(nameof(LearningGroup.SubjectId), out var id)
|
||
&& id.IsGuid && subjects.FindById(id.AsGuid) is not null;
|
||
|
||
if (!hasSubjectId && legacyName.Length > 0)
|
||
{
|
||
var subject = subjects.FindAll().FirstOrDefault(s =>
|
||
string.Equals(s.Name.Trim(), legacyName, StringComparison.OrdinalIgnoreCase));
|
||
if (subject is null)
|
||
{
|
||
subject = new Subject { Name = legacyName };
|
||
subjects.Insert(subject);
|
||
}
|
||
group[nameof(LearningGroup.SubjectId)] = subject.Id;
|
||
changed = true;
|
||
}
|
||
else if (!hasSubjectId && group.ContainsKey(nameof(LearningGroup.SubjectId)))
|
||
{
|
||
group[nameof(LearningGroup.SubjectId)] = BsonValue.Null;
|
||
changed = true;
|
||
}
|
||
|
||
changed |= group.Remove("Subject");
|
||
if (changed) groups.Update(group);
|
||
}
|
||
}
|
||
|
||
private void MigrateMemberships()
|
||
{
|
||
const string oldName = "enrollments";
|
||
const string newName = "group_memberships";
|
||
var names = _db.GetCollectionNames().ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||
if (names.Contains(oldName) && !names.Contains(newName))
|
||
_db.RenameCollection(oldName, newName);
|
||
|
||
var memberships = _db.GetCollection<BsonDocument>(newName);
|
||
foreach (var membership in memberships.FindAll().ToList())
|
||
{
|
||
var changed = false;
|
||
if (!membership.ContainsKey(nameof(GroupMembership.AddedOn)) &&
|
||
membership.TryGetValue("EnrolledAt", out var enrolledAt))
|
||
{
|
||
membership[nameof(GroupMembership.AddedOn)] = enrolledAt;
|
||
changed = true;
|
||
}
|
||
changed |= membership.Remove("EnrolledAt");
|
||
changed |= membership.Remove("SchoolYear");
|
||
if (changed) memberships.Update(membership);
|
||
}
|
||
}
|
||
|
||
/// Fasst die frühere flache Struktur einer Stunde (einzelnes Phase-Textfeld, Methoden-/
|
||
/// Materialien-Listen) verlustfrei in eine einzige <see cref="LessonPhaseStep"/>-Zeile der
|
||
/// neuen Verlaufsplan-Tabelle zusammen. Nur die alten Felder werden gelesen — die typisierte
|
||
/// <see cref="Lesson"/>-Klasse kennt sie nicht mehr, ein Zugriff über die typisierte
|
||
/// Lessons-Collection würde sie beim Deserialisieren bereits verwerfen.
|
||
private void MigrateLessonPhases()
|
||
{
|
||
var lessons = _db.GetCollection<BsonDocument>("lessons");
|
||
foreach (var lesson in lessons.FindAll().ToList())
|
||
{
|
||
if (lesson.ContainsKey(nameof(Lesson.Phases))) continue;
|
||
|
||
var phase = lesson.TryGetValue("Phase", out var p) && p.IsString ? p.AsString.Trim() : "";
|
||
var methods = lesson.TryGetValue("Methods", out var m) && m.IsArray
|
||
? string.Join("; ", m.AsArray.Select(x => x.AsString)) : "";
|
||
var materials = lesson.TryGetValue("Materials", out var mat) && mat.IsArray
|
||
? string.Join(", ", mat.AsArray.Select(x => x.AsString)) : "";
|
||
|
||
var phases = new BsonArray();
|
||
if (phase.Length > 0 || methods.Length > 0 || materials.Length > 0)
|
||
{
|
||
phases.Add(new BsonDocument
|
||
{
|
||
[nameof(LessonPhaseStep.Id)] = Guid.NewGuid(),
|
||
[nameof(LessonPhaseStep.Name)] = phase,
|
||
[nameof(LessonPhaseStep.DurationMinutes)] = 0,
|
||
[nameof(LessonPhaseStep.Activity)] = methods,
|
||
[nameof(LessonPhaseStep.Material)] = materials,
|
||
[nameof(LessonPhaseStep.Shorthand)] = "",
|
||
});
|
||
}
|
||
lesson[nameof(Lesson.Phases)] = phases;
|
||
lesson.Remove("Phase");
|
||
lesson.Remove("Methods");
|
||
lesson.Remove("Materials");
|
||
lessons.Update(lesson);
|
||
}
|
||
}
|
||
|
||
/// Führt die anfangs erzwungene Von/Nach-Struktur des Kurzsymbols (<c>ShorthandFrom</c>/
|
||
/// <c>ShorthandTo</c>) in ein einzelnes Freitextfeld zusammen — der Praxis nach ist ein
|
||
/// Kurzsymbol nicht immer ein Materialfluss-Pfeil, manchmal nur eine Sozialform ("Plenum").
|
||
/// Beide Felder gesetzt ergeben "Von->Nach", nur eines gesetzt bleibt als Einzelwert erhalten.
|
||
private void MigrateLessonShorthand()
|
||
{
|
||
var lessons = _db.GetCollection<BsonDocument>("lessons");
|
||
foreach (var lesson in lessons.FindAll().ToList())
|
||
{
|
||
if (!lesson.TryGetValue(nameof(Lesson.Phases), out var phasesValue) || !phasesValue.IsArray) continue;
|
||
|
||
var changed = false;
|
||
foreach (var phaseValue in phasesValue.AsArray)
|
||
{
|
||
if (phaseValue is not BsonDocument phase) continue;
|
||
if (phase.ContainsKey(nameof(LessonPhaseStep.Shorthand))) continue;
|
||
|
||
var from = phase.TryGetValue("ShorthandFrom", out var f) && f.IsString ? f.AsString : "";
|
||
var to = phase.TryGetValue("ShorthandTo", out var t) && t.IsString ? t.AsString : "";
|
||
phase[nameof(LessonPhaseStep.Shorthand)] = (from.Length > 0, to.Length > 0) switch
|
||
{
|
||
(true, true) => $"{from}->{to}",
|
||
(true, false) => from,
|
||
(false, true) => to,
|
||
_ => "",
|
||
};
|
||
phase.Remove("ShorthandFrom");
|
||
phase.Remove("ShorthandTo");
|
||
changed = true;
|
||
}
|
||
if (changed) lessons.Update(lesson);
|
||
}
|
||
}
|
||
|
||
private void RemoveRedundantLegacyFields()
|
||
{
|
||
RemoveFields("exams", "Subject");
|
||
RemoveFields("units", "Subject", "SchoolYear");
|
||
RemoveFields("grades", "SchoolYear");
|
||
RemoveFields("participation", "GroupId", "Date");
|
||
}
|
||
|
||
private void RemoveFields(string collectionName, params string[] fieldNames)
|
||
{
|
||
var collection = _db.GetCollection<BsonDocument>(collectionName);
|
||
foreach (var document in collection.FindAll().ToList())
|
||
{
|
||
var changed = false;
|
||
foreach (var fieldName in fieldNames) changed |= document.Remove(fieldName);
|
||
if (changed) collection.Update(document);
|
||
}
|
||
}
|
||
|
||
private void EnsureIndexes()
|
||
{
|
||
Students.EnsureIndex(x => x.LastName);
|
||
Students.EnsureIndex(x => x.IsActive);
|
||
Groups.EnsureIndex(x => x.SchoolYear);
|
||
Groups.EnsureIndex(x => x.IsActive);
|
||
Memberships.EnsureIndex(x => x.StudentId);
|
||
Memberships.EnsureIndex(x => x.GroupId);
|
||
Memberships.EnsureIndex("ux_student_group",
|
||
BsonExpression.Create("STRING($.StudentId) + ':' + STRING($.GroupId)"), unique: true);
|
||
Exams.EnsureIndex(x => x.GroupId);
|
||
Exams.EnsureIndex(x => x.Status);
|
||
ExamResults.EnsureIndex(x => x.ExamId);
|
||
ExamResults.EnsureIndex(x => x.StudentId);
|
||
ExamResults.EnsureIndex("ux_exam_student",
|
||
BsonExpression.Create("STRING($.ExamId) + ':' + STRING($.StudentId)"), unique: true);
|
||
Grades.EnsureIndex(x => x.StudentId);
|
||
Grades.EnsureIndex(x => x.GroupId);
|
||
GradingSchemes.EnsureIndex(x => x.GroupId);
|
||
GradingSchemes.EnsureIndex(x => x.GroupType);
|
||
ReportGrades.EnsureIndex(x => x.GroupId);
|
||
ReportGrades.EnsureIndex(x => x.StudentId);
|
||
ReportGrades.EnsureIndex("ux_student_group_period",
|
||
BsonExpression.Create("STRING($.StudentId) + ':' + STRING($.GroupId) + ':' + $.Period"), unique: true);
|
||
GradingKeyTemplates.EnsureIndex(x => x.GradingSystem);
|
||
Units.EnsureIndex(x => x.GroupId);
|
||
Lessons.EnsureIndex(x => x.UnitId);
|
||
Lessons.EnsureIndex(x => x.GroupId);
|
||
Lessons.EnsureIndex(x => x.Date);
|
||
Documentation.EnsureIndex(x => x.StudentId);
|
||
Tasks.EnsureIndex(x => x.Status);
|
||
TimeEntries.EnsureIndex(x => x.Date);
|
||
ParticipationSessions.EnsureIndex(x => x.GroupId);
|
||
ParticipationSessions.EnsureIndex(x => x.Date);
|
||
ParticipationEntries.EnsureIndex(x => x.SessionId);
|
||
ParticipationEntries.EnsureIndex(x => x.StudentId);
|
||
ParticipationEntries.EnsureIndex("ux_session_student",
|
||
BsonExpression.Create("STRING($.SessionId) + ':' + STRING($.StudentId)"), unique: true);
|
||
ParticipationAspects.EnsureIndex(x => x.GroupId);
|
||
ParticipationSections.EnsureIndex(x => x.GroupId);
|
||
Subjects.EnsureIndex(x => x.Name);
|
||
Subjects.EnsureIndex("ux_subject_name", BsonExpression.Create("LOWER(TRIM($.Name))"), unique: true);
|
||
CompetencyDomains.EnsureIndex(x => x.SubjectId);
|
||
CompetencyDomains.EnsureIndex(x => x.GradeLevel);
|
||
ShorthandCodes.EnsureIndex("ux_shorthand_code", BsonExpression.Create("LOWER(TRIM($.Code))"), unique: true);
|
||
}
|
||
|
||
public void Dispose() => _db.Dispose();
|
||
}
|