From a1e722ace1937d69f78a5d0664cb1cec98f125a5 Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Wed, 12 Aug 2026 17:45:02 +0200 Subject: [PATCH] =?UTF-8?q?aufr=C3=A4umen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Directory.Packages.props | 1 - LehrerApp.Api/LehrerApp.Api.csproj | 1 - LehrerApp.Api/ReadableSnapshot.cs | 3 +- LehrerApp.Core/Interfaces/IRepositories.cs | 12 +- LehrerApp.Core/Models/Exam.cs | 1 - LehrerApp.Core/Models/LearningGroup.cs | 21 +-- LehrerApp.Core/Models/Participation.cs | 2 - LehrerApp.Core/Models/Planning.cs | 3 - .../LehrerApp.Data.Tests.csproj | 18 +++ LehrerApp.Data.Tests/LiteDbContextTests.cs | 135 ++++++++++++++++++ LehrerApp.Data/LiteDbContext.cs | 95 +++++++++++- .../Repositories/AllRepositories.cs | 65 ++++++--- LehrerApp.Desktop/AppBootstrapper.cs | 2 +- .../ViewModels/DashboardViewModel.cs | 12 +- .../Groups/ExamGradingViewModels.cs | 22 +-- .../ViewModels/Groups/ExamViewModels.cs | 1 - .../ViewModels/Groups/GroupViewModels.cs | 131 ++++++++--------- .../Groups/ParticipationGradeViewModels.cs | 25 ++-- .../Groups/ParticipationViewModels.cs | 36 ++--- .../ViewModels/Settings/SettingsViewModel.cs | 21 ++- .../ViewModels/Students/StudentViewModels.cs | 22 +-- .../Views/Groups/GroupDetailView.axaml.cs | 11 +- .../Groups/ParticipationTabView.axaml.cs | 2 +- .../Views/Students/StudentDetailView.axaml | 8 +- LehrerApp.sln | 4 + TODO.md | 12 +- docs/Datenmodell.md | 73 ++++++++++ 27 files changed, 542 insertions(+), 197 deletions(-) create mode 100644 LehrerApp.Data.Tests/LehrerApp.Data.Tests.csproj create mode 100644 LehrerApp.Data.Tests/LiteDbContextTests.cs create mode 100644 docs/Datenmodell.md diff --git a/Directory.Packages.props b/Directory.Packages.props index 44e9fdd..f22fb7a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -19,7 +19,6 @@ - diff --git a/LehrerApp.Api/LehrerApp.Api.csproj b/LehrerApp.Api/LehrerApp.Api.csproj index 7bce219..ffa4719 100644 --- a/LehrerApp.Api/LehrerApp.Api.csproj +++ b/LehrerApp.Api/LehrerApp.Api.csproj @@ -9,6 +9,5 @@ - diff --git a/LehrerApp.Api/ReadableSnapshot.cs b/LehrerApp.Api/ReadableSnapshot.cs index b767af7..484cd67 100644 --- a/LehrerApp.Api/ReadableSnapshot.cs +++ b/LehrerApp.Api/ReadableSnapshot.cs @@ -7,8 +7,9 @@ public class ReadableSnapshot public DateTime ExportedAt { get; set; } public ReadableSnapshotMeta Meta { get; set; } = new(); public List Groups { get; set; } = []; + public List Subjects { get; set; } = []; public List Students { get; set; } = []; - public List Enrollments { get; set; } = []; + public List Memberships { get; set; } = []; } public class ReadableSnapshotMeta diff --git a/LehrerApp.Core/Interfaces/IRepositories.cs b/LehrerApp.Core/Interfaces/IRepositories.cs index 5c7f886..658c97c 100644 --- a/LehrerApp.Core/Interfaces/IRepositories.cs +++ b/LehrerApp.Core/Interfaces/IRepositories.cs @@ -6,7 +6,7 @@ public interface IStudentRepository { Student? GetById(Guid id); List GetAll(bool includeInactive = false); - List GetByGroup(Guid groupId, string schoolYear); + List GetByGroup(Guid groupId); void Save(Student student); void Delete(Guid id); } @@ -18,12 +18,12 @@ public interface IGroupRepository void Save(LearningGroup group); void Delete(Guid id); } -public interface IEnrollmentRepository +public interface IGroupMembershipRepository { - List GetByStudent(Guid studentId); - List GetByGroup(Guid groupId); - List GetByGroupAndYear(Guid groupId, string schoolYear); - void Save(Enrollment enrollment); + List GetByStudent(Guid studentId); + List GetByGroup(Guid groupId); + GroupMembership? GetByStudentAndGroup(Guid studentId, Guid groupId); + void Save(GroupMembership membership); void Delete(Guid id); } public interface IExamRepository diff --git a/LehrerApp.Core/Models/Exam.cs b/LehrerApp.Core/Models/Exam.cs index b9f6f9c..7e830c4 100644 --- a/LehrerApp.Core/Models/Exam.cs +++ b/LehrerApp.Core/Models/Exam.cs @@ -6,7 +6,6 @@ public class Exam public Guid GroupId { get; set; } public string Title { get; set; } = ""; public DateOnly Date { get; set; } - public string Subject { get; set; } = ""; public int? ExamNumber { get; set; } public List Tasks { get; set; } = []; public List GradingKey { get; set; } = []; diff --git a/LehrerApp.Core/Models/LearningGroup.cs b/LehrerApp.Core/Models/LearningGroup.cs index 1a43b10..70d8605 100644 --- a/LehrerApp.Core/Models/LearningGroup.cs +++ b/LehrerApp.Core/Models/LearningGroup.cs @@ -5,7 +5,6 @@ public class LearningGroup public Guid Id { get; set; } = Guid.NewGuid(); public string Name { get; set; } = ""; public GroupType Type { get; set; } - public string? Subject { get; set; } public Guid? SubjectId { get; set; } public string SchoolYear { get; set; } = ""; public int GradeLevel { get; set; } @@ -17,19 +16,23 @@ public class LearningGroup public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } -public class Enrollment +/// +/// Mitgliedschaft eines Schülers in einer Lerngruppe. Das Schuljahr gehört zur +/// Lerngruppe und wird deshalb hier nicht ein zweites Mal gespeichert. +/// +public class GroupMembership { - public Guid Id { get; set; } = Guid.NewGuid(); - public Guid StudentId { get; set; } - public Guid GroupId { get; set; } - public string SchoolYear { get; set; } = ""; - public DateOnly EnrolledAt { get; set; } = DateOnly.FromDateTime(DateTime.Today); - public EnrollmentPeriod Period { get; set; } = EnrollmentPeriod.FullYear; + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid StudentId { get; set; } + public Guid GroupId { get; set; } + /// Tag, an dem die Zuordnung in der App angelegt wurde. + public DateOnly AddedOn { get; set; } = DateOnly.FromDateTime(DateTime.Today); + public MembershipPeriod Period { get; set; } = MembershipPeriod.FullYear; public DateOnly? JoinedAt { get; set; } public DateOnly? LeftAt { get; set; } public Niveau? Niveau { get; set; } } -public enum EnrollmentPeriod { FullYear, H1Only, H2Only, Custom } +public enum MembershipPeriod { FullYear, H1Only, H2Only, Custom } public enum GroupType { Class, Course } public enum GradingSystem { Grades1To6, Points0To15 } public enum Niveau { E, G, Foerder } diff --git a/LehrerApp.Core/Models/Participation.cs b/LehrerApp.Core/Models/Participation.cs index 67bb6e8..4455cc4 100644 --- a/LehrerApp.Core/Models/Participation.cs +++ b/LehrerApp.Core/Models/Participation.cs @@ -16,9 +16,7 @@ public class ParticipationEntry { public Guid Id { get; set; } = Guid.NewGuid(); public Guid SessionId { get; set; } - public Guid GroupId { get; set; } public Guid StudentId { get; set; } - public DateOnly Date { get; set; } = DateOnly.FromDateTime(DateTime.Today); public List Ratings { get; set; } = []; public List CompetencyRatings { get; set; } = []; public string? Note { get; set; } diff --git a/LehrerApp.Core/Models/Planning.cs b/LehrerApp.Core/Models/Planning.cs index 1b24ca5..73fd48c 100644 --- a/LehrerApp.Core/Models/Planning.cs +++ b/LehrerApp.Core/Models/Planning.cs @@ -5,7 +5,6 @@ public class Grade public Guid Id { get; set; } = Guid.NewGuid(); public Guid StudentId { get; set; } public Guid GroupId { get; set; } - public string SchoolYear { get; set; } = ""; public GradeCategory Category { get; set; } public string Value { get; set; } = ""; public DateOnly Date { get; set; } @@ -20,8 +19,6 @@ public class Unit public Guid Id { get; set; } = Guid.NewGuid(); public Guid GroupId { get; set; } public string Title { get; set; } = ""; - public string Subject { get; set; } = ""; - public string SchoolYear { get; set; } = ""; public DateOnly? StartDate { get; set; } public DateOnly? EndDate { get; set; } public List Competencies { get; set; } = []; diff --git a/LehrerApp.Data.Tests/LehrerApp.Data.Tests.csproj b/LehrerApp.Data.Tests/LehrerApp.Data.Tests.csproj new file mode 100644 index 0000000..4af4c7d --- /dev/null +++ b/LehrerApp.Data.Tests/LehrerApp.Data.Tests.csproj @@ -0,0 +1,18 @@ + + + net10.0 + false + true + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/LehrerApp.Data.Tests/LiteDbContextTests.cs b/LehrerApp.Data.Tests/LiteDbContextTests.cs new file mode 100644 index 0000000..3948d06 --- /dev/null +++ b/LehrerApp.Data.Tests/LiteDbContextTests.cs @@ -0,0 +1,135 @@ +using LehrerApp.Core.Models; +using LiteDB; +using Xunit; + +namespace LehrerApp.Data.Tests; + +public sealed class LiteDbContextTests +{ + [Fact] + public void MigratesLegacyEnrollmentAndSubjectTextWithoutLosingData() + { + using var temp = new TempDatabase(); + var groupId = Guid.NewGuid(); + var studentId = Guid.NewGuid(); + var membershipId = Guid.NewGuid(); + var examId = Guid.NewGuid(); + var gradeId = Guid.NewGuid(); + var entryId = Guid.NewGuid(); + var addedOn = new DateOnly(2026, 6, 23); + + using (var legacy = new LiteDatabase(temp.Path)) + { + legacy.GetCollection("subjects").Insert(new Subject { Name = "mathematik" }); + legacy.GetCollection("groups").Insert(new BsonDocument + { + ["_id"] = groupId, + [nameof(LearningGroup.Name)] = "Mathe G", + ["Subject"] = "Mathematik", + [nameof(LearningGroup.SchoolYear)] = "2025/26", + }); + legacy.GetCollection("enrollments").Insert(new BsonDocument + { + ["_id"] = membershipId, + [nameof(GroupMembership.StudentId)] = studentId, + [nameof(GroupMembership.GroupId)] = groupId, + ["SchoolYear"] = "2025/26", + ["EnrolledAt"] = BsonMapper.Global.Serialize(addedOn), + }); + legacy.GetCollection("exams").Insert(new BsonDocument + { + ["_id"] = examId, + [nameof(Exam.GroupId)] = groupId, + ["Subject"] = "Mathematik", + }); + legacy.GetCollection("grades").Insert(new BsonDocument + { + ["_id"] = gradeId, + [nameof(Grade.StudentId)] = studentId, + [nameof(Grade.GroupId)] = groupId, + ["SchoolYear"] = "2025/26", + }); + legacy.GetCollection("participation").Insert(new BsonDocument + { + ["_id"] = entryId, + [nameof(ParticipationEntry.SessionId)] = Guid.NewGuid(), + [nameof(ParticipationEntry.StudentId)] = studentId, + ["GroupId"] = groupId, + ["Date"] = BsonMapper.Global.Serialize(addedOn), + }); + } + + using (var context = new LiteDbContext(temp.Path)) + { + var group = context.Groups.FindById(groupId); + var membership = context.Memberships.FindById(membershipId); + Assert.NotNull(group); + Assert.NotNull(group.SubjectId); + Assert.Equal("mathematik", context.Subjects.FindById(group.SubjectId.Value)?.Name); + Assert.Equal(1, context.Subjects.Count()); + Assert.NotNull(membership); + Assert.Equal(addedOn, membership.AddedOn); + } + + using var migrated = new LiteDatabase(temp.Path); + Assert.DoesNotContain("enrollments", migrated.GetCollectionNames()); + Assert.Contains("group_memberships", migrated.GetCollectionNames()); + var rawGroup = migrated.GetCollection("groups").FindById(groupId); + var rawMembership = migrated.GetCollection("group_memberships").FindById(membershipId); + var rawExam = migrated.GetCollection("exams").FindById(examId); + var rawGrade = migrated.GetCollection("grades").FindById(gradeId); + var rawEntry = migrated.GetCollection("participation").FindById(entryId); + Assert.False(rawGroup.ContainsKey("Subject")); + Assert.False(rawMembership.ContainsKey("SchoolYear")); + Assert.False(rawMembership.ContainsKey("EnrolledAt")); + Assert.True(rawMembership.ContainsKey(nameof(GroupMembership.AddedOn))); + Assert.False(rawExam.ContainsKey("Subject")); + Assert.False(rawGrade.ContainsKey("SchoolYear")); + Assert.False(rawEntry.ContainsKey("GroupId")); + Assert.False(rawEntry.ContainsKey("Date")); + } + + [Fact] + public void RejectsASecondMembershipForTheSameStudentAndGroup() + { + using var temp = new TempDatabase(); + using var context = new LiteDbContext(temp.Path); + var studentId = Guid.NewGuid(); + var groupId = Guid.NewGuid(); + context.Memberships.Insert(new GroupMembership { StudentId = studentId, GroupId = groupId }); + + Assert.Throws(() => context.Memberships.Insert( + new GroupMembership { StudentId = studentId, GroupId = groupId })); + } + + [Fact] + public void RejectsASecondExamResultForTheSameStudentAndExam() + { + using var temp = new TempDatabase(); + using var context = new LiteDbContext(temp.Path); + var studentId = Guid.NewGuid(); + var examId = Guid.NewGuid(); + context.ExamResults.Insert(new ExamResult { StudentId = studentId, ExamId = examId }); + + Assert.Throws(() => context.ExamResults.Insert( + new ExamResult { StudentId = studentId, ExamId = examId })); + } + + private sealed class TempDatabase : IDisposable + { + private readonly string _directory = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), $"lehrerapp-tests-{Guid.NewGuid():N}"); + public string Path { get; } + + public TempDatabase() + { + Directory.CreateDirectory(_directory); + Path = System.IO.Path.Combine(_directory, "test.db"); + } + + public void Dispose() + { + if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true); + } + } +} diff --git a/LehrerApp.Data/LiteDbContext.cs b/LehrerApp.Data/LiteDbContext.cs index 0d795de..264439c 100644 --- a/LehrerApp.Data/LiteDbContext.cs +++ b/LehrerApp.Data/LiteDbContext.cs @@ -22,7 +22,7 @@ public class LiteDbContext : IDisposable public ILiteCollection Students => _db.GetCollection("students"); public ILiteCollection Groups => _db.GetCollection("groups"); - public ILiteCollection Enrollments => _db.GetCollection("enrollments"); + public ILiteCollection Memberships => _db.GetCollection("group_memberships"); public ILiteCollection Exams => _db.GetCollection("exams"); public ILiteCollection ExamResults => _db.GetCollection("exam_results"); public ILiteCollection Grades => _db.GetCollection("grades"); @@ -42,7 +42,11 @@ public class LiteDbContext : IDisposable private void MigrateExistingData() { + MigrateMemberships(); + RemoveRedundantLegacyFields(); + var groups = _db.GetCollection("groups"); + var subjects = _db.GetCollection("subjects"); var missingArchiveState = groups.FindAll() .Where(g => !g.ContainsKey(nameof(LearningGroup.IsActive))) .ToList(); @@ -51,6 +55,83 @@ public class LiteDbContext : IDisposable 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(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); + } + } + + 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(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() @@ -59,13 +140,16 @@ public class LiteDbContext : IDisposable Students.EnsureIndex(x => x.IsActive); Groups.EnsureIndex(x => x.SchoolYear); Groups.EnsureIndex(x => x.IsActive); - Enrollments.EnsureIndex(x => x.StudentId); - Enrollments.EnsureIndex(x => x.GroupId); - Enrollments.EnsureIndex(x => x.SchoolYear); + 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); GradingKeyTemplates.EnsureIndex(x => x.GradingSystem); @@ -80,8 +164,11 @@ public class LiteDbContext : IDisposable 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); 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); } diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs index 15f0f80..64b7498 100644 --- a/LehrerApp.Data/Repositories/AllRepositories.cs +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -9,10 +9,10 @@ public class StudentRepository(LiteDbContext db) : IStudentRepository public List GetAll(bool includeInactive = false) => (includeInactive ? db.Students.FindAll() : db.Students.Find(s => s.IsActive)) .OrderBy(s => s.LastName).ToList(); - public List GetByGroup(Guid groupId, string schoolYear) + public List GetByGroup(Guid groupId) { - var ids = db.Enrollments - .Find(e => e.GroupId == groupId && e.SchoolYear == schoolYear) + var ids = db.Memberships + .Find(e => e.GroupId == groupId) .Select(e => e.StudentId).ToHashSet(); return db.Students.Find(s => ids.Contains(s.Id)).OrderBy(s => s.LastName).ToList(); } @@ -31,11 +31,17 @@ public class GroupRepository(LiteDbContext db) : IGroupRepository ? db.Groups.Find(g => g.SchoolYear == schoolYear) : db.Groups.Find(g => g.SchoolYear == schoolYear && g.IsActive)) .OrderBy(g => g.Name).ToList(); - public void Save(LearningGroup g) { g.UpdatedAt = DateTime.UtcNow; db.Groups.Upsert(g); } + public void Save(LearningGroup g) + { + if (g.SubjectId is Guid subjectId && db.Subjects.FindById(subjectId) is null) + throw new InvalidOperationException("Das gewählte Fach existiert nicht mehr."); + g.UpdatedAt = DateTime.UtcNow; + db.Groups.Upsert(g); + } public void Delete(Guid id) { - foreach (var enrollment in db.Enrollments.Find(e => e.GroupId == id).ToList()) - db.Enrollments.Delete(enrollment.Id); + foreach (var membership in db.Memberships.Find(e => e.GroupId == id).ToList()) + db.Memberships.Delete(membership.Id); foreach (var exam in db.Exams.Find(e => e.GroupId == id).ToList()) { @@ -71,16 +77,22 @@ public class GroupRepository(LiteDbContext db) : IGroupRepository } } -public class EnrollmentRepository(LiteDbContext db) : IEnrollmentRepository +public class GroupMembershipRepository(LiteDbContext db) : IGroupMembershipRepository { - public List GetByStudent(Guid id) => - db.Enrollments.Find(e => e.StudentId == id).ToList(); - public List GetByGroup(Guid id) => - db.Enrollments.Find(e => e.GroupId == id).ToList(); - public List GetByGroupAndYear(Guid groupId, string sy) => - db.Enrollments.Find(e => e.GroupId == groupId && e.SchoolYear == sy).ToList(); - public void Save(Enrollment e) => db.Enrollments.Upsert(e); - public void Delete(Guid id) => db.Enrollments.Delete(id); + public List GetByStudent(Guid id) => + db.Memberships.Find(e => e.StudentId == id).ToList(); + public List GetByGroup(Guid id) => + db.Memberships.Find(e => e.GroupId == id).ToList(); + public GroupMembership? GetByStudentAndGroup(Guid studentId, Guid groupId) => + db.Memberships.FindOne(e => e.StudentId == studentId && e.GroupId == groupId); + public void Save(GroupMembership membership) + { + var existing = GetByStudentAndGroup(membership.StudentId, membership.GroupId); + if (existing is not null && existing.Id != membership.Id) + throw new InvalidOperationException("Der Schüler ist dieser Lerngruppe bereits zugeordnet."); + db.Memberships.Upsert(membership); + } + public void Delete(Guid id) => db.Memberships.Delete(id); } public class ExamRepository(LiteDbContext db) : IExamRepository @@ -240,10 +252,25 @@ public class SubjectRepository(LiteDbContext db) : ISubjectRepository { public List GetAll() => db.Subjects.FindAll().OrderBy(s => s.Name).ToList(); public Subject? GetById(Guid id) => db.Subjects.FindById(id); - public Subject? GetByName(string name) => - db.Subjects.FindOne(s => s.Name == name); - public void Save(Subject s) { s.UpdatedAt = DateTime.UtcNow; db.Subjects.Upsert(s); } - public void Delete(Guid id) => db.Subjects.Delete(id); + public Subject? GetByName(string name) => db.Subjects.FindAll().FirstOrDefault(s => + string.Equals(s.Name.Trim(), name.Trim(), StringComparison.OrdinalIgnoreCase)); + public void Save(Subject s) + { + s.Name = s.Name.Trim(); + s.ShortName = s.ShortName.Trim(); + if (s.Name.Length == 0) throw new ArgumentException("Der Fachname darf nicht leer sein."); + var duplicate = GetByName(s.Name); + if (duplicate is not null && duplicate.Id != s.Id) + throw new InvalidOperationException("Ein Fach mit diesem Namen existiert bereits."); + s.UpdatedAt = DateTime.UtcNow; + db.Subjects.Upsert(s); + } + public void Delete(Guid id) + { + if (db.Groups.Exists(g => g.SubjectId == id) || db.CompetencyDomains.Exists(d => d.SubjectId == id)) + throw new InvalidOperationException("Das Fach wird noch von einer Lerngruppe oder einem Kompetenzkatalog verwendet."); + db.Subjects.Delete(id); + } } public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index afe9418..f34a194 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -44,7 +44,7 @@ public static class AppBootstrapper // ── Repositories ────────────────────────────────────────────────────── services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs index cf9a862..60bffcd 100644 --- a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs @@ -13,6 +13,7 @@ public partial class DashboardViewModel : ObservableObject private static readonly CultureInfo De = new("de-DE"); private readonly IGroupRepository _groups; + private readonly ISubjectRepository _subjects; private readonly ILessonRepository _lessons; private readonly IExamRepository _exams; private readonly IWorkTaskRepository _tasks; @@ -34,10 +35,10 @@ public partial class DashboardViewModel : ObservableObject // Navigation-Callback – wird von App.axaml.cs verdrahtet public Action? OnNavigateToGroup { get; set; } - public DashboardViewModel(IGroupRepository groups, ILessonRepository lessons, + public DashboardViewModel(IGroupRepository groups, ISubjectRepository subjects, ILessonRepository lessons, IExamRepository exams, IWorkTaskRepository tasks, SchoolYearService sy) { - _groups = groups; _lessons = lessons; _exams = exams; _tasks = tasks; _sy = sy; + _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks; _sy = sy; Load(); } @@ -70,7 +71,12 @@ public partial class DashboardViewModel : ObservableObject CurrentGroups.Clear(); foreach (var g in groups.Values.OrderBy(g => g.Name)) - CurrentGroups.Add(new() { GroupId = g.Id, Name = g.Name, Subject = g.Subject ?? "" }); + CurrentGroups.Add(new() + { + GroupId = g.Id, + Name = g.Name, + Subject = g.SubjectId is Guid subjectId ? _subjects.GetById(subjectId)?.Name ?? "" : "", + }); CalendarMonth = FirstOfMonth(now); LoadCalendar(); diff --git a/LehrerApp.Desktop/ViewModels/Groups/ExamGradingViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ExamGradingViewModels.cs index 935bcc0..cfd678b 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ExamGradingViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ExamGradingViewModels.cs @@ -24,24 +24,24 @@ public partial class ExamGradingDialogViewModel : ObservableObject public ObservableCollection Rows { get; } = []; public ExamGradingDialogViewModel(IExamResultRepository results, IStudentRepository students, - IEnrollmentRepository enrollments, GradingService grading, Exam exam, Guid groupId, string schoolYear) + IGroupMembershipRepository memberships, GradingService grading, Exam exam, Guid groupId) { _results = results; _grading = grading; _exam = exam; Tasks = exam.Tasks.OrderBy(t => t.Nr).ToList(); _examMaxPoints = Tasks.Sum(t => t.MaxPoints); - var enrolled = students.GetByGroup(groupId, schoolYear); - var enrollmentList = enrollments.GetByGroupAndYear(groupId, schoolYear); + var enrolled = students.GetByGroup(groupId); + var membershipList = memberships.GetByGroup(groupId); var existing = results.GetByExam(exam.Id).ToDictionary(r => r.StudentId); foreach (var s in enrolled.OrderBy(s => s.LastName).ThenBy(s => s.FirstName)) { - var enrollment = enrollmentList.FirstOrDefault(e => e.StudentId == s.Id); - if (enrollment is not null && !IsEnrolledAtDate(enrollment, exam.Date)) continue; + var membership = membershipList.FirstOrDefault(e => e.StudentId == s.Id); + if (membership is not null && !IsMemberAtDate(membership, exam.Date)) continue; // Niveau-Klausur: nur Schüler mit passendem Niveau zeigen. Klausuren ohne // Niveau-Zuordnung gelten weiterhin für die ganze Gruppe. - if (exam.Niveau.HasValue && enrollment?.Niveau != exam.Niveau) continue; + if (exam.Niveau.HasValue && membership?.Niveau != exam.Niveau) continue; existing.TryGetValue(s.Id, out var result); var row = new ExamResultRow(s.Id, s.FullName, Tasks, result, _exam.GradingKey, _examMaxPoints, _grading); @@ -52,12 +52,12 @@ public partial class ExamGradingDialogViewModel : ObservableObject private void SaveRow(ExamResultRow row) => _results.Save(row.ToModel(_exam.Id)); - private static bool IsEnrolledAtDate(Enrollment e, DateOnly date) => e.Period switch + private static bool IsMemberAtDate(GroupMembership membership, DateOnly date) => membership.Period switch { - EnrollmentPeriod.H1Only => date.Month >= 8 || date.Month <= 1, - EnrollmentPeriod.H2Only => date.Month >= 2 && date.Month <= 7, - EnrollmentPeriod.Custom => (e.JoinedAt is null || date >= e.JoinedAt.Value) - && (e.LeftAt is null || date <= e.LeftAt.Value), + MembershipPeriod.H1Only => date.Month >= 8 || date.Month <= 1, + MembershipPeriod.H2Only => date.Month >= 2 && date.Month <= 7, + MembershipPeriod.Custom => (membership.JoinedAt is null || date >= membership.JoinedAt.Value) + && (membership.LeftAt is null || date <= membership.LeftAt.Value), _ => true, }; } diff --git a/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs index 4e23e8c..44daa37 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs @@ -289,7 +289,6 @@ public partial class ExamDialogViewModel : ObservableObject Result = _editingExam ?? new Exam { GroupId = _groupId }; Result.Title = Title.Trim(); Result.Date = date; - Result.Subject = _subjectName.Trim(); Result.ExamNumber = ExamNumber; Result.Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim(); Result.ReturnedAt = returnedAt; diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs index e9cba7e..240b180 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs @@ -12,7 +12,7 @@ namespace LehrerApp.Desktop.ViewModels.Groups; public partial class GroupListViewModel : ObservableObject { private readonly IGroupRepository _groups; - private readonly SchoolYearService _sy; + private readonly ISubjectRepository _subjects; public Action? OnNavigateToDetail { get; set; } public Func? OnAddGroup { get; set; } @@ -37,9 +37,9 @@ public partial class GroupListViewModel : ObservableObject public ObservableCollection SchoolYears { get; } = []; public ObservableCollection Groups { get; } = []; - public GroupListViewModel(IGroupRepository groups, SchoolYearService sy) + public GroupListViewModel(IGroupRepository groups, ISubjectRepository subjects, SchoolYearService sy) { - _groups = groups; _sy = sy; + _groups = groups; _subjects = subjects; foreach (var y in sy.RecentSchoolYears()) SchoolYears.Add(y); SelectedSchoolYear = sy.CurrentSchoolYear(); } @@ -63,11 +63,13 @@ public partial class GroupListViewModel : ObservableObject Groups.Clear(); var all = _groups.GetBySchoolYear(SelectedSchoolYear, includeInactive: ShowArchived) .Where(g => g.IsActive != ShowArchived); + var subjectNames = _subjects.GetAll().ToDictionary(s => s.Id, s => s.Name); var filtered = string.IsNullOrWhiteSpace(SearchText) ? all : all.Where(g => g.Name.Contains(SearchText, StringComparison.OrdinalIgnoreCase) - || (g.Subject?.Contains(SearchText, StringComparison.OrdinalIgnoreCase) ?? false)); + || (g.SubjectId is Guid id && subjectNames.GetValueOrDefault(id, "") + .Contains(SearchText, StringComparison.OrdinalIgnoreCase))); foreach (var g in filtered.OrderBy(g => g.Name)) - Groups.Add(new GroupListItem(g)); + Groups.Add(new GroupListItem(g, g.SubjectId is Guid id ? subjectNames.GetValueOrDefault(id, "") : "")); SelectedGroup = Groups.FirstOrDefault(g => g.Id == selectedId); OnPropertyChanged(nameof(ListSummary)); OnPropertyChanged(nameof(HasNoGroups)); @@ -136,15 +138,15 @@ public class GroupListItem public bool IsActive { get; } public string ArchiveActionLabel => IsActive ? "Archivieren" : "Wieder aktivieren"; - public GroupListItem(LearningGroup g) + public GroupListItem(LearningGroup g, string subjectName) { Id = g.Id; IsActive = g.IsActive; Name = g.Name; - Subject = g.Subject ?? ""; + Subject = subjectName; TypeLabel = g.Type == GroupType.Class ? "Klasse" : "Kurs"; GradingLabel = g.GradingSystem == GradingSystem.Grades1To6 ? "1–6" : "0–15"; - DisplayName = string.IsNullOrEmpty(g.Subject) ? g.Name : $"{g.Name} · {g.Subject}"; + DisplayName = string.IsNullOrEmpty(subjectName) ? g.Name : $"{g.Name} · {subjectName}"; Subtitle = $"{TypeLabel} · Stufe {g.GradeLevel} · Noten {GradingLabel} · {g.SchoolYear}"; } } @@ -155,7 +157,8 @@ public partial class GroupDetailViewModel : ObservableObject { private readonly IGroupRepository _groups; private readonly IStudentRepository _students; - private readonly IEnrollmentRepository _enrollments; + private readonly IGroupMembershipRepository _memberships; + private readonly ISubjectRepository _subjects; private readonly IExamRepository _exams; private readonly IGradeRepository _grades; @@ -171,6 +174,7 @@ public partial class GroupDetailViewModel : ObservableObject // bevor LoadGroup() läuft (siehe MainWindowViewModel.NavigateToGroupDetail), Group ist dann // kurzzeitig null — ein verschachtelter Pfad würde dafür jedes Mal einen Binding-Fehler loggen. public bool IsDifferentiated => Group?.IsDifferentiated ?? false; + public string SubjectName { get; private set; } = ""; partial void OnGroupChanged(LearningGroup? value) => OnPropertyChanged(nameof(IsDifferentiated)); @@ -187,10 +191,11 @@ public partial class GroupDetailViewModel : ObservableObject public Func? OnEvaluateExam { get; set; } public GroupDetailViewModel(IGroupRepository groups, IStudentRepository students, - IEnrollmentRepository enrollments, IExamRepository exams, IGradeRepository grades, + IGroupMembershipRepository memberships, ISubjectRepository subjects, + IExamRepository exams, IGradeRepository grades, ParticipationTabViewModel participationTab) { - _groups = groups; _students = students; _enrollments = enrollments; + _groups = groups; _students = students; _memberships = memberships; _subjects = subjects; _exams = exams; _grades = grades; ParticipationTab = participationTab; } @@ -199,6 +204,9 @@ public partial class GroupDetailViewModel : ObservableObject { Group = _groups.GetById(id); if (Group is null) return; + SubjectName = Group.SubjectId is Guid subjectId + ? _subjects.GetById(subjectId)?.Name ?? "" + : ""; GroupTitle = Group.Name; GroupSubtitle = $"{Group.SchoolYear} · " + $"{(Group.Type == GroupType.Class ? "Klasse" : "Kurs")} · " + @@ -221,14 +229,13 @@ public partial class GroupDetailViewModel : ObservableObject { if (Group is null) return; Students.Clear(); - var enrolled = _students.GetByGroup(Group.Id, Group.SchoolYear); - var enrollments = _enrollments.GetByGroupAndYear(Group.Id, Group.SchoolYear) - .ToDictionary(e => e.StudentId); + var enrolled = _students.GetByGroup(Group.Id); + var memberships = _memberships.GetByGroup(Group.Id).ToDictionary(e => e.StudentId); StudentCount = enrolled.Count; foreach (var s in enrolled) { - enrollments.TryGetValue(s.Id, out var enr); - var summary = new StudentSummary(s, enr) { OnChanged = SaveStudentNiveau }; + memberships.TryGetValue(s.Id, out var membership); + var summary = new StudentSummary(s, membership) { OnChanged = SaveStudentNiveau }; Students.Add(summary); } } @@ -236,11 +243,10 @@ public partial class GroupDetailViewModel : ObservableObject private void SaveStudentNiveau(StudentSummary summary) { if (Group is null) return; - var enrollment = _enrollments.GetByGroupAndYear(Group.Id, Group.SchoolYear) - .FirstOrDefault(e => e.StudentId == summary.Id); - if (enrollment is null) return; - enrollment.Niveau = summary.Niveau; - _enrollments.Save(enrollment); + var membership = _memberships.GetByStudentAndGroup(summary.Id, Group.Id); + if (membership is null) return; + membership.Niveau = summary.Niveau; + _memberships.Save(membership); } [RelayCommand] @@ -259,10 +265,9 @@ public partial class GroupDetailViewModel : ObservableObject private void RemoveStudent() { if (Group is null || SelectedStudent is null) return; - var enrollment = _enrollments.GetByGroupAndYear(Group.Id, Group.SchoolYear) - .FirstOrDefault(e => e.StudentId == SelectedStudent.Id); - if (enrollment is null) return; - _enrollments.Delete(enrollment.Id); + var membership = _memberships.GetByStudentAndGroup(SelectedStudent.Id, Group.Id); + if (membership is null) return; + _memberships.Delete(membership.Id); LoadStudents(); SelectedStudent = null; ParticipationTab.RefreshCurrentGrid(); @@ -422,18 +427,18 @@ public partial class StudentSummary : ObservableObject public Action? OnChanged { get; set; } - public StudentSummary(Core.Models.Student s, Enrollment? e) + public StudentSummary(Core.Models.Student s, GroupMembership? membership) { Id = s.Id; FullName = s.FullName; - PeriodLabel = e?.Period switch + PeriodLabel = membership?.Period switch { - EnrollmentPeriod.H1Only => "H1", - EnrollmentPeriod.H2Only => "H2", - EnrollmentPeriod.Custom => BuildCustomLabel(e), + MembershipPeriod.H1Only => "H1", + MembershipPeriod.H2Only => "H2", + MembershipPeriod.Custom => BuildCustomLabel(membership), _ => "", }; - _niveau = e?.Niveau; + _niveau = membership?.Niveau; } partial void OnNiveauChanged(Niveau? value) @@ -442,11 +447,11 @@ public partial class StudentSummary : ObservableObject OnChanged?.Invoke(this); } - private static string BuildCustomLabel(Enrollment e) + private static string BuildCustomLabel(GroupMembership membership) { - if (e.JoinedAt.HasValue && e.LeftAt.HasValue) return $"{e.JoinedAt:dd.MM.}–{e.LeftAt:dd.MM.}"; - if (e.JoinedAt.HasValue) return $"ab {e.JoinedAt:dd.MM.}"; - if (e.LeftAt.HasValue) return $"bis {e.LeftAt:dd.MM.}"; + if (membership.JoinedAt.HasValue && membership.LeftAt.HasValue) return $"{membership.JoinedAt:dd.MM.}–{membership.LeftAt:dd.MM.}"; + if (membership.JoinedAt.HasValue) return $"ab {membership.JoinedAt:dd.MM.}"; + if (membership.LeftAt.HasValue) return $"bis {membership.LeftAt:dd.MM.}"; return "Datum"; } } @@ -491,35 +496,34 @@ public class ExamSummary public partial class AddStudentToGroupDialogViewModel : ObservableObject { private readonly IStudentRepository _students; - private readonly IEnrollmentRepository _enrollments; + private readonly IGroupMembershipRepository _memberships; private readonly Guid _groupId; - private readonly string _schoolYear; [ObservableProperty] private string _searchText = ""; [ObservableProperty] private StudentPickerItem? _selectedStudent; [ObservableProperty] private string _validationMessage = ""; - [ObservableProperty] private EnrollmentPeriod _period = EnrollmentPeriod.FullYear; + [ObservableProperty] private MembershipPeriod _period = MembershipPeriod.FullYear; [ObservableProperty] private string _joinedAtText = ""; [ObservableProperty] private string _leftAtText = ""; - public bool IsFullYear { get => Period == EnrollmentPeriod.FullYear; set { if (value) Period = EnrollmentPeriod.FullYear; } } - public bool IsH1Only { get => Period == EnrollmentPeriod.H1Only; set { if (value) Period = EnrollmentPeriod.H1Only; } } - public bool IsH2Only { get => Period == EnrollmentPeriod.H2Only; set { if (value) Period = EnrollmentPeriod.H2Only; } } - public bool IsCustom { get => Period == EnrollmentPeriod.Custom; set { if (value) Period = EnrollmentPeriod.Custom; } } - public bool IsCustomPeriod => Period == EnrollmentPeriod.Custom; + public bool IsFullYear { get => Period == MembershipPeriod.FullYear; set { if (value) Period = MembershipPeriod.FullYear; } } + public bool IsH1Only { get => Period == MembershipPeriod.H1Only; set { if (value) Period = MembershipPeriod.H1Only; } } + public bool IsH2Only { get => Period == MembershipPeriod.H2Only; set { if (value) Period = MembershipPeriod.H2Only; } } + public bool IsCustom { get => Period == MembershipPeriod.Custom; set { if (value) Period = MembershipPeriod.Custom; } } + public bool IsCustomPeriod => Period == MembershipPeriod.Custom; public ObservableCollection AvailableStudents { get; } = []; - public Enrollment? Result { get; private set; } + public GroupMembership? Result { get; private set; } public AddStudentToGroupDialogViewModel(IStudentRepository students, - IEnrollmentRepository enrollments, Guid groupId, string schoolYear) + IGroupMembershipRepository memberships, Guid groupId) { - _students = students; _enrollments = enrollments; - _groupId = groupId; _schoolYear = schoolYear; + _students = students; _memberships = memberships; + _groupId = groupId; LoadAvailableStudents(); } - partial void OnPeriodChanged(EnrollmentPeriod value) + partial void OnPeriodChanged(MembershipPeriod value) { OnPropertyChanged(nameof(IsFullYear)); OnPropertyChanged(nameof(IsH1Only)); @@ -532,11 +536,11 @@ public partial class AddStudentToGroupDialogViewModel : ObservableObject private void LoadAvailableStudents() { - var alreadyEnrolled = _enrollments.GetByGroupAndYear(_groupId, _schoolYear) + var alreadyAssigned = _memberships.GetByGroup(_groupId) .Select(e => e.StudentId).ToHashSet(); var all = _students.GetAll(); var available = all - .Where(s => !alreadyEnrolled.Contains(s.Id)) + .Where(s => !alreadyAssigned.Contains(s.Id)) .Where(s => string.IsNullOrWhiteSpace(SearchText) || s.FullName.Contains(SearchText, StringComparison.OrdinalIgnoreCase)); AvailableStudents.Clear(); @@ -549,7 +553,7 @@ public partial class AddStudentToGroupDialogViewModel : ObservableObject if (SelectedStudent is null) { ValidationMessage = "Bitte einen Schüler auswählen."; return; } DateOnly? joinedAt = null, leftAt = null; - if (Period == EnrollmentPeriod.Custom) + if (Period == MembershipPeriod.Custom) { if (!string.IsNullOrWhiteSpace(JoinedAtText) && DateOnly.TryParseExact(JoinedAtText, "dd.MM.yyyy", @@ -561,16 +565,15 @@ public partial class AddStudentToGroupDialogViewModel : ObservableObject leftAt = l; } - Result = new Enrollment + Result = new GroupMembership { StudentId = SelectedStudent.Id, GroupId = _groupId, - SchoolYear = _schoolYear, Period = Period, JoinedAt = joinedAt, LeftAt = leftAt, }; - _enrollments.Save(Result); + _memberships.Save(Result); } } @@ -587,11 +590,8 @@ public partial class AddGroupDialogViewModel : ObservableObject { private readonly IGroupRepository _groups; private readonly ISubjectRepository _subjects; - private readonly SchoolYearService _sy; - private readonly IEnrollmentRepository _enrollments; private List _allSubjects = []; private LearningGroup? _editingGroup; - private string? _originalSchoolYear; public List TypeOptions { get; } = ["Klasse", "Kurs"]; [ObservableProperty] private string _selectedTypeName = "Kurs"; @@ -625,9 +625,9 @@ public partial class AddGroupDialogViewModel : ObservableObject public string SaveButtonText => _editingGroup is null ? "Anlegen" : "Speichern"; public AddGroupDialogViewModel(IGroupRepository groups, ISubjectRepository subjects, - SchoolYearService sy, IEnrollmentRepository enrollments) + SchoolYearService sy) { - _groups = groups; _subjects = subjects; _sy = sy; _enrollments = enrollments; + _groups = groups; _subjects = subjects; _allSubjects = subjects.GetAll(); KnownSubjectNames = _allSubjects.Select(s => s.Name).ToList(); SchoolYears = sy.RecentSchoolYears(3); @@ -642,10 +642,9 @@ public partial class AddGroupDialogViewModel : ObservableObject public void LoadForEdit(LearningGroup group) { _editingGroup = group; - _originalSchoolYear = group.SchoolYear; SelectedTypeName = group.Type == GroupType.Class ? "Klasse" : "Kurs"; Name = group.Name; - Subject = group.Subject ?? ""; + Subject = group.SubjectId is Guid subjectId ? _subjects.GetById(subjectId)?.Name ?? "" : ""; GradeLevel = group.GradeLevel; SelectedGradingName = group.GradingSystem == GradingSystem.Grades1To6 ? "Noten 1–6" : "Punkte 0–15"; @@ -684,7 +683,6 @@ public partial class AddGroupDialogViewModel : ObservableObject Result = _editingGroup ?? new LearningGroup(); Result.Name = Name.Trim(); - Result.Subject = subjectName; Result.SubjectId = subjectId; Result.Type = IsKurs ? GroupType.Course : GroupType.Class; Result.GradeLevel = GradeLevel; @@ -695,14 +693,5 @@ public partial class AddGroupDialogViewModel : ObservableObject Result.IsOwnClass = IsOwnClass; Result.IsDifferentiated = IsDifferentiated; _groups.Save(Result); - - if (_editingGroup is not null && _originalSchoolYear != SelectedSchoolYear) - { - foreach (var enrollment in _enrollments.GetByGroup(Result.Id)) - { - enrollment.SchoolYear = SelectedSchoolYear; - _enrollments.Save(enrollment); - } - } } } diff --git a/LehrerApp.Desktop/ViewModels/Groups/ParticipationGradeViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ParticipationGradeViewModels.cs index 94110eb..42c8f83 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ParticipationGradeViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ParticipationGradeViewModels.cs @@ -16,7 +16,7 @@ public partial class ParticipationGradeDialogViewModel : ObservableObject private readonly IParticipationRepository _entries; private readonly IParticipationAspectRepository _aspects; private readonly IStudentRepository _students; - private readonly IEnrollmentRepository _enrollments; + private readonly IGroupMembershipRepository _memberships; private readonly IGradeRepository _grades; private readonly GradingService _grading; private readonly Guid _groupId; @@ -39,11 +39,11 @@ public partial class ParticipationGradeDialogViewModel : ObservableObject public ParticipationGradeDialogViewModel( IParticipationSessionRepository sessions, IParticipationRepository entries, IParticipationAspectRepository aspects, IStudentRepository students, - IEnrollmentRepository enrollments, IGradeRepository grades, GradingService grading, + IGroupMembershipRepository memberships, IGradeRepository grades, GradingService grading, Guid groupId, string schoolYear, GradingSystem gradingSystem) { _sessions = sessions; _entries = entries; _aspects = aspects; - _students = students; _enrollments = enrollments; _grades = grades; + _students = students; _memberships = memberships; _grades = grades; _grading = grading; _groupId = groupId; _schoolYear = schoolYear; _gradingSystem = gradingSystem; @@ -83,14 +83,14 @@ public partial class ParticipationGradeDialogViewModel : ObservableObject .OrderBy(s => s.Date) .ToList(); - var studentList = _students.GetByGroup(_groupId, _schoolYear); - var enrollmentList = _enrollments.GetByGroupAndYear(_groupId, _schoolYear); + var studentList = _students.GetByGroup(_groupId); + var membershipList = _memberships.GetByGroup(_groupId); foreach (var student in studentList.OrderBy(s => s.LastName).ThenBy(s => s.FirstName)) { - var enrollment = enrollmentList.FirstOrDefault(e => e.StudentId == student.Id); + var membership = membershipList.FirstOrDefault(e => e.StudentId == student.Id); var relevantSessions = sessions - .Where(s => enrollment is null || IsEnrolledAtDate(enrollment, s.Date)) + .Where(s => membership is null || IsMemberAtDate(membership, s.Date)) .ToList(); var points = new List<(DateOnly Date, double Rating)>(); @@ -132,7 +132,6 @@ public partial class ParticipationGradeDialogViewModel : ObservableObject { StudentId = row.StudentId, GroupId = _groupId, - SchoolYear = _schoolYear, Category = GradeCategory.Participation, Note = noteTag, }; @@ -153,12 +152,12 @@ public partial class ParticipationGradeDialogViewModel : ObservableObject _ => true, }; - private static bool IsEnrolledAtDate(Enrollment e, DateOnly date) => e.Period switch + private static bool IsMemberAtDate(GroupMembership membership, DateOnly date) => membership.Period switch { - EnrollmentPeriod.H1Only => date.Month >= 8 || date.Month <= 1, - EnrollmentPeriod.H2Only => date.Month >= 2 && date.Month <= 7, - EnrollmentPeriod.Custom => (e.JoinedAt is null || date >= e.JoinedAt.Value) - && (e.LeftAt is null || date <= e.LeftAt.Value), + MembershipPeriod.H1Only => date.Month >= 8 || date.Month <= 1, + MembershipPeriod.H2Only => date.Month >= 2 && date.Month <= 7, + MembershipPeriod.Custom => (membership.JoinedAt is null || date >= membership.JoinedAt.Value) + && (membership.LeftAt is null || date <= membership.LeftAt.Value), _ => true, }; } diff --git a/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs index 89013c1..4b7abdf 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs @@ -15,7 +15,7 @@ public partial class ParticipationTabViewModel : ObservableObject private readonly IParticipationRepository _entries; private readonly IParticipationAspectRepository _aspects; private readonly IStudentRepository _students; - private readonly IEnrollmentRepository _enrollments; + private readonly IGroupMembershipRepository _memberships; private readonly IGroupRepository _groups; private readonly ICompetencyDomainRepository _competencyDomains; @@ -53,13 +53,13 @@ public partial class ParticipationTabViewModel : ObservableObject IParticipationRepository entries, IParticipationAspectRepository aspects, IStudentRepository students, - IEnrollmentRepository enrollments, + IGroupMembershipRepository memberships, IGroupRepository groups, ICompetencyDomainRepository competencyDomains) { _sessions = sessions; _entries = entries; _aspects = aspects; _students = students; - _enrollments = enrollments; _groups = groups; + _memberships = memberships; _groups = groups; _competencyDomains = competencyDomains; } @@ -128,18 +128,18 @@ public partial class ParticipationTabViewModel : ObservableObject StudentRows.Clear(); var session = _sessions.GetById(sessionId); var sessionDate = session?.Date ?? DateOnly.FromDateTime(DateTime.Today); - var students = _students.GetByGroup(_groupId, _schoolYear); - var enrollments = _enrollments.GetByGroupAndYear(_groupId, _schoolYear); + var students = _students.GetByGroup(_groupId); + var memberships = _memberships.GetByGroup(_groupId); var entries = _entries.GetBySession(sessionId); foreach (var s in students) { - var enrollment = enrollments.FirstOrDefault(e => e.StudentId == s.Id); - if (enrollment is not null && !IsEnrolledAtDate(enrollment, sessionDate)) + var membership = memberships.FirstOrDefault(e => e.StudentId == s.Id); + if (membership is not null && !IsMemberAtDate(membership, sessionDate)) continue; var entry = entries.FirstOrDefault(e => e.StudentId == s.Id) - ?? new ParticipationEntry { SessionId = sessionId, GroupId = _groupId, StudentId = s.Id }; + ?? new ParticipationEntry { SessionId = sessionId, StudentId = s.Id }; var row = new ParticipationStudentRow(s.Id, s.FullName, entry, Aspects.ToList(), ActiveCompetencyCodes); row.OnRatingChanged = (sid, key, val) => SaveRating(sessionId, sid, key, val); row.OnCompetencyRatingChanged = (sid, code, val) => SaveCompetencyRating(sessionId, sid, code, val); @@ -149,25 +149,22 @@ public partial class ParticipationTabViewModel : ObservableObject RebuildColumnsSignal++; } - private static bool IsEnrolledAtDate(Enrollment e, DateOnly date) => e.Period switch + private static bool IsMemberAtDate(GroupMembership membership, DateOnly date) => membership.Period switch { - EnrollmentPeriod.H1Only => date.Month >= 8 || date.Month <= 1, - EnrollmentPeriod.H2Only => date.Month >= 2 && date.Month <= 7, - EnrollmentPeriod.Custom => (e.JoinedAt is null || date >= e.JoinedAt.Value) - && (e.LeftAt is null || date <= e.LeftAt.Value), + MembershipPeriod.H1Only => date.Month >= 8 || date.Month <= 1, + MembershipPeriod.H2Only => date.Month >= 2 && date.Month <= 7, + MembershipPeriod.Custom => (membership.JoinedAt is null || date >= membership.JoinedAt.Value) + && (membership.LeftAt is null || date <= membership.LeftAt.Value), _ => true, }; private void SaveRating(Guid sessionId, Guid studentId, string key, int? value) { - var session = _sessions.GetById(sessionId); - var entry = _entries.GetBySessionAndStudent(sessionId, studentId) + var entry = _entries.GetBySessionAndStudent(sessionId, studentId) ?? new ParticipationEntry { SessionId = sessionId, - GroupId = _groupId, StudentId = studentId, - Date = session?.Date ?? DateOnly.FromDateTime(DateTime.Today), }; var existing = entry.Ratings.FirstOrDefault(r => r.Key == key); @@ -280,14 +277,11 @@ public partial class ParticipationTabViewModel : ObservableObject private void SaveCompetencyRating(Guid sessionId, Guid studentId, string code, int? value) { - var session = _sessions.GetById(sessionId); - var entry = _entries.GetBySessionAndStudent(sessionId, studentId) + var entry = _entries.GetBySessionAndStudent(sessionId, studentId) ?? new ParticipationEntry { SessionId = sessionId, - GroupId = _groupId, StudentId = studentId, - Date = session?.Date ?? DateOnly.FromDateTime(DateTime.Today), }; var existing = entry.CompetencyRatings.FirstOrDefault(r => r.Code == code); if (value is null) diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs index 059e54f..ea5912f 100644 --- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs @@ -110,7 +110,15 @@ public partial class SettingsViewModel : ObservableObject private void AddSubject() { if (string.IsNullOrWhiteSpace(NewName)) { ValidationMessage = "Name erforderlich."; return; } - _subjects.Save(new Subject { Name = NewName.Trim(), ShortName = NewShort.Trim() }); + try + { + _subjects.Save(new Subject { Name = NewName.Trim(), ShortName = NewShort.Trim() }); + } + catch (InvalidOperationException ex) + { + ValidationMessage = ex.Message; + return; + } NewName = ""; NewShort = ""; ValidationMessage = ""; LoadSubjects(); } @@ -119,7 +127,16 @@ public partial class SettingsViewModel : ObservableObject private void DeleteSubject(SubjectListItem? item) { if (item is null) return; - _subjects.Delete(item.Id); + try + { + _subjects.Delete(item.Id); + } + catch (InvalidOperationException ex) + { + ValidationMessage = ex.Message; + return; + } + ValidationMessage = ""; if (CatalogSubject?.Id == item.Id) CatalogSubject = null; LoadSubjects(); } diff --git a/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs b/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs index 3454ed0..d22428a 100644 --- a/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs @@ -68,8 +68,9 @@ public class StudentListItem public partial class StudentDetailViewModel : ObservableObject { private readonly IStudentRepository _students; - private readonly IEnrollmentRepository _enrollments; + private readonly IGroupMembershipRepository _memberships; private readonly IGroupRepository _groups; + private readonly ISubjectRepository _subjects; private readonly IDocumentationRepository _docs; [ObservableProperty] private Student? _student; @@ -79,7 +80,7 @@ public partial class StudentDetailViewModel : ObservableObject [ObservableProperty] private string _editLastName = ""; [ObservableProperty] private ContactItem? _selectedContact; - public ObservableCollection Enrollments { get; } = []; + public ObservableCollection GroupMemberships { get; } = []; public ObservableCollection Documentation { get; } = []; public ObservableCollection Contacts { get; } = []; public bool HasNoContacts => Contacts.Count == 0; @@ -87,11 +88,11 @@ public partial class StudentDetailViewModel : ObservableObject public Action? OnViewAddress { get; set; } public StudentDetailViewModel(IStudentRepository students, - IEnrollmentRepository enrollments, IGroupRepository groups, + IGroupMembershipRepository memberships, IGroupRepository groups, ISubjectRepository subjects, IDocumentationRepository docs) { - _students = students; _enrollments = enrollments; - _groups = groups; _docs = docs; + _students = students; _memberships = memberships; + _groups = groups; _subjects = subjects; _docs = docs; } public void LoadStudent(Guid id) @@ -102,12 +103,13 @@ public partial class StudentDetailViewModel : ObservableObject EditFirstName = Student.FirstName; EditLastName = Student.LastName; - Enrollments.Clear(); - foreach (var e in _enrollments.GetByStudent(Student.Id)) + GroupMemberships.Clear(); + foreach (var membership in _memberships.GetByStudent(Student.Id)) { - var g = _groups.GetById(e.GroupId); + var g = _groups.GetById(membership.GroupId); if (g is null) continue; - Enrollments.Add(new() { SchoolYear = e.SchoolYear, GroupName = g.Name, Subject = g.Subject ?? "" }); + var subject = g.SubjectId is Guid subjectId ? _subjects.GetById(subjectId)?.Name ?? "" : ""; + GroupMemberships.Add(new() { SchoolYear = g.SchoolYear, GroupName = g.Name, Subject = subject }); } LoadContacts(); @@ -195,7 +197,7 @@ public partial class StudentDetailViewModel : ObservableObject private bool CanViewSelectedAddress() => SelectedContact?.HasAddress == true; } -public class EnrollmentEntry { public string SchoolYear { get; set; } = ""; public string GroupName { get; set; } = ""; public string Subject { get; set; } = ""; } +public class GroupMembershipEntry { public string SchoolYear { get; set; } = ""; public string GroupName { get; set; } = ""; public string Subject { get; set; } = ""; } public class DocEntry { public string Date { get; set; } = ""; public string Title { get; set; } = ""; public string TypeLabel { get; set; } = ""; public bool IsConfidential { get; set; } } public class ContactItem diff --git a/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml.cs b/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml.cs index 9fe11bf..f50167b 100644 --- a/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml.cs +++ b/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml.cs @@ -32,9 +32,8 @@ public partial class GroupDetailView : UserControl var dialogVm = new AddStudentToGroupDialogViewModel( App.Services.GetRequiredService(), - App.Services.GetRequiredService(), - vm.Group.Id, - vm.Group.SchoolYear); + App.Services.GetRequiredService(), + vm.Group.Id); var dialog = new AddStudentToGroupDialog { DataContext = dialogVm }; var owner = TopLevel.GetTopLevel(this) as Window; @@ -62,7 +61,7 @@ public partial class GroupDetailView : UserControl App.Services.GetRequiredService(), App.Services.GetRequiredService(), groupId, vm.Group.SubjectId, vm.Group.GradeLevel, vm.Group.GradingSystem, - vm.Group.Subject ?? "", vm.Group.IsDifferentiated, editingExam, duplicateSource); + vm.SubjectName, vm.Group.IsDifferentiated, editingExam, duplicateSource); var dialog = new ExamDialog { DataContext = dialogVm }; var owner = TopLevel.GetTopLevel(this) as Window; @@ -85,9 +84,9 @@ public partial class GroupDetailView : UserControl var dialogVm = new ExamGradingDialogViewModel( App.Services.GetRequiredService(), App.Services.GetRequiredService(), - App.Services.GetRequiredService(), + App.Services.GetRequiredService(), App.Services.GetRequiredService(), - exam, vm.Group.Id, vm.Group.SchoolYear); + exam, vm.Group.Id); var dialog = new ExamGradingDialog { DataContext = dialogVm }; var owner = TopLevel.GetTopLevel(this) as Window; diff --git a/LehrerApp.Desktop/Views/Groups/ParticipationTabView.axaml.cs b/LehrerApp.Desktop/Views/Groups/ParticipationTabView.axaml.cs index e3dfe19..4f86034 100644 --- a/LehrerApp.Desktop/Views/Groups/ParticipationTabView.axaml.cs +++ b/LehrerApp.Desktop/Views/Groups/ParticipationTabView.axaml.cs @@ -153,7 +153,7 @@ public partial class ParticipationTabView : UserControl App.Services.GetRequiredService(), App.Services.GetRequiredService(), App.Services.GetRequiredService(), - App.Services.GetRequiredService(), + App.Services.GetRequiredService(), App.Services.GetRequiredService(), App.Services.GetRequiredService(), tabVm.GroupId, tabVm.SchoolYear, tabVm.GradingSystem); diff --git a/LehrerApp.Desktop/Views/Students/StudentDetailView.axaml b/LehrerApp.Desktop/Views/Students/StudentDetailView.axaml index 611ce85..51f0814 100644 --- a/LehrerApp.Desktop/Views/Students/StudentDetailView.axaml +++ b/LehrerApp.Desktop/Views/Students/StudentDetailView.axaml @@ -40,9 +40,9 @@ - + - + @@ -54,8 +54,8 @@ - + diff --git a/LehrerApp.sln b/LehrerApp.sln index 5b982b6..5dc2cd5 100644 --- a/LehrerApp.sln +++ b/LehrerApp.sln @@ -10,6 +10,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Api", "LehrerApp. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Desktop", "LehrerApp.Desktop\LehrerApp.Desktop.csproj", "{A1000005-0000-0000-0000-000000000005}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Data.Tests", "LehrerApp.Data.Tests\LehrerApp.Data.Tests.csproj", "{A1000006-0000-0000-0000-000000000006}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -26,5 +28,7 @@ Global {A1000004-0000-0000-0000-000000000004}.Debug|Any CPU.Build.0 = Debug|Any CPU {A1000005-0000-0000-0000-000000000005}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A1000005-0000-0000-0000-000000000005}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1000006-0000-0000-0000-000000000006}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1000006-0000-0000-0000-000000000006}.Debug|Any CPU.Build.0 = Debug|Any CPU EndGlobalSection EndGlobal diff --git a/TODO.md b/TODO.md index 68083a5..4cd29ad 100644 --- a/TODO.md +++ b/TODO.md @@ -33,7 +33,7 @@ Modelle `Exam`, `ExamTask`, `GradingKeyEntry`, `ExamResult` existieren bereits i **Niveaudifferenzierung (E/G/Förder) ist bereits umgesetzt** — nicht aus dieser Liste, sondern ein separater Bedarf (Nachteilsausgleich/Binnendifferenzierung): `LearningGroup.IsDifferentiated` -(Checkbox in den Stammdaten) blendet eine Niveau-Zuordnung je Schüler ein (`Enrollment.Niveau`, +(Checkbox in den Stammdaten) blendet eine Niveau-Zuordnung je Schüler ein (`GroupMembership.Niveau`, Schüler-Tab). Klausuren bekommen optional ein `Niveau`; Punkteeingabe und Auswertung filtern dann automatisch auf die passenden Schüler. Workflow: G-Klausur normal anlegen, per "Duplizieren" (1.1.4) die E-Variante mit eigenen Aufgaben/Punkten ableiten (Niveau wird beim Duplizieren bewusst @@ -109,7 +109,7 @@ Der Tab "Noten" in [GroupDetailView.axaml:83](LehrerApp.Desktop/Views/Groups/Gro sonstige Noten). Zelle zeigt Note/Punkte. - [ ] **2.1.2** Spalte "Gesamt" mit gewichtetem Durchschnitt über `GradingService.WeightedAverage()`. - [ ] **2.1.3** Sortierung nach Name / Gesamtnote, Umschalten Noten ↔ Punkte. -- [ ] **2.1.4** Halbjahresfilter (H1 / H2 / Gesamtjahr), berücksichtigt `Enrollment.Period`. +- [ ] **2.1.4** Halbjahresfilter (H1 / H2 / Gesamtjahr), berücksichtigt `GroupMembership.Period`. ### 2.2 Einzelnoten pflegen - [ ] **2.2.1** Dialog "Note hinzufügen": Kategorie (`GradeCategory`), Wert, Datum, Gewichtung, Notiz. @@ -276,7 +276,7 @@ Navigationspunkt "Arbeitszeit" ist ein `PlaceholderViewModel`. Grundfunktionen sind vorhanden ([StudentViewModels.cs](LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs), [GroupViewModels.cs](LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs)). -Niveau-Zuordnung je Schüler (E/G/Förder, `Enrollment.Niveau`) ist bereits umgesetzt, siehe +Niveau-Zuordnung je Schüler (E/G/Förder, `GroupMembership.Niveau`) ist bereits umgesetzt, siehe Hinweis in Kapitel 1 — betrifft auch Kurse, nicht nur Klassen. ### 7.1 Schüler @@ -291,9 +291,9 @@ Hinweis in Kapitel 1 — betrifft auch Kurse, nicht nur Klassen. `ToggleArchiveCommand` in `GroupListViewModel`), nicht Teil der aktuellen Klausuren-Arbeit, beim Review aber bestätigt. - [ ] **7.2.2** Gruppe ins neue Schuljahr übernehmen: Kopie mit gleicher Schülerschaft, - neues `SchoolYear`, neue `Enrollment`-Einträge. -- [ ] **7.2.3** Schüler aus einer Gruppe entfernen (`Enrollment.LeftAt` setzen statt löschen). -- [ ] **7.2.4** Umgang mit `EnrollmentPeriod.Custom` in allen Auswertungen prüfen + neues `SchoolYear`, neue `GroupMembership`-Einträge. +- [ ] **7.2.3** Schüler aus einer Gruppe entfernen (`GroupMembership.LeftAt` setzen statt löschen). +- [ ] **7.2.4** Umgang mit `MembershipPeriod.Custom` in allen Auswertungen prüfen (Schüler zählt nur im belegten Zeitraum). - [ ] **7.2.5** Archivansicht abgeschlossener Schuljahre (schreibgeschützt). diff --git a/docs/Datenmodell.md b/docs/Datenmodell.md new file mode 100644 index 0000000..5741cd9 --- /dev/null +++ b/docs/Datenmodell.md @@ -0,0 +1,73 @@ +# Datenmodell und Begriffe + +Dieses Dokument beschreibt die fachliche Bedeutung der zentralen Datensätze. +Es soll verhindern, dass technisch ähnliche Felder als unterschiedliche +Sachverhalte interpretiert oder dieselben Informationen mehrfach gespeichert +werden. + +## Lerngruppe (`LearningGroup`) + +Eine Lerngruppe ist die konkrete Unterrichtsgruppe eines Fachs in genau einem +Schuljahr. Sie kann als Klasse oder Kurs organisiert sein. + +- `Name`: frei gewählte Bezeichnung, zum Beispiel `5b NAT (BEN)` oder `Mathe G` +- `SubjectId`: Verweis auf das unterrichtete Fach +- `SchoolYear`: Schuljahr dieser konkreten Lerngruppe +- `GradeLevel`: Klassen- beziehungsweise Jahrgangsstufe + +Der Fachname wird ausschließlich im `Subject`-Stammdatensatz gepflegt. Die +Lerngruppe speichert keine zweite Kopie des Fachnamens. + +## Gruppenzuordnung (`GroupMembership`) + +Eine Gruppenzuordnung verbindet einen Schüler mit einer Lerngruppe. Sie ist +keine Aufnahme oder Einschreibung an der Schule. + +- `StudentId`: Schüler +- `GroupId`: Lerngruppe +- `AddedOn`: Tag, an dem die Zuordnung in der App angelegt wurde +- `Period`: ganzes Schuljahr, erstes Halbjahr, zweites Halbjahr oder eigener Zeitraum +- `JoinedAt` / `LeftAt`: Grenzen eines eigenen Teilnahmezeitraums +- `Niveau`: optionale Niveaudifferenzierung + +Das Schuljahr wird über die Lerngruppe ermittelt und deshalb nicht zusätzlich +in der Gruppenzuordnung gespeichert. Für eine spätere echte Schulaufnahme wäre +ein eigenes Feld wie `Student.SchoolEntryDate` zu verwenden. + +Pro Kombination aus Schüler und Lerngruppe darf es höchstens eine Zuordnung +geben. + +## Bewusst gespeicherte Momentaufnahmen + +Einige berechnete Werte bleiben absichtlich gespeichert: + +- `ExamResult.TotalPoints` und `ExamResult.Grade` halten das zuletzt berechnete + Klausurergebnis fest. +- `Exam.GradingKey` hält den für die konkrete Klausur verwendeten Notenschlüssel + fest und ist unabhängig von später geänderten Vorlagen. +- `Exam.Tasks` hält die Aufgabenstruktur der konkreten Klausur fest. + +Änderungen an Aufgaben oder Notenschlüssel müssen die betroffenen Ergebnisse +kontrolliert neu berechnen. Diese Werte sind daher fachliche Momentaufnahmen und +nicht bloß unkontrollierte Kopien. + +## Bewusste Denormalisierung + +`Lesson.GroupId` bleibt zusätzlich zu `Lesson.UnitId` gespeichert. Dadurch kann +der häufige Kalenderzugriff auf alle Stunden einer Lerngruppe direkt indiziert +werden. Beim späteren Ausbau der Unterrichtsplanung muss sichergestellt werden, +dass `Lesson.GroupId` mit der Lerngruppe der zugehörigen Einheit übereinstimmt. + +## Eindeutige Schlüssel + +Die Datenbank schützt folgende Kombinationen mit eindeutigen Indizes: + +- Gruppenzuordnung: `StudentId + GroupId` +- Klausurergebnis: `ExamId + StudentId` +- Mitarbeitseintrag: `SessionId + StudentId` +- Fach: normalisierter Fachname + +Altdaten werden beim Öffnen der Datenbank automatisch migriert. Die Migration +verknüpft bisherige Fachtexte mit den Fachstammdaten, benennt die bisherige +`enrollments`-Collection in `group_memberships` um und entfernt daraus das +doppelt gespeicherte Schuljahr.