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.