aufräumen

This commit is contained in:
2026-08-12 17:45:02 +02:00
parent 0823a023c9
commit a1e722ace1
27 changed files with 542 additions and 197 deletions
-1
View File
@@ -19,7 +19,6 @@
<!-- API --> <!-- API -->
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" /> <PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
<PackageVersion Include="System.Text.Json" Version="10.0.0" />
<!-- Tests --> <!-- Tests -->
<PackageVersion Include="xunit" Version="2.9.3" /> <PackageVersion Include="xunit" Version="2.9.3" />
-1
View File
@@ -9,6 +9,5 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="LiteDB" /> <PackageReference Include="LiteDB" />
<PackageReference Include="System.Text.Json" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+2 -1
View File
@@ -7,8 +7,9 @@ public class ReadableSnapshot
public DateTime ExportedAt { get; set; } public DateTime ExportedAt { get; set; }
public ReadableSnapshotMeta Meta { get; set; } = new(); public ReadableSnapshotMeta Meta { get; set; } = new();
public List<LearningGroup> Groups { get; set; } = []; public List<LearningGroup> Groups { get; set; } = [];
public List<Subject> Subjects { get; set; } = [];
public List<Student> Students { get; set; } = []; public List<Student> Students { get; set; } = [];
public List<Enrollment> Enrollments { get; set; } = []; public List<GroupMembership> Memberships { get; set; } = [];
} }
public class ReadableSnapshotMeta public class ReadableSnapshotMeta
+6 -6
View File
@@ -6,7 +6,7 @@ public interface IStudentRepository
{ {
Student? GetById(Guid id); Student? GetById(Guid id);
List<Student> GetAll(bool includeInactive = false); List<Student> GetAll(bool includeInactive = false);
List<Student> GetByGroup(Guid groupId, string schoolYear); List<Student> GetByGroup(Guid groupId);
void Save(Student student); void Save(Student student);
void Delete(Guid id); void Delete(Guid id);
} }
@@ -18,12 +18,12 @@ public interface IGroupRepository
void Save(LearningGroup group); void Save(LearningGroup group);
void Delete(Guid id); void Delete(Guid id);
} }
public interface IEnrollmentRepository public interface IGroupMembershipRepository
{ {
List<Enrollment> GetByStudent(Guid studentId); List<GroupMembership> GetByStudent(Guid studentId);
List<Enrollment> GetByGroup(Guid groupId); List<GroupMembership> GetByGroup(Guid groupId);
List<Enrollment> GetByGroupAndYear(Guid groupId, string schoolYear); GroupMembership? GetByStudentAndGroup(Guid studentId, Guid groupId);
void Save(Enrollment enrollment); void Save(GroupMembership membership);
void Delete(Guid id); void Delete(Guid id);
} }
public interface IExamRepository public interface IExamRepository
-1
View File
@@ -6,7 +6,6 @@ public class Exam
public Guid GroupId { get; set; } public Guid GroupId { get; set; }
public string Title { get; set; } = ""; public string Title { get; set; } = "";
public DateOnly Date { get; set; } public DateOnly Date { get; set; }
public string Subject { get; set; } = "";
public int? ExamNumber { get; set; } public int? ExamNumber { get; set; }
public List<ExamTask> Tasks { get; set; } = []; public List<ExamTask> Tasks { get; set; } = [];
public List<GradingKeyEntry> GradingKey { get; set; } = []; public List<GradingKeyEntry> GradingKey { get; set; } = [];
+12 -9
View File
@@ -5,7 +5,6 @@ public class LearningGroup
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = ""; public string Name { get; set; } = "";
public GroupType Type { get; set; } public GroupType Type { get; set; }
public string? Subject { get; set; }
public Guid? SubjectId { get; set; } public Guid? SubjectId { get; set; }
public string SchoolYear { get; set; } = ""; public string SchoolYear { get; set; } = "";
public int GradeLevel { get; set; } public int GradeLevel { get; set; }
@@ -17,19 +16,23 @@ public class LearningGroup
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
} }
public class Enrollment /// <summary>
/// Mitgliedschaft eines Schülers in einer Lerngruppe. Das Schuljahr gehört zur
/// Lerngruppe und wird deshalb hier nicht ein zweites Mal gespeichert.
/// </summary>
public class GroupMembership
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
public Guid StudentId { get; set; } public Guid StudentId { get; set; }
public Guid GroupId { get; set; } public Guid GroupId { get; set; }
public string SchoolYear { get; set; } = ""; /// <summary>Tag, an dem die Zuordnung in der App angelegt wurde.</summary>
public DateOnly EnrolledAt { get; set; } = DateOnly.FromDateTime(DateTime.Today); public DateOnly AddedOn { get; set; } = DateOnly.FromDateTime(DateTime.Today);
public EnrollmentPeriod Period { get; set; } = EnrollmentPeriod.FullYear; public MembershipPeriod Period { get; set; } = MembershipPeriod.FullYear;
public DateOnly? JoinedAt { get; set; } public DateOnly? JoinedAt { get; set; }
public DateOnly? LeftAt { get; set; } public DateOnly? LeftAt { get; set; }
public Niveau? Niveau { 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 GroupType { Class, Course }
public enum GradingSystem { Grades1To6, Points0To15 } public enum GradingSystem { Grades1To6, Points0To15 }
public enum Niveau { E, G, Foerder } public enum Niveau { E, G, Foerder }
-2
View File
@@ -16,9 +16,7 @@ public class ParticipationEntry
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
public Guid SessionId { get; set; } public Guid SessionId { get; set; }
public Guid GroupId { get; set; }
public Guid StudentId { get; set; } public Guid StudentId { get; set; }
public DateOnly Date { get; set; } = DateOnly.FromDateTime(DateTime.Today);
public List<AspectRating> Ratings { get; set; } = []; public List<AspectRating> Ratings { get; set; } = [];
public List<CompetencyRating> CompetencyRatings { get; set; } = []; public List<CompetencyRating> CompetencyRatings { get; set; } = [];
public string? Note { get; set; } public string? Note { get; set; }
-3
View File
@@ -5,7 +5,6 @@ public class Grade
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
public Guid StudentId { get; set; } public Guid StudentId { get; set; }
public Guid GroupId { get; set; } public Guid GroupId { get; set; }
public string SchoolYear { get; set; } = "";
public GradeCategory Category { get; set; } public GradeCategory Category { get; set; }
public string Value { get; set; } = ""; public string Value { get; set; } = "";
public DateOnly Date { get; set; } public DateOnly Date { get; set; }
@@ -20,8 +19,6 @@ public class Unit
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
public Guid GroupId { get; set; } public Guid GroupId { get; set; }
public string Title { get; set; } = ""; public string Title { get; set; } = "";
public string Subject { get; set; } = "";
public string SchoolYear { get; set; } = "";
public DateOnly? StartDate { get; set; } public DateOnly? StartDate { get; set; }
public DateOnly? EndDate { get; set; } public DateOnly? EndDate { get; set; }
public List<string> Competencies { get; set; } = []; public List<string> Competencies { get; set; } = [];
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LehrerApp.Data\LehrerApp.Data.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
+135
View File
@@ -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<Subject>("subjects").Insert(new Subject { Name = "mathematik" });
legacy.GetCollection<BsonDocument>("groups").Insert(new BsonDocument
{
["_id"] = groupId,
[nameof(LearningGroup.Name)] = "Mathe G",
["Subject"] = "Mathematik",
[nameof(LearningGroup.SchoolYear)] = "2025/26",
});
legacy.GetCollection<BsonDocument>("enrollments").Insert(new BsonDocument
{
["_id"] = membershipId,
[nameof(GroupMembership.StudentId)] = studentId,
[nameof(GroupMembership.GroupId)] = groupId,
["SchoolYear"] = "2025/26",
["EnrolledAt"] = BsonMapper.Global.Serialize(addedOn),
});
legacy.GetCollection<BsonDocument>("exams").Insert(new BsonDocument
{
["_id"] = examId,
[nameof(Exam.GroupId)] = groupId,
["Subject"] = "Mathematik",
});
legacy.GetCollection<BsonDocument>("grades").Insert(new BsonDocument
{
["_id"] = gradeId,
[nameof(Grade.StudentId)] = studentId,
[nameof(Grade.GroupId)] = groupId,
["SchoolYear"] = "2025/26",
});
legacy.GetCollection<BsonDocument>("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<BsonDocument>("groups").FindById(groupId);
var rawMembership = migrated.GetCollection<BsonDocument>("group_memberships").FindById(membershipId);
var rawExam = migrated.GetCollection<BsonDocument>("exams").FindById(examId);
var rawGrade = migrated.GetCollection<BsonDocument>("grades").FindById(gradeId);
var rawEntry = migrated.GetCollection<BsonDocument>("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<LiteException>(() => 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<LiteException>(() => 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);
}
}
}
+91 -4
View File
@@ -22,7 +22,7 @@ public class LiteDbContext : IDisposable
public ILiteCollection<Student> Students => _db.GetCollection<Student>("students"); public ILiteCollection<Student> Students => _db.GetCollection<Student>("students");
public ILiteCollection<LearningGroup> Groups => _db.GetCollection<LearningGroup>("groups"); public ILiteCollection<LearningGroup> Groups => _db.GetCollection<LearningGroup>("groups");
public ILiteCollection<Enrollment> Enrollments => _db.GetCollection<Enrollment>("enrollments"); public ILiteCollection<GroupMembership> Memberships => _db.GetCollection<GroupMembership>("group_memberships");
public ILiteCollection<Exam> Exams => _db.GetCollection<Exam>("exams"); public ILiteCollection<Exam> Exams => _db.GetCollection<Exam>("exams");
public ILiteCollection<ExamResult> ExamResults => _db.GetCollection<ExamResult>("exam_results"); public ILiteCollection<ExamResult> ExamResults => _db.GetCollection<ExamResult>("exam_results");
public ILiteCollection<Grade> Grades => _db.GetCollection<Grade>("grades"); public ILiteCollection<Grade> Grades => _db.GetCollection<Grade>("grades");
@@ -42,7 +42,11 @@ public class LiteDbContext : IDisposable
private void MigrateExistingData() private void MigrateExistingData()
{ {
MigrateMemberships();
RemoveRedundantLegacyFields();
var groups = _db.GetCollection<BsonDocument>("groups"); var groups = _db.GetCollection<BsonDocument>("groups");
var subjects = _db.GetCollection<Subject>("subjects");
var missingArchiveState = groups.FindAll() var missingArchiveState = groups.FindAll()
.Where(g => !g.ContainsKey(nameof(LearningGroup.IsActive))) .Where(g => !g.ContainsKey(nameof(LearningGroup.IsActive)))
.ToList(); .ToList();
@@ -51,6 +55,83 @@ public class LiteDbContext : IDisposable
group[nameof(LearningGroup.IsActive)] = true; group[nameof(LearningGroup.IsActive)] = true;
groups.Update(group); groups.Update(group);
} }
// Subject war anfangs nur als Text in der Gruppe gespeichert. Alte
// Werte werden einmalig an die Fachstammdaten angebunden und danach
// entfernt, damit es nur noch eine führende Fachbezeichnung gibt.
foreach (var group in groups.FindAll().ToList())
{
var changed = false;
var legacyName = group.TryGetValue("Subject", out var value) && value.IsString
? value.AsString.Trim()
: "";
var hasSubjectId = group.TryGetValue(nameof(LearningGroup.SubjectId), out var id)
&& id.IsGuid && subjects.FindById(id.AsGuid) is not null;
if (!hasSubjectId && legacyName.Length > 0)
{
var subject = subjects.FindAll().FirstOrDefault(s =>
string.Equals(s.Name.Trim(), legacyName, StringComparison.OrdinalIgnoreCase));
if (subject is null)
{
subject = new Subject { Name = legacyName };
subjects.Insert(subject);
}
group[nameof(LearningGroup.SubjectId)] = subject.Id;
changed = true;
}
else if (!hasSubjectId && group.ContainsKey(nameof(LearningGroup.SubjectId)))
{
group[nameof(LearningGroup.SubjectId)] = BsonValue.Null;
changed = true;
}
changed |= group.Remove("Subject");
if (changed) groups.Update(group);
}
}
private void MigrateMemberships()
{
const string oldName = "enrollments";
const string newName = "group_memberships";
var names = _db.GetCollectionNames().ToHashSet(StringComparer.OrdinalIgnoreCase);
if (names.Contains(oldName) && !names.Contains(newName))
_db.RenameCollection(oldName, newName);
var memberships = _db.GetCollection<BsonDocument>(newName);
foreach (var membership in memberships.FindAll().ToList())
{
var changed = false;
if (!membership.ContainsKey(nameof(GroupMembership.AddedOn)) &&
membership.TryGetValue("EnrolledAt", out var enrolledAt))
{
membership[nameof(GroupMembership.AddedOn)] = enrolledAt;
changed = true;
}
changed |= membership.Remove("EnrolledAt");
changed |= membership.Remove("SchoolYear");
if (changed) memberships.Update(membership);
}
}
private void RemoveRedundantLegacyFields()
{
RemoveFields("exams", "Subject");
RemoveFields("units", "Subject", "SchoolYear");
RemoveFields("grades", "SchoolYear");
RemoveFields("participation", "GroupId", "Date");
}
private void RemoveFields(string collectionName, params string[] fieldNames)
{
var collection = _db.GetCollection<BsonDocument>(collectionName);
foreach (var document in collection.FindAll().ToList())
{
var changed = false;
foreach (var fieldName in fieldNames) changed |= document.Remove(fieldName);
if (changed) collection.Update(document);
}
} }
private void EnsureIndexes() private void EnsureIndexes()
@@ -59,13 +140,16 @@ public class LiteDbContext : IDisposable
Students.EnsureIndex(x => x.IsActive); Students.EnsureIndex(x => x.IsActive);
Groups.EnsureIndex(x => x.SchoolYear); Groups.EnsureIndex(x => x.SchoolYear);
Groups.EnsureIndex(x => x.IsActive); Groups.EnsureIndex(x => x.IsActive);
Enrollments.EnsureIndex(x => x.StudentId); Memberships.EnsureIndex(x => x.StudentId);
Enrollments.EnsureIndex(x => x.GroupId); Memberships.EnsureIndex(x => x.GroupId);
Enrollments.EnsureIndex(x => x.SchoolYear); Memberships.EnsureIndex("ux_student_group",
BsonExpression.Create("STRING($.StudentId) + ':' + STRING($.GroupId)"), unique: true);
Exams.EnsureIndex(x => x.GroupId); Exams.EnsureIndex(x => x.GroupId);
Exams.EnsureIndex(x => x.Status); Exams.EnsureIndex(x => x.Status);
ExamResults.EnsureIndex(x => x.ExamId); ExamResults.EnsureIndex(x => x.ExamId);
ExamResults.EnsureIndex(x => x.StudentId); 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.StudentId);
Grades.EnsureIndex(x => x.GroupId); Grades.EnsureIndex(x => x.GroupId);
GradingKeyTemplates.EnsureIndex(x => x.GradingSystem); GradingKeyTemplates.EnsureIndex(x => x.GradingSystem);
@@ -80,8 +164,11 @@ public class LiteDbContext : IDisposable
ParticipationSessions.EnsureIndex(x => x.Date); ParticipationSessions.EnsureIndex(x => x.Date);
ParticipationEntries.EnsureIndex(x => x.SessionId); ParticipationEntries.EnsureIndex(x => x.SessionId);
ParticipationEntries.EnsureIndex(x => x.StudentId); ParticipationEntries.EnsureIndex(x => x.StudentId);
ParticipationEntries.EnsureIndex("ux_session_student",
BsonExpression.Create("STRING($.SessionId) + ':' + STRING($.StudentId)"), unique: true);
ParticipationAspects.EnsureIndex(x => x.GroupId); ParticipationAspects.EnsureIndex(x => x.GroupId);
Subjects.EnsureIndex(x => x.Name); 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.SubjectId);
CompetencyDomains.EnsureIndex(x => x.GradeLevel); CompetencyDomains.EnsureIndex(x => x.GradeLevel);
} }
+46 -19
View File
@@ -9,10 +9,10 @@ public class StudentRepository(LiteDbContext db) : IStudentRepository
public List<Student> GetAll(bool includeInactive = false) => public List<Student> GetAll(bool includeInactive = false) =>
(includeInactive ? db.Students.FindAll() : db.Students.Find(s => s.IsActive)) (includeInactive ? db.Students.FindAll() : db.Students.Find(s => s.IsActive))
.OrderBy(s => s.LastName).ToList(); .OrderBy(s => s.LastName).ToList();
public List<Student> GetByGroup(Guid groupId, string schoolYear) public List<Student> GetByGroup(Guid groupId)
{ {
var ids = db.Enrollments var ids = db.Memberships
.Find(e => e.GroupId == groupId && e.SchoolYear == schoolYear) .Find(e => e.GroupId == groupId)
.Select(e => e.StudentId).ToHashSet(); .Select(e => e.StudentId).ToHashSet();
return db.Students.Find(s => ids.Contains(s.Id)).OrderBy(s => s.LastName).ToList(); 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)
: db.Groups.Find(g => g.SchoolYear == schoolYear && g.IsActive)) : db.Groups.Find(g => g.SchoolYear == schoolYear && g.IsActive))
.OrderBy(g => g.Name).ToList(); .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) public void Delete(Guid id)
{ {
foreach (var enrollment in db.Enrollments.Find(e => e.GroupId == id).ToList()) foreach (var membership in db.Memberships.Find(e => e.GroupId == id).ToList())
db.Enrollments.Delete(enrollment.Id); db.Memberships.Delete(membership.Id);
foreach (var exam in db.Exams.Find(e => e.GroupId == id).ToList()) 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<Enrollment> GetByStudent(Guid id) => public List<GroupMembership> GetByStudent(Guid id) =>
db.Enrollments.Find(e => e.StudentId == id).ToList(); db.Memberships.Find(e => e.StudentId == id).ToList();
public List<Enrollment> GetByGroup(Guid id) => public List<GroupMembership> GetByGroup(Guid id) =>
db.Enrollments.Find(e => e.GroupId == id).ToList(); db.Memberships.Find(e => e.GroupId == id).ToList();
public List<Enrollment> GetByGroupAndYear(Guid groupId, string sy) => public GroupMembership? GetByStudentAndGroup(Guid studentId, Guid groupId) =>
db.Enrollments.Find(e => e.GroupId == groupId && e.SchoolYear == sy).ToList(); db.Memberships.FindOne(e => e.StudentId == studentId && e.GroupId == groupId);
public void Save(Enrollment e) => db.Enrollments.Upsert(e); public void Save(GroupMembership membership)
public void Delete(Guid id) => db.Enrollments.Delete(id); {
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 public class ExamRepository(LiteDbContext db) : IExamRepository
@@ -240,10 +252,25 @@ public class SubjectRepository(LiteDbContext db) : ISubjectRepository
{ {
public List<Subject> GetAll() => db.Subjects.FindAll().OrderBy(s => s.Name).ToList(); public List<Subject> GetAll() => db.Subjects.FindAll().OrderBy(s => s.Name).ToList();
public Subject? GetById(Guid id) => db.Subjects.FindById(id); public Subject? GetById(Guid id) => db.Subjects.FindById(id);
public Subject? GetByName(string name) => public Subject? GetByName(string name) => db.Subjects.FindAll().FirstOrDefault(s =>
db.Subjects.FindOne(s => s.Name == name); string.Equals(s.Name.Trim(), name.Trim(), StringComparison.OrdinalIgnoreCase));
public void Save(Subject s) { s.UpdatedAt = DateTime.UtcNow; db.Subjects.Upsert(s); } public void Save(Subject s)
public void Delete(Guid id) => db.Subjects.Delete(id); {
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 public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository
+1 -1
View File
@@ -44,7 +44,7 @@ public static class AppBootstrapper
// ── Repositories ────────────────────────────────────────────────────── // ── Repositories ──────────────────────────────────────────────────────
services.AddSingleton<IStudentRepository, StudentRepository>(); services.AddSingleton<IStudentRepository, StudentRepository>();
services.AddSingleton<IGroupRepository, GroupRepository>(); services.AddSingleton<IGroupRepository, GroupRepository>();
services.AddSingleton<IEnrollmentRepository, EnrollmentRepository>(); services.AddSingleton<IGroupMembershipRepository, GroupMembershipRepository>();
services.AddSingleton<IExamRepository, ExamRepository>(); services.AddSingleton<IExamRepository, ExamRepository>();
services.AddSingleton<IExamResultRepository, ExamResultRepository>(); services.AddSingleton<IExamResultRepository, ExamResultRepository>();
services.AddSingleton<IGradeRepository, GradeRepository>(); services.AddSingleton<IGradeRepository, GradeRepository>();
@@ -13,6 +13,7 @@ public partial class DashboardViewModel : ObservableObject
private static readonly CultureInfo De = new("de-DE"); private static readonly CultureInfo De = new("de-DE");
private readonly IGroupRepository _groups; private readonly IGroupRepository _groups;
private readonly ISubjectRepository _subjects;
private readonly ILessonRepository _lessons; private readonly ILessonRepository _lessons;
private readonly IExamRepository _exams; private readonly IExamRepository _exams;
private readonly IWorkTaskRepository _tasks; private readonly IWorkTaskRepository _tasks;
@@ -34,10 +35,10 @@ public partial class DashboardViewModel : ObservableObject
// Navigation-Callback wird von App.axaml.cs verdrahtet // Navigation-Callback wird von App.axaml.cs verdrahtet
public Action<Guid>? OnNavigateToGroup { get; set; } public Action<Guid>? OnNavigateToGroup { get; set; }
public DashboardViewModel(IGroupRepository groups, ILessonRepository lessons, public DashboardViewModel(IGroupRepository groups, ISubjectRepository subjects, ILessonRepository lessons,
IExamRepository exams, IWorkTaskRepository tasks, SchoolYearService sy) 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(); Load();
} }
@@ -70,7 +71,12 @@ public partial class DashboardViewModel : ObservableObject
CurrentGroups.Clear(); CurrentGroups.Clear();
foreach (var g in groups.Values.OrderBy(g => g.Name)) 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); CalendarMonth = FirstOfMonth(now);
LoadCalendar(); LoadCalendar();
@@ -24,24 +24,24 @@ public partial class ExamGradingDialogViewModel : ObservableObject
public ObservableCollection<ExamResultRow> Rows { get; } = []; public ObservableCollection<ExamResultRow> Rows { get; } = [];
public ExamGradingDialogViewModel(IExamResultRepository results, IStudentRepository students, 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; _results = results; _grading = grading; _exam = exam;
Tasks = exam.Tasks.OrderBy(t => t.Nr).ToList(); Tasks = exam.Tasks.OrderBy(t => t.Nr).ToList();
_examMaxPoints = Tasks.Sum(t => t.MaxPoints); _examMaxPoints = Tasks.Sum(t => t.MaxPoints);
var enrolled = students.GetByGroup(groupId, schoolYear); var enrolled = students.GetByGroup(groupId);
var enrollmentList = enrollments.GetByGroupAndYear(groupId, schoolYear); var membershipList = memberships.GetByGroup(groupId);
var existing = results.GetByExam(exam.Id).ToDictionary(r => r.StudentId); var existing = results.GetByExam(exam.Id).ToDictionary(r => r.StudentId);
foreach (var s in enrolled.OrderBy(s => s.LastName).ThenBy(s => s.FirstName)) foreach (var s in enrolled.OrderBy(s => s.LastName).ThenBy(s => s.FirstName))
{ {
var enrollment = enrollmentList.FirstOrDefault(e => e.StudentId == s.Id); var membership = membershipList.FirstOrDefault(e => e.StudentId == s.Id);
if (enrollment is not null && !IsEnrolledAtDate(enrollment, exam.Date)) continue; if (membership is not null && !IsMemberAtDate(membership, exam.Date)) continue;
// Niveau-Klausur: nur Schüler mit passendem Niveau zeigen. Klausuren ohne // Niveau-Klausur: nur Schüler mit passendem Niveau zeigen. Klausuren ohne
// Niveau-Zuordnung gelten weiterhin für die ganze Gruppe. // 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); existing.TryGetValue(s.Id, out var result);
var row = new ExamResultRow(s.Id, s.FullName, Tasks, result, _exam.GradingKey, _examMaxPoints, _grading); 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 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, MembershipPeriod.H1Only => date.Month >= 8 || date.Month <= 1,
EnrollmentPeriod.H2Only => date.Month >= 2 && date.Month <= 7, MembershipPeriod.H2Only => date.Month >= 2 && date.Month <= 7,
EnrollmentPeriod.Custom => (e.JoinedAt is null || date >= e.JoinedAt.Value) MembershipPeriod.Custom => (membership.JoinedAt is null || date >= membership.JoinedAt.Value)
&& (e.LeftAt is null || date <= e.LeftAt.Value), && (membership.LeftAt is null || date <= membership.LeftAt.Value),
_ => true, _ => true,
}; };
} }
@@ -289,7 +289,6 @@ public partial class ExamDialogViewModel : ObservableObject
Result = _editingExam ?? new Exam { GroupId = _groupId }; Result = _editingExam ?? new Exam { GroupId = _groupId };
Result.Title = Title.Trim(); Result.Title = Title.Trim();
Result.Date = date; Result.Date = date;
Result.Subject = _subjectName.Trim();
Result.ExamNumber = ExamNumber; Result.ExamNumber = ExamNumber;
Result.Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim(); Result.Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim();
Result.ReturnedAt = returnedAt; Result.ReturnedAt = returnedAt;
@@ -12,7 +12,7 @@ namespace LehrerApp.Desktop.ViewModels.Groups;
public partial class GroupListViewModel : ObservableObject public partial class GroupListViewModel : ObservableObject
{ {
private readonly IGroupRepository _groups; private readonly IGroupRepository _groups;
private readonly SchoolYearService _sy; private readonly ISubjectRepository _subjects;
public Action<Guid, int>? OnNavigateToDetail { get; set; } public Action<Guid, int>? OnNavigateToDetail { get; set; }
public Func<Task>? OnAddGroup { get; set; } public Func<Task>? OnAddGroup { get; set; }
@@ -37,9 +37,9 @@ public partial class GroupListViewModel : ObservableObject
public ObservableCollection<string> SchoolYears { get; } = []; public ObservableCollection<string> SchoolYears { get; } = [];
public ObservableCollection<GroupListItem> Groups { get; } = []; public ObservableCollection<GroupListItem> 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); foreach (var y in sy.RecentSchoolYears()) SchoolYears.Add(y);
SelectedSchoolYear = sy.CurrentSchoolYear(); SelectedSchoolYear = sy.CurrentSchoolYear();
} }
@@ -63,11 +63,13 @@ public partial class GroupListViewModel : ObservableObject
Groups.Clear(); Groups.Clear();
var all = _groups.GetBySchoolYear(SelectedSchoolYear, includeInactive: ShowArchived) var all = _groups.GetBySchoolYear(SelectedSchoolYear, includeInactive: ShowArchived)
.Where(g => g.IsActive != ShowArchived); .Where(g => g.IsActive != ShowArchived);
var subjectNames = _subjects.GetAll().ToDictionary(s => s.Id, s => s.Name);
var filtered = string.IsNullOrWhiteSpace(SearchText) ? all var filtered = string.IsNullOrWhiteSpace(SearchText) ? all
: all.Where(g => g.Name.Contains(SearchText, StringComparison.OrdinalIgnoreCase) : 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)) 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); SelectedGroup = Groups.FirstOrDefault(g => g.Id == selectedId);
OnPropertyChanged(nameof(ListSummary)); OnPropertyChanged(nameof(ListSummary));
OnPropertyChanged(nameof(HasNoGroups)); OnPropertyChanged(nameof(HasNoGroups));
@@ -136,15 +138,15 @@ public class GroupListItem
public bool IsActive { get; } public bool IsActive { get; }
public string ArchiveActionLabel => IsActive ? "Archivieren" : "Wieder aktivieren"; public string ArchiveActionLabel => IsActive ? "Archivieren" : "Wieder aktivieren";
public GroupListItem(LearningGroup g) public GroupListItem(LearningGroup g, string subjectName)
{ {
Id = g.Id; Id = g.Id;
IsActive = g.IsActive; IsActive = g.IsActive;
Name = g.Name; Name = g.Name;
Subject = g.Subject ?? ""; Subject = subjectName;
TypeLabel = g.Type == GroupType.Class ? "Klasse" : "Kurs"; TypeLabel = g.Type == GroupType.Class ? "Klasse" : "Kurs";
GradingLabel = g.GradingSystem == GradingSystem.Grades1To6 ? "16" : "015"; GradingLabel = g.GradingSystem == GradingSystem.Grades1To6 ? "16" : "015";
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}"; Subtitle = $"{TypeLabel} · Stufe {g.GradeLevel} · Noten {GradingLabel} · {g.SchoolYear}";
} }
} }
@@ -155,7 +157,8 @@ public partial class GroupDetailViewModel : ObservableObject
{ {
private readonly IGroupRepository _groups; private readonly IGroupRepository _groups;
private readonly IStudentRepository _students; private readonly IStudentRepository _students;
private readonly IEnrollmentRepository _enrollments; private readonly IGroupMembershipRepository _memberships;
private readonly ISubjectRepository _subjects;
private readonly IExamRepository _exams; private readonly IExamRepository _exams;
private readonly IGradeRepository _grades; private readonly IGradeRepository _grades;
@@ -171,6 +174,7 @@ public partial class GroupDetailViewModel : ObservableObject
// bevor LoadGroup() läuft (siehe MainWindowViewModel.NavigateToGroupDetail), Group ist dann // 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. // kurzzeitig null — ein verschachtelter Pfad würde dafür jedes Mal einen Binding-Fehler loggen.
public bool IsDifferentiated => Group?.IsDifferentiated ?? false; public bool IsDifferentiated => Group?.IsDifferentiated ?? false;
public string SubjectName { get; private set; } = "";
partial void OnGroupChanged(LearningGroup? value) => OnPropertyChanged(nameof(IsDifferentiated)); partial void OnGroupChanged(LearningGroup? value) => OnPropertyChanged(nameof(IsDifferentiated));
@@ -187,10 +191,11 @@ public partial class GroupDetailViewModel : ObservableObject
public Func<Exam, Task>? OnEvaluateExam { get; set; } public Func<Exam, Task>? OnEvaluateExam { get; set; }
public GroupDetailViewModel(IGroupRepository groups, IStudentRepository students, public GroupDetailViewModel(IGroupRepository groups, IStudentRepository students,
IEnrollmentRepository enrollments, IExamRepository exams, IGradeRepository grades, IGroupMembershipRepository memberships, ISubjectRepository subjects,
IExamRepository exams, IGradeRepository grades,
ParticipationTabViewModel participationTab) ParticipationTabViewModel participationTab)
{ {
_groups = groups; _students = students; _enrollments = enrollments; _groups = groups; _students = students; _memberships = memberships; _subjects = subjects;
_exams = exams; _grades = grades; _exams = exams; _grades = grades;
ParticipationTab = participationTab; ParticipationTab = participationTab;
} }
@@ -199,6 +204,9 @@ public partial class GroupDetailViewModel : ObservableObject
{ {
Group = _groups.GetById(id); Group = _groups.GetById(id);
if (Group is null) return; if (Group is null) return;
SubjectName = Group.SubjectId is Guid subjectId
? _subjects.GetById(subjectId)?.Name ?? ""
: "";
GroupTitle = Group.Name; GroupTitle = Group.Name;
GroupSubtitle = $"{Group.SchoolYear} · " + GroupSubtitle = $"{Group.SchoolYear} · " +
$"{(Group.Type == GroupType.Class ? "Klasse" : "Kurs")} · " + $"{(Group.Type == GroupType.Class ? "Klasse" : "Kurs")} · " +
@@ -221,14 +229,13 @@ public partial class GroupDetailViewModel : ObservableObject
{ {
if (Group is null) return; if (Group is null) return;
Students.Clear(); Students.Clear();
var enrolled = _students.GetByGroup(Group.Id, Group.SchoolYear); var enrolled = _students.GetByGroup(Group.Id);
var enrollments = _enrollments.GetByGroupAndYear(Group.Id, Group.SchoolYear) var memberships = _memberships.GetByGroup(Group.Id).ToDictionary(e => e.StudentId);
.ToDictionary(e => e.StudentId);
StudentCount = enrolled.Count; StudentCount = enrolled.Count;
foreach (var s in enrolled) foreach (var s in enrolled)
{ {
enrollments.TryGetValue(s.Id, out var enr); memberships.TryGetValue(s.Id, out var membership);
var summary = new StudentSummary(s, enr) { OnChanged = SaveStudentNiveau }; var summary = new StudentSummary(s, membership) { OnChanged = SaveStudentNiveau };
Students.Add(summary); Students.Add(summary);
} }
} }
@@ -236,11 +243,10 @@ public partial class GroupDetailViewModel : ObservableObject
private void SaveStudentNiveau(StudentSummary summary) private void SaveStudentNiveau(StudentSummary summary)
{ {
if (Group is null) return; if (Group is null) return;
var enrollment = _enrollments.GetByGroupAndYear(Group.Id, Group.SchoolYear) var membership = _memberships.GetByStudentAndGroup(summary.Id, Group.Id);
.FirstOrDefault(e => e.StudentId == summary.Id); if (membership is null) return;
if (enrollment is null) return; membership.Niveau = summary.Niveau;
enrollment.Niveau = summary.Niveau; _memberships.Save(membership);
_enrollments.Save(enrollment);
} }
[RelayCommand] [RelayCommand]
@@ -259,10 +265,9 @@ public partial class GroupDetailViewModel : ObservableObject
private void RemoveStudent() private void RemoveStudent()
{ {
if (Group is null || SelectedStudent is null) return; if (Group is null || SelectedStudent is null) return;
var enrollment = _enrollments.GetByGroupAndYear(Group.Id, Group.SchoolYear) var membership = _memberships.GetByStudentAndGroup(SelectedStudent.Id, Group.Id);
.FirstOrDefault(e => e.StudentId == SelectedStudent.Id); if (membership is null) return;
if (enrollment is null) return; _memberships.Delete(membership.Id);
_enrollments.Delete(enrollment.Id);
LoadStudents(); LoadStudents();
SelectedStudent = null; SelectedStudent = null;
ParticipationTab.RefreshCurrentGrid(); ParticipationTab.RefreshCurrentGrid();
@@ -422,18 +427,18 @@ public partial class StudentSummary : ObservableObject
public Action<StudentSummary>? OnChanged { get; set; } public Action<StudentSummary>? OnChanged { get; set; }
public StudentSummary(Core.Models.Student s, Enrollment? e) public StudentSummary(Core.Models.Student s, GroupMembership? membership)
{ {
Id = s.Id; Id = s.Id;
FullName = s.FullName; FullName = s.FullName;
PeriodLabel = e?.Period switch PeriodLabel = membership?.Period switch
{ {
EnrollmentPeriod.H1Only => "H1", MembershipPeriod.H1Only => "H1",
EnrollmentPeriod.H2Only => "H2", MembershipPeriod.H2Only => "H2",
EnrollmentPeriod.Custom => BuildCustomLabel(e), MembershipPeriod.Custom => BuildCustomLabel(membership),
_ => "", _ => "",
}; };
_niveau = e?.Niveau; _niveau = membership?.Niveau;
} }
partial void OnNiveauChanged(Niveau? value) partial void OnNiveauChanged(Niveau? value)
@@ -442,11 +447,11 @@ public partial class StudentSummary : ObservableObject
OnChanged?.Invoke(this); 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 (membership.JoinedAt.HasValue && membership.LeftAt.HasValue) return $"{membership.JoinedAt:dd.MM.}{membership.LeftAt:dd.MM.}";
if (e.JoinedAt.HasValue) return $"ab {e.JoinedAt:dd.MM.}"; if (membership.JoinedAt.HasValue) return $"ab {membership.JoinedAt:dd.MM.}";
if (e.LeftAt.HasValue) return $"bis {e.LeftAt:dd.MM.}"; if (membership.LeftAt.HasValue) return $"bis {membership.LeftAt:dd.MM.}";
return "Datum"; return "Datum";
} }
} }
@@ -491,35 +496,34 @@ public class ExamSummary
public partial class AddStudentToGroupDialogViewModel : ObservableObject public partial class AddStudentToGroupDialogViewModel : ObservableObject
{ {
private readonly IStudentRepository _students; private readonly IStudentRepository _students;
private readonly IEnrollmentRepository _enrollments; private readonly IGroupMembershipRepository _memberships;
private readonly Guid _groupId; private readonly Guid _groupId;
private readonly string _schoolYear;
[ObservableProperty] private string _searchText = ""; [ObservableProperty] private string _searchText = "";
[ObservableProperty] private StudentPickerItem? _selectedStudent; [ObservableProperty] private StudentPickerItem? _selectedStudent;
[ObservableProperty] private string _validationMessage = ""; [ObservableProperty] private string _validationMessage = "";
[ObservableProperty] private EnrollmentPeriod _period = EnrollmentPeriod.FullYear; [ObservableProperty] private MembershipPeriod _period = MembershipPeriod.FullYear;
[ObservableProperty] private string _joinedAtText = ""; [ObservableProperty] private string _joinedAtText = "";
[ObservableProperty] private string _leftAtText = ""; [ObservableProperty] private string _leftAtText = "";
public bool IsFullYear { get => Period == EnrollmentPeriod.FullYear; set { if (value) Period = EnrollmentPeriod.FullYear; } } public bool IsFullYear { get => Period == MembershipPeriod.FullYear; set { if (value) Period = MembershipPeriod.FullYear; } }
public bool IsH1Only { get => Period == EnrollmentPeriod.H1Only; set { if (value) Period = EnrollmentPeriod.H1Only; } } public bool IsH1Only { get => Period == MembershipPeriod.H1Only; set { if (value) Period = MembershipPeriod.H1Only; } }
public bool IsH2Only { get => Period == EnrollmentPeriod.H2Only; set { if (value) Period = EnrollmentPeriod.H2Only; } } public bool IsH2Only { get => Period == MembershipPeriod.H2Only; set { if (value) Period = MembershipPeriod.H2Only; } }
public bool IsCustom { get => Period == EnrollmentPeriod.Custom; set { if (value) Period = EnrollmentPeriod.Custom; } } public bool IsCustom { get => Period == MembershipPeriod.Custom; set { if (value) Period = MembershipPeriod.Custom; } }
public bool IsCustomPeriod => Period == EnrollmentPeriod.Custom; public bool IsCustomPeriod => Period == MembershipPeriod.Custom;
public ObservableCollection<StudentPickerItem> AvailableStudents { get; } = []; public ObservableCollection<StudentPickerItem> AvailableStudents { get; } = [];
public Enrollment? Result { get; private set; } public GroupMembership? Result { get; private set; }
public AddStudentToGroupDialogViewModel(IStudentRepository students, public AddStudentToGroupDialogViewModel(IStudentRepository students,
IEnrollmentRepository enrollments, Guid groupId, string schoolYear) IGroupMembershipRepository memberships, Guid groupId)
{ {
_students = students; _enrollments = enrollments; _students = students; _memberships = memberships;
_groupId = groupId; _schoolYear = schoolYear; _groupId = groupId;
LoadAvailableStudents(); LoadAvailableStudents();
} }
partial void OnPeriodChanged(EnrollmentPeriod value) partial void OnPeriodChanged(MembershipPeriod value)
{ {
OnPropertyChanged(nameof(IsFullYear)); OnPropertyChanged(nameof(IsFullYear));
OnPropertyChanged(nameof(IsH1Only)); OnPropertyChanged(nameof(IsH1Only));
@@ -532,11 +536,11 @@ public partial class AddStudentToGroupDialogViewModel : ObservableObject
private void LoadAvailableStudents() private void LoadAvailableStudents()
{ {
var alreadyEnrolled = _enrollments.GetByGroupAndYear(_groupId, _schoolYear) var alreadyAssigned = _memberships.GetByGroup(_groupId)
.Select(e => e.StudentId).ToHashSet(); .Select(e => e.StudentId).ToHashSet();
var all = _students.GetAll(); var all = _students.GetAll();
var available = all var available = all
.Where(s => !alreadyEnrolled.Contains(s.Id)) .Where(s => !alreadyAssigned.Contains(s.Id))
.Where(s => string.IsNullOrWhiteSpace(SearchText) || .Where(s => string.IsNullOrWhiteSpace(SearchText) ||
s.FullName.Contains(SearchText, StringComparison.OrdinalIgnoreCase)); s.FullName.Contains(SearchText, StringComparison.OrdinalIgnoreCase));
AvailableStudents.Clear(); AvailableStudents.Clear();
@@ -549,7 +553,7 @@ public partial class AddStudentToGroupDialogViewModel : ObservableObject
if (SelectedStudent is null) { ValidationMessage = "Bitte einen Schüler auswählen."; return; } if (SelectedStudent is null) { ValidationMessage = "Bitte einen Schüler auswählen."; return; }
DateOnly? joinedAt = null, leftAt = null; DateOnly? joinedAt = null, leftAt = null;
if (Period == EnrollmentPeriod.Custom) if (Period == MembershipPeriod.Custom)
{ {
if (!string.IsNullOrWhiteSpace(JoinedAtText) && if (!string.IsNullOrWhiteSpace(JoinedAtText) &&
DateOnly.TryParseExact(JoinedAtText, "dd.MM.yyyy", DateOnly.TryParseExact(JoinedAtText, "dd.MM.yyyy",
@@ -561,16 +565,15 @@ public partial class AddStudentToGroupDialogViewModel : ObservableObject
leftAt = l; leftAt = l;
} }
Result = new Enrollment Result = new GroupMembership
{ {
StudentId = SelectedStudent.Id, StudentId = SelectedStudent.Id,
GroupId = _groupId, GroupId = _groupId,
SchoolYear = _schoolYear,
Period = Period, Period = Period,
JoinedAt = joinedAt, JoinedAt = joinedAt,
LeftAt = leftAt, LeftAt = leftAt,
}; };
_enrollments.Save(Result); _memberships.Save(Result);
} }
} }
@@ -587,11 +590,8 @@ public partial class AddGroupDialogViewModel : ObservableObject
{ {
private readonly IGroupRepository _groups; private readonly IGroupRepository _groups;
private readonly ISubjectRepository _subjects; private readonly ISubjectRepository _subjects;
private readonly SchoolYearService _sy;
private readonly IEnrollmentRepository _enrollments;
private List<Subject> _allSubjects = []; private List<Subject> _allSubjects = [];
private LearningGroup? _editingGroup; private LearningGroup? _editingGroup;
private string? _originalSchoolYear;
public List<string> TypeOptions { get; } = ["Klasse", "Kurs"]; public List<string> TypeOptions { get; } = ["Klasse", "Kurs"];
[ObservableProperty] private string _selectedTypeName = "Kurs"; [ObservableProperty] private string _selectedTypeName = "Kurs";
@@ -625,9 +625,9 @@ public partial class AddGroupDialogViewModel : ObservableObject
public string SaveButtonText => _editingGroup is null ? "Anlegen" : "Speichern"; public string SaveButtonText => _editingGroup is null ? "Anlegen" : "Speichern";
public AddGroupDialogViewModel(IGroupRepository groups, ISubjectRepository subjects, 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(); _allSubjects = subjects.GetAll();
KnownSubjectNames = _allSubjects.Select(s => s.Name).ToList(); KnownSubjectNames = _allSubjects.Select(s => s.Name).ToList();
SchoolYears = sy.RecentSchoolYears(3); SchoolYears = sy.RecentSchoolYears(3);
@@ -642,10 +642,9 @@ public partial class AddGroupDialogViewModel : ObservableObject
public void LoadForEdit(LearningGroup group) public void LoadForEdit(LearningGroup group)
{ {
_editingGroup = group; _editingGroup = group;
_originalSchoolYear = group.SchoolYear;
SelectedTypeName = group.Type == GroupType.Class ? "Klasse" : "Kurs"; SelectedTypeName = group.Type == GroupType.Class ? "Klasse" : "Kurs";
Name = group.Name; Name = group.Name;
Subject = group.Subject ?? ""; Subject = group.SubjectId is Guid subjectId ? _subjects.GetById(subjectId)?.Name ?? "" : "";
GradeLevel = group.GradeLevel; GradeLevel = group.GradeLevel;
SelectedGradingName = group.GradingSystem == GradingSystem.Grades1To6 SelectedGradingName = group.GradingSystem == GradingSystem.Grades1To6
? "Noten 16" : "Punkte 015"; ? "Noten 16" : "Punkte 015";
@@ -684,7 +683,6 @@ public partial class AddGroupDialogViewModel : ObservableObject
Result = _editingGroup ?? new LearningGroup(); Result = _editingGroup ?? new LearningGroup();
Result.Name = Name.Trim(); Result.Name = Name.Trim();
Result.Subject = subjectName;
Result.SubjectId = subjectId; Result.SubjectId = subjectId;
Result.Type = IsKurs ? GroupType.Course : GroupType.Class; Result.Type = IsKurs ? GroupType.Course : GroupType.Class;
Result.GradeLevel = GradeLevel; Result.GradeLevel = GradeLevel;
@@ -695,14 +693,5 @@ public partial class AddGroupDialogViewModel : ObservableObject
Result.IsOwnClass = IsOwnClass; Result.IsOwnClass = IsOwnClass;
Result.IsDifferentiated = IsDifferentiated; Result.IsDifferentiated = IsDifferentiated;
_groups.Save(Result); _groups.Save(Result);
if (_editingGroup is not null && _originalSchoolYear != SelectedSchoolYear)
{
foreach (var enrollment in _enrollments.GetByGroup(Result.Id))
{
enrollment.SchoolYear = SelectedSchoolYear;
_enrollments.Save(enrollment);
}
}
} }
} }
@@ -16,7 +16,7 @@ public partial class ParticipationGradeDialogViewModel : ObservableObject
private readonly IParticipationRepository _entries; private readonly IParticipationRepository _entries;
private readonly IParticipationAspectRepository _aspects; private readonly IParticipationAspectRepository _aspects;
private readonly IStudentRepository _students; private readonly IStudentRepository _students;
private readonly IEnrollmentRepository _enrollments; private readonly IGroupMembershipRepository _memberships;
private readonly IGradeRepository _grades; private readonly IGradeRepository _grades;
private readonly GradingService _grading; private readonly GradingService _grading;
private readonly Guid _groupId; private readonly Guid _groupId;
@@ -39,11 +39,11 @@ public partial class ParticipationGradeDialogViewModel : ObservableObject
public ParticipationGradeDialogViewModel( public ParticipationGradeDialogViewModel(
IParticipationSessionRepository sessions, IParticipationRepository entries, IParticipationSessionRepository sessions, IParticipationRepository entries,
IParticipationAspectRepository aspects, IStudentRepository students, IParticipationAspectRepository aspects, IStudentRepository students,
IEnrollmentRepository enrollments, IGradeRepository grades, GradingService grading, IGroupMembershipRepository memberships, IGradeRepository grades, GradingService grading,
Guid groupId, string schoolYear, GradingSystem gradingSystem) Guid groupId, string schoolYear, GradingSystem gradingSystem)
{ {
_sessions = sessions; _entries = entries; _aspects = aspects; _sessions = sessions; _entries = entries; _aspects = aspects;
_students = students; _enrollments = enrollments; _grades = grades; _students = students; _memberships = memberships; _grades = grades;
_grading = grading; _groupId = groupId; _schoolYear = schoolYear; _grading = grading; _groupId = groupId; _schoolYear = schoolYear;
_gradingSystem = gradingSystem; _gradingSystem = gradingSystem;
@@ -83,14 +83,14 @@ public partial class ParticipationGradeDialogViewModel : ObservableObject
.OrderBy(s => s.Date) .OrderBy(s => s.Date)
.ToList(); .ToList();
var studentList = _students.GetByGroup(_groupId, _schoolYear); var studentList = _students.GetByGroup(_groupId);
var enrollmentList = _enrollments.GetByGroupAndYear(_groupId, _schoolYear); var membershipList = _memberships.GetByGroup(_groupId);
foreach (var student in studentList.OrderBy(s => s.LastName).ThenBy(s => s.FirstName)) 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 var relevantSessions = sessions
.Where(s => enrollment is null || IsEnrolledAtDate(enrollment, s.Date)) .Where(s => membership is null || IsMemberAtDate(membership, s.Date))
.ToList(); .ToList();
var points = new List<(DateOnly Date, double Rating)>(); var points = new List<(DateOnly Date, double Rating)>();
@@ -132,7 +132,6 @@ public partial class ParticipationGradeDialogViewModel : ObservableObject
{ {
StudentId = row.StudentId, StudentId = row.StudentId,
GroupId = _groupId, GroupId = _groupId,
SchoolYear = _schoolYear,
Category = GradeCategory.Participation, Category = GradeCategory.Participation,
Note = noteTag, Note = noteTag,
}; };
@@ -153,12 +152,12 @@ public partial class ParticipationGradeDialogViewModel : ObservableObject
_ => true, _ => 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, MembershipPeriod.H1Only => date.Month >= 8 || date.Month <= 1,
EnrollmentPeriod.H2Only => date.Month >= 2 && date.Month <= 7, MembershipPeriod.H2Only => date.Month >= 2 && date.Month <= 7,
EnrollmentPeriod.Custom => (e.JoinedAt is null || date >= e.JoinedAt.Value) MembershipPeriod.Custom => (membership.JoinedAt is null || date >= membership.JoinedAt.Value)
&& (e.LeftAt is null || date <= e.LeftAt.Value), && (membership.LeftAt is null || date <= membership.LeftAt.Value),
_ => true, _ => true,
}; };
} }
@@ -15,7 +15,7 @@ public partial class ParticipationTabViewModel : ObservableObject
private readonly IParticipationRepository _entries; private readonly IParticipationRepository _entries;
private readonly IParticipationAspectRepository _aspects; private readonly IParticipationAspectRepository _aspects;
private readonly IStudentRepository _students; private readonly IStudentRepository _students;
private readonly IEnrollmentRepository _enrollments; private readonly IGroupMembershipRepository _memberships;
private readonly IGroupRepository _groups; private readonly IGroupRepository _groups;
private readonly ICompetencyDomainRepository _competencyDomains; private readonly ICompetencyDomainRepository _competencyDomains;
@@ -53,13 +53,13 @@ public partial class ParticipationTabViewModel : ObservableObject
IParticipationRepository entries, IParticipationRepository entries,
IParticipationAspectRepository aspects, IParticipationAspectRepository aspects,
IStudentRepository students, IStudentRepository students,
IEnrollmentRepository enrollments, IGroupMembershipRepository memberships,
IGroupRepository groups, IGroupRepository groups,
ICompetencyDomainRepository competencyDomains) ICompetencyDomainRepository competencyDomains)
{ {
_sessions = sessions; _entries = entries; _sessions = sessions; _entries = entries;
_aspects = aspects; _students = students; _aspects = aspects; _students = students;
_enrollments = enrollments; _groups = groups; _memberships = memberships; _groups = groups;
_competencyDomains = competencyDomains; _competencyDomains = competencyDomains;
} }
@@ -128,18 +128,18 @@ public partial class ParticipationTabViewModel : ObservableObject
StudentRows.Clear(); StudentRows.Clear();
var session = _sessions.GetById(sessionId); var session = _sessions.GetById(sessionId);
var sessionDate = session?.Date ?? DateOnly.FromDateTime(DateTime.Today); var sessionDate = session?.Date ?? DateOnly.FromDateTime(DateTime.Today);
var students = _students.GetByGroup(_groupId, _schoolYear); var students = _students.GetByGroup(_groupId);
var enrollments = _enrollments.GetByGroupAndYear(_groupId, _schoolYear); var memberships = _memberships.GetByGroup(_groupId);
var entries = _entries.GetBySession(sessionId); var entries = _entries.GetBySession(sessionId);
foreach (var s in students) foreach (var s in students)
{ {
var enrollment = enrollments.FirstOrDefault(e => e.StudentId == s.Id); var membership = memberships.FirstOrDefault(e => e.StudentId == s.Id);
if (enrollment is not null && !IsEnrolledAtDate(enrollment, sessionDate)) if (membership is not null && !IsMemberAtDate(membership, sessionDate))
continue; continue;
var entry = entries.FirstOrDefault(e => e.StudentId == s.Id) 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); var row = new ParticipationStudentRow(s.Id, s.FullName, entry, Aspects.ToList(), ActiveCompetencyCodes);
row.OnRatingChanged = (sid, key, val) => SaveRating(sessionId, sid, key, val); row.OnRatingChanged = (sid, key, val) => SaveRating(sessionId, sid, key, val);
row.OnCompetencyRatingChanged = (sid, code, val) => SaveCompetencyRating(sessionId, sid, code, val); row.OnCompetencyRatingChanged = (sid, code, val) => SaveCompetencyRating(sessionId, sid, code, val);
@@ -149,25 +149,22 @@ public partial class ParticipationTabViewModel : ObservableObject
RebuildColumnsSignal++; 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, MembershipPeriod.H1Only => date.Month >= 8 || date.Month <= 1,
EnrollmentPeriod.H2Only => date.Month >= 2 && date.Month <= 7, MembershipPeriod.H2Only => date.Month >= 2 && date.Month <= 7,
EnrollmentPeriod.Custom => (e.JoinedAt is null || date >= e.JoinedAt.Value) MembershipPeriod.Custom => (membership.JoinedAt is null || date >= membership.JoinedAt.Value)
&& (e.LeftAt is null || date <= e.LeftAt.Value), && (membership.LeftAt is null || date <= membership.LeftAt.Value),
_ => true, _ => true,
}; };
private void SaveRating(Guid sessionId, Guid studentId, string key, int? value) 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 ?? new ParticipationEntry
{ {
SessionId = sessionId, SessionId = sessionId,
GroupId = _groupId,
StudentId = studentId, StudentId = studentId,
Date = session?.Date ?? DateOnly.FromDateTime(DateTime.Today),
}; };
var existing = entry.Ratings.FirstOrDefault(r => r.Key == key); 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) 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 ?? new ParticipationEntry
{ {
SessionId = sessionId, SessionId = sessionId,
GroupId = _groupId,
StudentId = studentId, StudentId = studentId,
Date = session?.Date ?? DateOnly.FromDateTime(DateTime.Today),
}; };
var existing = entry.CompetencyRatings.FirstOrDefault(r => r.Code == code); var existing = entry.CompetencyRatings.FirstOrDefault(r => r.Code == code);
if (value is null) if (value is null)
@@ -110,7 +110,15 @@ public partial class SettingsViewModel : ObservableObject
private void AddSubject() private void AddSubject()
{ {
if (string.IsNullOrWhiteSpace(NewName)) { ValidationMessage = "Name erforderlich."; return; } 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 = ""; NewName = ""; NewShort = ""; ValidationMessage = "";
LoadSubjects(); LoadSubjects();
} }
@@ -119,7 +127,16 @@ public partial class SettingsViewModel : ObservableObject
private void DeleteSubject(SubjectListItem? item) private void DeleteSubject(SubjectListItem? item)
{ {
if (item is null) return; 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; if (CatalogSubject?.Id == item.Id) CatalogSubject = null;
LoadSubjects(); LoadSubjects();
} }
@@ -68,8 +68,9 @@ public class StudentListItem
public partial class StudentDetailViewModel : ObservableObject public partial class StudentDetailViewModel : ObservableObject
{ {
private readonly IStudentRepository _students; private readonly IStudentRepository _students;
private readonly IEnrollmentRepository _enrollments; private readonly IGroupMembershipRepository _memberships;
private readonly IGroupRepository _groups; private readonly IGroupRepository _groups;
private readonly ISubjectRepository _subjects;
private readonly IDocumentationRepository _docs; private readonly IDocumentationRepository _docs;
[ObservableProperty] private Student? _student; [ObservableProperty] private Student? _student;
@@ -79,7 +80,7 @@ public partial class StudentDetailViewModel : ObservableObject
[ObservableProperty] private string _editLastName = ""; [ObservableProperty] private string _editLastName = "";
[ObservableProperty] private ContactItem? _selectedContact; [ObservableProperty] private ContactItem? _selectedContact;
public ObservableCollection<EnrollmentEntry> Enrollments { get; } = []; public ObservableCollection<GroupMembershipEntry> GroupMemberships { get; } = [];
public ObservableCollection<DocEntry> Documentation { get; } = []; public ObservableCollection<DocEntry> Documentation { get; } = [];
public ObservableCollection<ContactItem> Contacts { get; } = []; public ObservableCollection<ContactItem> Contacts { get; } = [];
public bool HasNoContacts => Contacts.Count == 0; public bool HasNoContacts => Contacts.Count == 0;
@@ -87,11 +88,11 @@ public partial class StudentDetailViewModel : ObservableObject
public Action<ContactItem>? OnViewAddress { get; set; } public Action<ContactItem>? OnViewAddress { get; set; }
public StudentDetailViewModel(IStudentRepository students, public StudentDetailViewModel(IStudentRepository students,
IEnrollmentRepository enrollments, IGroupRepository groups, IGroupMembershipRepository memberships, IGroupRepository groups, ISubjectRepository subjects,
IDocumentationRepository docs) IDocumentationRepository docs)
{ {
_students = students; _enrollments = enrollments; _students = students; _memberships = memberships;
_groups = groups; _docs = docs; _groups = groups; _subjects = subjects; _docs = docs;
} }
public void LoadStudent(Guid id) public void LoadStudent(Guid id)
@@ -102,12 +103,13 @@ public partial class StudentDetailViewModel : ObservableObject
EditFirstName = Student.FirstName; EditFirstName = Student.FirstName;
EditLastName = Student.LastName; EditLastName = Student.LastName;
Enrollments.Clear(); GroupMemberships.Clear();
foreach (var e in _enrollments.GetByStudent(Student.Id)) 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; 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(); LoadContacts();
@@ -195,7 +197,7 @@ public partial class StudentDetailViewModel : ObservableObject
private bool CanViewSelectedAddress() => SelectedContact?.HasAddress == true; 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 DocEntry { public string Date { get; set; } = ""; public string Title { get; set; } = ""; public string TypeLabel { get; set; } = ""; public bool IsConfidential { get; set; } }
public class ContactItem public class ContactItem
@@ -32,9 +32,8 @@ public partial class GroupDetailView : UserControl
var dialogVm = new AddStudentToGroupDialogViewModel( var dialogVm = new AddStudentToGroupDialogViewModel(
App.Services.GetRequiredService<IStudentRepository>(), App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IEnrollmentRepository>(), App.Services.GetRequiredService<IGroupMembershipRepository>(),
vm.Group.Id, vm.Group.Id);
vm.Group.SchoolYear);
var dialog = new AddStudentToGroupDialog { DataContext = dialogVm }; var dialog = new AddStudentToGroupDialog { DataContext = dialogVm };
var owner = TopLevel.GetTopLevel(this) as Window; var owner = TopLevel.GetTopLevel(this) as Window;
@@ -62,7 +61,7 @@ public partial class GroupDetailView : UserControl
App.Services.GetRequiredService<IGradingKeyTemplateRepository>(), App.Services.GetRequiredService<IGradingKeyTemplateRepository>(),
App.Services.GetRequiredService<GradingService>(), App.Services.GetRequiredService<GradingService>(),
groupId, vm.Group.SubjectId, vm.Group.GradeLevel, vm.Group.GradingSystem, 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 dialog = new ExamDialog { DataContext = dialogVm };
var owner = TopLevel.GetTopLevel(this) as Window; var owner = TopLevel.GetTopLevel(this) as Window;
@@ -85,9 +84,9 @@ public partial class GroupDetailView : UserControl
var dialogVm = new ExamGradingDialogViewModel( var dialogVm = new ExamGradingDialogViewModel(
App.Services.GetRequiredService<IExamResultRepository>(), App.Services.GetRequiredService<IExamResultRepository>(),
App.Services.GetRequiredService<IStudentRepository>(), App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IEnrollmentRepository>(), App.Services.GetRequiredService<IGroupMembershipRepository>(),
App.Services.GetRequiredService<GradingService>(), App.Services.GetRequiredService<GradingService>(),
exam, vm.Group.Id, vm.Group.SchoolYear); exam, vm.Group.Id);
var dialog = new ExamGradingDialog { DataContext = dialogVm }; var dialog = new ExamGradingDialog { DataContext = dialogVm };
var owner = TopLevel.GetTopLevel(this) as Window; var owner = TopLevel.GetTopLevel(this) as Window;
@@ -153,7 +153,7 @@ public partial class ParticipationTabView : UserControl
App.Services.GetRequiredService<IParticipationRepository>(), App.Services.GetRequiredService<IParticipationRepository>(),
App.Services.GetRequiredService<IParticipationAspectRepository>(), App.Services.GetRequiredService<IParticipationAspectRepository>(),
App.Services.GetRequiredService<IStudentRepository>(), App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IEnrollmentRepository>(), App.Services.GetRequiredService<IGroupMembershipRepository>(),
App.Services.GetRequiredService<IGradeRepository>(), App.Services.GetRequiredService<IGradeRepository>(),
App.Services.GetRequiredService<GradingService>(), App.Services.GetRequiredService<GradingService>(),
tabVm.GroupId, tabVm.SchoolYear, tabVm.GradingSystem); tabVm.GroupId, tabVm.SchoolYear, tabVm.GradingSystem);
@@ -40,9 +40,9 @@
<ScrollViewer Padding="20"> <ScrollViewer Padding="20">
<StackPanel Spacing="12"> <StackPanel Spacing="12">
<TextBlock Text="Lerngruppen" FontSize="15" FontWeight="SemiBold"/> <TextBlock Text="Lerngruppen" FontSize="15" FontWeight="SemiBold"/>
<ItemsControl ItemsSource="{Binding Enrollments}"> <ItemsControl ItemsSource="{Binding GroupMemberships}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:EnrollmentEntry"> <DataTemplate DataType="vm:GroupMembershipEntry">
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}" <Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="6" Padding="12,8" Margin="0,0,0,6"> CornerRadius="6" Padding="12,8" Margin="0,0,0,6">
<Grid ColumnDefinitions="70,*,Auto"> <Grid ColumnDefinitions="70,*,Auto">
@@ -54,8 +54,8 @@
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
<TextBlock Text="Keine Einschreibungen vorhanden." Opacity="0.4" <TextBlock Text="Keine Lerngruppenzuordnungen vorhanden." Opacity="0.4"
IsVisible="{Binding !Enrollments.Count}"/> IsVisible="{Binding !GroupMemberships.Count}"/>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
</ContentPage> </ContentPage>
+4
View File
@@ -10,6 +10,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Api", "LehrerApp.
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Desktop", "LehrerApp.Desktop\LehrerApp.Desktop.csproj", "{A1000005-0000-0000-0000-000000000005}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Desktop", "LehrerApp.Desktop\LehrerApp.Desktop.csproj", "{A1000005-0000-0000-0000-000000000005}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Data.Tests", "LehrerApp.Data.Tests\LehrerApp.Data.Tests.csproj", "{A1000006-0000-0000-0000-000000000006}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -26,5 +28,7 @@ Global
{A1000004-0000-0000-0000-000000000004}.Debug|Any CPU.Build.0 = Debug|Any CPU {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.ActiveCfg = Debug|Any CPU
{A1000005-0000-0000-0000-000000000005}.Debug|Any CPU.Build.0 = 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 EndGlobalSection
EndGlobal EndGlobal
+6 -6
View File
@@ -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 **Niveaudifferenzierung (E/G/Förder) ist bereits umgesetzt** — nicht aus dieser Liste, sondern
ein separater Bedarf (Nachteilsausgleich/Binnendifferenzierung): `LearningGroup.IsDifferentiated` 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 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" 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 (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. sonstige Noten). Zelle zeigt Note/Punkte.
- [ ] **2.1.2** Spalte "Gesamt" mit gewichtetem Durchschnitt über `GradingService.WeightedAverage()`. - [ ] **2.1.2** Spalte "Gesamt" mit gewichtetem Durchschnitt über `GradingService.WeightedAverage()`.
- [ ] **2.1.3** Sortierung nach Name / Gesamtnote, Umschalten Noten ↔ Punkte. - [ ] **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 Einzelnoten pflegen
- [ ] **2.2.1** Dialog "Note hinzufügen": Kategorie (`GradeCategory`), Wert, Datum, Gewichtung, Notiz. - [ ] **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), Grundfunktionen sind vorhanden ([StudentViewModels.cs](LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs),
[GroupViewModels.cs](LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.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. Hinweis in Kapitel 1 — betrifft auch Kurse, nicht nur Klassen.
### 7.1 Schüler ### 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, `ToggleArchiveCommand` in `GroupListViewModel`), nicht Teil der aktuellen Klausuren-Arbeit,
beim Review aber bestätigt. beim Review aber bestätigt.
- [ ] **7.2.2** Gruppe ins neue Schuljahr übernehmen: Kopie mit gleicher Schülerschaft, - [ ] **7.2.2** Gruppe ins neue Schuljahr übernehmen: Kopie mit gleicher Schülerschaft,
neues `SchoolYear`, neue `Enrollment`-Einträge. neues `SchoolYear`, neue `GroupMembership`-Einträge.
- [ ] **7.2.3** Schüler aus einer Gruppe entfernen (`Enrollment.LeftAt` setzen statt löschen). - [ ] **7.2.3** Schüler aus einer Gruppe entfernen (`GroupMembership.LeftAt` setzen statt löschen).
- [ ] **7.2.4** Umgang mit `EnrollmentPeriod.Custom` in allen Auswertungen prüfen - [ ] **7.2.4** Umgang mit `MembershipPeriod.Custom` in allen Auswertungen prüfen
(Schüler zählt nur im belegten Zeitraum). (Schüler zählt nur im belegten Zeitraum).
- [ ] **7.2.5** Archivansicht abgeschlossener Schuljahre (schreibgeschützt). - [ ] **7.2.5** Archivansicht abgeschlossener Schuljahre (schreibgeschützt).
+73
View File
@@ -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.