aufräumen
This commit is contained in:
@@ -19,7 +19,6 @@
|
||||
|
||||
<!-- API -->
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.0" />
|
||||
|
||||
<!-- Tests -->
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
|
||||
@@ -9,6 +9,5 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||
<PackageReference Include="LiteDB" />
|
||||
<PackageReference Include="System.Text.Json" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -7,8 +7,9 @@ public class ReadableSnapshot
|
||||
public DateTime ExportedAt { get; set; }
|
||||
public ReadableSnapshotMeta Meta { get; set; } = new();
|
||||
public List<LearningGroup> Groups { get; set; } = [];
|
||||
public List<Subject> Subjects { get; set; } = [];
|
||||
public List<Student> Students { get; set; } = [];
|
||||
public List<Enrollment> Enrollments { get; set; } = [];
|
||||
public List<GroupMembership> Memberships { get; set; } = [];
|
||||
}
|
||||
|
||||
public class ReadableSnapshotMeta
|
||||
|
||||
@@ -6,7 +6,7 @@ public interface IStudentRepository
|
||||
{
|
||||
Student? GetById(Guid id);
|
||||
List<Student> GetAll(bool includeInactive = false);
|
||||
List<Student> GetByGroup(Guid groupId, string schoolYear);
|
||||
List<Student> 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<Enrollment> GetByStudent(Guid studentId);
|
||||
List<Enrollment> GetByGroup(Guid groupId);
|
||||
List<Enrollment> GetByGroupAndYear(Guid groupId, string schoolYear);
|
||||
void Save(Enrollment enrollment);
|
||||
List<GroupMembership> GetByStudent(Guid studentId);
|
||||
List<GroupMembership> GetByGroup(Guid groupId);
|
||||
GroupMembership? GetByStudentAndGroup(Guid studentId, Guid groupId);
|
||||
void Save(GroupMembership membership);
|
||||
void Delete(Guid id);
|
||||
}
|
||||
public interface IExamRepository
|
||||
|
||||
@@ -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<ExamTask> Tasks { get; set; } = [];
|
||||
public List<GradingKeyEntry> GradingKey { get; set; } = [];
|
||||
|
||||
@@ -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
|
||||
/// <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 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; }
|
||||
/// <summary>Tag, an dem die Zuordnung in der App angelegt wurde.</summary>
|
||||
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 }
|
||||
|
||||
@@ -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<AspectRating> Ratings { get; set; } = [];
|
||||
public List<CompetencyRating> CompetencyRatings { get; set; } = [];
|
||||
public string? Note { get; set; }
|
||||
|
||||
@@ -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<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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ public class LiteDbContext : IDisposable
|
||||
|
||||
public ILiteCollection<Student> Students => _db.GetCollection<Student>("students");
|
||||
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<ExamResult> ExamResults => _db.GetCollection<ExamResult>("exam_results");
|
||||
public ILiteCollection<Grade> Grades => _db.GetCollection<Grade>("grades");
|
||||
@@ -42,7 +42,11 @@ public class LiteDbContext : IDisposable
|
||||
|
||||
private void MigrateExistingData()
|
||||
{
|
||||
MigrateMemberships();
|
||||
RemoveRedundantLegacyFields();
|
||||
|
||||
var groups = _db.GetCollection<BsonDocument>("groups");
|
||||
var subjects = _db.GetCollection<Subject>("subjects");
|
||||
var missingArchiveState = groups.FindAll()
|
||||
.Where(g => !g.ContainsKey(nameof(LearningGroup.IsActive)))
|
||||
.ToList();
|
||||
@@ -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<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()
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -9,10 +9,10 @@ public class StudentRepository(LiteDbContext db) : IStudentRepository
|
||||
public List<Student> GetAll(bool includeInactive = false) =>
|
||||
(includeInactive ? db.Students.FindAll() : db.Students.Find(s => s.IsActive))
|
||||
.OrderBy(s => s.LastName).ToList();
|
||||
public List<Student> GetByGroup(Guid groupId, string schoolYear)
|
||||
public List<Student> 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<Enrollment> GetByStudent(Guid id) =>
|
||||
db.Enrollments.Find(e => e.StudentId == id).ToList();
|
||||
public List<Enrollment> GetByGroup(Guid id) =>
|
||||
db.Enrollments.Find(e => e.GroupId == id).ToList();
|
||||
public List<Enrollment> 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<GroupMembership> GetByStudent(Guid id) =>
|
||||
db.Memberships.Find(e => e.StudentId == id).ToList();
|
||||
public List<GroupMembership> 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<Subject> 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
|
||||
|
||||
@@ -44,7 +44,7 @@ public static class AppBootstrapper
|
||||
// ── Repositories ──────────────────────────────────────────────────────
|
||||
services.AddSingleton<IStudentRepository, StudentRepository>();
|
||||
services.AddSingleton<IGroupRepository, GroupRepository>();
|
||||
services.AddSingleton<IEnrollmentRepository, EnrollmentRepository>();
|
||||
services.AddSingleton<IGroupMembershipRepository, GroupMembershipRepository>();
|
||||
services.AddSingleton<IExamRepository, ExamRepository>();
|
||||
services.AddSingleton<IExamResultRepository, ExamResultRepository>();
|
||||
services.AddSingleton<IGradeRepository, GradeRepository>();
|
||||
|
||||
@@ -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<Guid>? 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();
|
||||
|
||||
@@ -24,24 +24,24 @@ public partial class ExamGradingDialogViewModel : ObservableObject
|
||||
public ObservableCollection<ExamResultRow> 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Guid, int>? OnNavigateToDetail { get; set; }
|
||||
public Func<Task>? OnAddGroup { get; set; }
|
||||
@@ -37,9 +37,9 @@ public partial class GroupListViewModel : ObservableObject
|
||||
public ObservableCollection<string> SchoolYears { 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);
|
||||
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<Exam, Task>? 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<StudentSummary>? 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<StudentPickerItem> 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<Subject> _allSubjects = [];
|
||||
private LearningGroup? _editingGroup;
|
||||
private string? _originalSchoolYear;
|
||||
|
||||
public List<string> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<EnrollmentEntry> Enrollments { get; } = [];
|
||||
public ObservableCollection<GroupMembershipEntry> GroupMemberships { get; } = [];
|
||||
public ObservableCollection<DocEntry> Documentation { get; } = [];
|
||||
public ObservableCollection<ContactItem> Contacts { get; } = [];
|
||||
public bool HasNoContacts => Contacts.Count == 0;
|
||||
@@ -87,11 +88,11 @@ public partial class StudentDetailViewModel : ObservableObject
|
||||
public Action<ContactItem>? 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
|
||||
|
||||
@@ -32,9 +32,8 @@ public partial class GroupDetailView : UserControl
|
||||
|
||||
var dialogVm = new AddStudentToGroupDialogViewModel(
|
||||
App.Services.GetRequiredService<IStudentRepository>(),
|
||||
App.Services.GetRequiredService<IEnrollmentRepository>(),
|
||||
vm.Group.Id,
|
||||
vm.Group.SchoolYear);
|
||||
App.Services.GetRequiredService<IGroupMembershipRepository>(),
|
||||
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<IGradingKeyTemplateRepository>(),
|
||||
App.Services.GetRequiredService<GradingService>(),
|
||||
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<IExamResultRepository>(),
|
||||
App.Services.GetRequiredService<IStudentRepository>(),
|
||||
App.Services.GetRequiredService<IEnrollmentRepository>(),
|
||||
App.Services.GetRequiredService<IGroupMembershipRepository>(),
|
||||
App.Services.GetRequiredService<GradingService>(),
|
||||
exam, vm.Group.Id, vm.Group.SchoolYear);
|
||||
exam, vm.Group.Id);
|
||||
|
||||
var dialog = new ExamGradingDialog { DataContext = dialogVm };
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
|
||||
@@ -153,7 +153,7 @@ public partial class ParticipationTabView : UserControl
|
||||
App.Services.GetRequiredService<IParticipationRepository>(),
|
||||
App.Services.GetRequiredService<IParticipationAspectRepository>(),
|
||||
App.Services.GetRequiredService<IStudentRepository>(),
|
||||
App.Services.GetRequiredService<IEnrollmentRepository>(),
|
||||
App.Services.GetRequiredService<IGroupMembershipRepository>(),
|
||||
App.Services.GetRequiredService<IGradeRepository>(),
|
||||
App.Services.GetRequiredService<GradingService>(),
|
||||
tabVm.GroupId, tabVm.SchoolYear, tabVm.GradingSystem);
|
||||
|
||||
@@ -40,9 +40,9 @@
|
||||
<ScrollViewer Padding="20">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="Lerngruppen" FontSize="15" FontWeight="SemiBold"/>
|
||||
<ItemsControl ItemsSource="{Binding Enrollments}">
|
||||
<ItemsControl ItemsSource="{Binding GroupMemberships}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:EnrollmentEntry">
|
||||
<DataTemplate DataType="vm:GroupMembershipEntry">
|
||||
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||
CornerRadius="6" Padding="12,8" Margin="0,0,0,6">
|
||||
<Grid ColumnDefinitions="70,*,Auto">
|
||||
@@ -54,8 +54,8 @@
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<TextBlock Text="Keine Einschreibungen vorhanden." Opacity="0.4"
|
||||
IsVisible="{Binding !Enrollments.Count}"/>
|
||||
<TextBlock Text="Keine Lerngruppenzuordnungen vorhanden." Opacity="0.4"
|
||||
IsVisible="{Binding !GroupMemberships.Count}"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</ContentPage>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user