Neue Funktion Schüler austragen statt aus Kurs löschen.

This commit is contained in:
2026-08-15 23:38:42 +02:00
parent 52bb9c2c2f
commit da8d2bb1da
19 changed files with 473 additions and 114 deletions
@@ -0,0 +1,64 @@
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
public enum SchoolYearPeriodKind { FullYear, H1, H2 }
/// <summary>
/// Gemeinsame Regeln für zeitlich begrenzte Gruppenzugehörigkeiten.
/// Eintritt und Austritt gelten unabhängig vom gewählten Halbjahresmodus.
/// </summary>
public static class GroupMembershipService
{
public static bool IsActiveOn(GroupMembership membership, DateOnly date)
{
if (membership.JoinedAt is { } joinedAt && date < joinedAt) return false;
if (membership.LeftAt is { } leftAt && date > leftAt) return false;
return membership.Period switch
{
MembershipPeriod.H1Only => IsFirstSemester(date),
MembershipPeriod.H2Only => !IsFirstSemester(date),
_ => true,
};
}
public static bool Overlaps(GroupMembership membership, DateOnly from, DateOnly to)
{
if (to < from) return false;
var effectiveFrom = membership.JoinedAt is { } joinedAt && joinedAt > from ? joinedAt : from;
var effectiveTo = membership.LeftAt is { } leftAt && leftAt < to ? leftAt : to;
if (effectiveTo < effectiveFrom) return false;
if (membership.Period is MembershipPeriod.FullYear or MembershipPeriod.Custom) return true;
for (var month = new DateOnly(effectiveFrom.Year, effectiveFrom.Month, 1);
month <= effectiveTo;
month = month.AddMonths(1))
{
if (membership.Period == MembershipPeriod.H1Only && IsFirstSemester(month)) return true;
if (membership.Period == MembershipPeriod.H2Only && !IsFirstSemester(month)) return true;
}
return false;
}
public static (DateOnly From, DateOnly To) SchoolYearPeriod(
string schoolYear, SchoolYearPeriodKind period)
{
var slash = schoolYear.IndexOf('/');
var startText = slash >= 0 ? schoolYear[..slash] : schoolYear;
if (!int.TryParse(startText, out var startYear))
throw new ArgumentException("Ungültiges Schuljahr.", nameof(schoolYear));
return period switch
{
SchoolYearPeriodKind.H1 => (new DateOnly(startYear, 8, 1), new DateOnly(startYear + 1, 1, 31)),
SchoolYearPeriodKind.H2 => (new DateOnly(startYear + 1, 2, 1), new DateOnly(startYear + 1, 7, 31)),
_ => (new DateOnly(startYear, 8, 1), new DateOnly(startYear + 1, 7, 31)),
};
}
private static bool IsFirstSemester(DateOnly date) => date.Month >= 8 || date.Month <= 1;
}
+6 -2
View File
@@ -21,8 +21,12 @@ public class FakeMemberships(List<GroupMembership> all) : IGroupMembershipReposi
public List<GroupMembership> GetByGroup(Guid groupId) => all.Where(m => m.GroupId == groupId).ToList();
public GroupMembership? GetByStudentAndGroup(Guid studentId, Guid groupId) =>
all.FirstOrDefault(m => m.StudentId == studentId && m.GroupId == groupId);
public void Save(GroupMembership membership) { }
public void Delete(Guid id) { }
public void Save(GroupMembership membership)
{
all.RemoveAll(m => m.Id == membership.Id);
all.Add(membership);
}
public void Delete(Guid id) => all.RemoveAll(m => m.Id == id);
}
public class FakeSessions(List<ParticipationSession> all) : IParticipationSessionRepository
@@ -22,7 +22,8 @@ public class ReportGradeCalculationTests
var grading = new GradingService();
return new ReportGradeDialogViewModel(grades, exams, results, students, memberships,
schemes, reportGrades, grading, GroupId, GroupType.Class, GradingSystem.Grades1To6, "Testgruppe");
schemes, reportGrades, grading, GroupId, GroupType.Class, GradingSystem.Grades1To6,
"Testgruppe", "2025/26");
}
private static (Exam Exam1, Exam Exam2, FakeExams Exams, FakeResults Results) BuildExams()
@@ -0,0 +1,39 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public class WithdrawStudentDialogViewModelTests
{
[Fact]
public void InitialisiertMitHeutigemDatumUndSpeichertAustritt()
{
var membership = new GroupMembership { JoinedAt = new DateOnly(2025, 9, 1) };
var memberships = new FakeMemberships([membership]);
var vm = new WithdrawStudentDialogViewModel(
memberships, membership, "Anna Beispiel", "7a", new DateOnly(2025, 10, 5));
Assert.Equal(new DateOnly(2025, 10, 5), DateOnly.FromDateTime(vm.SelectedDate!.Value.LocalDateTime));
vm.SaveCommand.Execute(null);
Assert.True(vm.WasSaved);
Assert.Equal(new DateOnly(2025, 10, 5), membership.LeftAt);
}
[Fact]
public void VerhindertAustrittVorEintritt()
{
var membership = new GroupMembership { JoinedAt = new DateOnly(2025, 9, 1) };
var vm = new WithdrawStudentDialogViewModel(
new FakeMemberships([membership]), membership, "Anna Beispiel", "7a");
vm.SelectedDate = new DateTimeOffset(2025, 8, 31, 0, 0, 0, TimeSpan.Zero);
vm.SaveCommand.Execute(null);
Assert.False(vm.WasSaved);
Assert.Null(membership.LeftAt);
Assert.NotEmpty(vm.DateError);
}
}
@@ -37,7 +37,7 @@ public partial class ExamGradingDialogViewModel : ObservableObject
foreach (var s in enrolled.OrderBy(s => s.LastName).ThenBy(s => s.FirstName))
{
var membership = membershipList.FirstOrDefault(e => e.StudentId == s.Id);
if (membership is not null && !IsMemberAtDate(membership, exam.Date)) continue;
if (membership is not null && !GroupMembershipService.IsActiveOn(membership, exam.Date)) continue;
// Niveau-Klausur: nur Schüler mit passendem Niveau zeigen. Klausuren ohne
// Niveau-Zuordnung gelten weiterhin für die ganze Gruppe.
@@ -52,14 +52,6 @@ public partial class ExamGradingDialogViewModel : ObservableObject
private void SaveRow(ExamResultRow row) => _results.Save(row.ToModel(_exam.Id));
private static bool IsMemberAtDate(GroupMembership membership, DateOnly date) => membership.Period switch
{
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,
};
}
// ── Zeile im Punkteraster ──────────────────────────────────────────────────
@@ -23,6 +23,7 @@ public partial class GradeOverviewTabViewModel : ObservableObject
private GradingSystem _gradingSystem;
private GroupType _groupType;
private string _groupLabel = "";
private string _schoolYear = "";
private bool _sortByTotal;
private bool _sortDescending;
@@ -30,6 +31,7 @@ public partial class GradeOverviewTabViewModel : ObservableObject
public GradingSystem GradingSystem => _gradingSystem;
public GroupType GroupType => _groupType;
public string GroupLabel => _groupLabel;
public string SchoolYear => _schoolYear;
[ObservableProperty] private ParticipationPeriodOption _selectedPeriod;
[ObservableProperty] private bool _showAsPoints = true;
@@ -61,12 +63,14 @@ public partial class GradeOverviewTabViewModel : ObservableObject
_selectedPeriod = PeriodOptions[0];
}
public void Initialize(Guid groupId, GradingSystem gradingSystem, GroupType groupType, string groupLabel)
public void Initialize(Guid groupId, GradingSystem gradingSystem, GroupType groupType,
string groupLabel, string schoolYear)
{
_groupId = groupId;
_gradingSystem = gradingSystem;
_groupType = groupType;
_groupLabel = groupLabel;
_schoolYear = schoolYear;
ShowAsPoints = gradingSystem == GradingSystem.Points0To15;
OnPropertyChanged(nameof(CanTogglePointsView));
Recompute();
@@ -119,18 +123,24 @@ public partial class GradeOverviewTabViewModel : ObservableObject
private void Recompute()
{
var period = SelectedPeriod.Period;
var (periodFrom, periodTo) = GroupMembershipService.SchoolYearPeriod(_schoolYear, period switch
{
ParticipationPeriod.H1 => SchoolYearPeriodKind.H1,
ParticipationPeriod.H2 => SchoolYearPeriodKind.H2,
_ => SchoolYearPeriodKind.FullYear,
});
var students = _students.GetByGroup(_groupId);
var membershipsByStudent = _memberships.GetByGroup(_groupId).ToDictionary(m => m.StudentId);
var exams = _exams.GetByGroup(_groupId)
.Where(e => InPeriod(e.Date, period))
.Where(e => e.Date >= periodFrom && e.Date <= periodTo)
.OrderBy(e => e.Date)
.ToList();
var resultsByExam = exams.ToDictionary(e => e.Id, e => _results.GetByExam(e.Id).ToDictionary(r => r.StudentId));
var otherGrades = _grades.GetByGroup(_groupId)
.Where(g => InPeriod(g.Date, period))
.Where(g => g.Date >= periodFrom && g.Date <= periodTo)
.ToList();
var gradeColumnKeys = otherGrades
.Select(g => (g.Category, Note: g.Note ?? "", g.Date))
@@ -153,13 +163,15 @@ public partial class GradeOverviewTabViewModel : ObservableObject
foreach (var student in students)
{
membershipsByStudent.TryGetValue(student.Id, out var membership);
if (!StudentActiveInPeriod(membership, period)) continue;
if (membership is not null && !GroupMembershipService.Overlaps(membership, periodFrom, periodTo)) continue;
var cells = new List<string>();
var numeric = new List<(string Grade, double Weight)>();
foreach (var exam in exams)
{
if (membership is not null && !GroupMembershipService.IsActiveOn(membership, exam.Date))
{ cells.Add(""); continue; }
if (exam.Niveau.HasValue && membership?.Niveau != exam.Niveau) { cells.Add(""); continue; }
resultsByExam[exam.Id].TryGetValue(student.Id, out var result);
if (result is null) { cells.Add(""); continue; }
@@ -171,6 +183,8 @@ public partial class GradeOverviewTabViewModel : ObservableObject
foreach (var key in gradeColumnKeys)
{
if (membership is not null && !GroupMembershipService.IsActiveOn(membership, key.Date))
{ cells.Add(""); continue; }
var grade = otherGrades.FirstOrDefault(g =>
g.StudentId == student.Id && g.Category == key.Category &&
(g.Note ?? "") == key.Note && g.Date == key.Date);
@@ -206,23 +220,6 @@ public partial class GradeOverviewTabViewModel : ObservableObject
for (var i = 0; i < sorted.Count; i++) Rows.Move(Rows.IndexOf(sorted[i]), i);
}
private static bool InPeriod(DateOnly date, ParticipationPeriod period) => period switch
{
ParticipationPeriod.H1 => date.Month >= 8 || date.Month <= 1,
ParticipationPeriod.H2 => date.Month >= 2 && date.Month <= 7,
_ => true,
};
private static bool StudentActiveInPeriod(GroupMembership? m, ParticipationPeriod period)
{
if (period == ParticipationPeriod.FullYear || m is null) return true;
return m.Period switch
{
MembershipPeriod.H1Only => period == ParticipationPeriod.H1,
MembershipPeriod.H2Only => period == ParticipationPeriod.H2,
_ => true,
};
}
}
public class GradeOverviewColumnDef(string header)
@@ -404,6 +401,8 @@ public partial class GradeEditItem : ObservableObject
public partial class CollectiveGradeDialogViewModel : ObservableObject
{
private readonly IGradeRepository _grades;
private readonly IGroupMembershipRepository _memberships;
private readonly List<Student> _students;
private readonly Guid _groupId;
[ObservableProperty] private GradeCategory _category = GradeCategory.Other;
@@ -422,11 +421,36 @@ public partial class CollectiveGradeDialogViewModel : ObservableObject
public ObservableCollection<CollectiveGradeStudentRow> Rows { get; } = [];
public CollectiveGradeDialogViewModel(IGradeRepository grades, IStudentRepository students, Guid groupId)
public CollectiveGradeDialogViewModel(IGradeRepository grades, IStudentRepository students,
IGroupMembershipRepository memberships, Guid groupId)
{
_grades = grades; _groupId = groupId;
foreach (var s in students.GetByGroup(groupId).OrderBy(s => s.LastName).ThenBy(s => s.FirstName))
Rows.Add(new CollectiveGradeStudentRow(s.Id, s.FullName));
_grades = grades; _memberships = memberships; _groupId = groupId;
_students = students.GetByGroup(groupId).OrderBy(s => s.LastName).ThenBy(s => s.FirstName).ToList();
RebuildRows(DateOnly.FromDateTime(DateTime.Today));
}
partial void OnDateTextChanged(string value)
{
if (DateOnly.TryParseExact(value, "dd.MM.yyyy", null,
System.Globalization.DateTimeStyles.None, out var date))
RebuildRows(date);
}
private void RebuildRows(DateOnly date)
{
var previousValues = Rows.ToDictionary(r => r.StudentId, r => r.Value);
var membershipByStudent = _memberships.GetByGroup(_groupId).ToDictionary(m => m.StudentId);
Rows.Clear();
foreach (var student in _students)
{
if (membershipByStudent.TryGetValue(student.Id, out var membership)
&& !GroupMembershipService.IsActiveOn(membership, date))
continue;
Rows.Add(new CollectiveGradeStudentRow(student.Id, student.FullName)
{
Value = previousValues.GetValueOrDefault(student.Id, ""),
});
}
}
[RelayCommand]
@@ -169,6 +169,7 @@ public partial class GroupDetailViewModel : ObservableObject
[ObservableProperty] private int _activeTabIndex = 0;
[ObservableProperty] private StudentSummary? _selectedStudent;
[ObservableProperty] private ExamSummary? _selectedExam;
[ObservableProperty] private bool _showFormerStudents;
// Eigene Property statt "Group.IsDifferentiated" im Binding-Pfad: die View bindet bereits,
// bevor LoadGroup() läuft (siehe MainWindowViewModel.NavigateToGroupDetail), Group ist dann
@@ -177,6 +178,7 @@ public partial class GroupDetailViewModel : ObservableObject
public string SubjectName { get; private set; } = "";
partial void OnGroupChanged(LearningGroup? value) => OnPropertyChanged(nameof(IsDifferentiated));
partial void OnShowFormerStudentsChanged(bool value) => LoadStudents();
public ObservableCollection<StudentSummary> Students { get; } = [];
public ObservableCollection<ExamSummary> Exams { get; } = [];
@@ -185,6 +187,7 @@ public partial class GroupDetailViewModel : ObservableObject
public GradeOverviewTabViewModel GradeOverviewTab { get; }
public PlanningTabViewModel PlanningTab { get; }
public Func<Task<bool>>? OnAddStudent { get; set; }
public Func<StudentSummary, Task<bool>>? OnWithdrawStudent { get; set; }
public Func<Guid, Task<bool>>? OnAddExam { get; set; }
public Func<Exam, Task<bool>>? OnEditExam { get; set; }
public Func<Exam, Task<bool>>? OnDuplicateExam { get; set; }
@@ -219,7 +222,7 @@ public partial class GroupDetailViewModel : ObservableObject
LoadStudents();
ReloadExams();
ParticipationTab.Initialize(Group.Id, Group.SchoolYear);
GradeOverviewTab.Initialize(Group.Id, Group.GradingSystem, Group.Type, GroupTitle);
GradeOverviewTab.Initialize(Group.Id, Group.GradingSystem, Group.Type, GroupTitle, Group.SchoolYear);
PlanningTab.Initialize(Group.Id);
}
@@ -238,11 +241,14 @@ public partial class GroupDetailViewModel : ObservableObject
Students.Clear();
var enrolled = _students.GetByGroup(Group.Id);
var memberships = _memberships.GetByGroup(Group.Id).ToDictionary(e => e.StudentId);
StudentCount = enrolled.Count;
foreach (var s in enrolled)
var today = DateOnly.FromDateTime(DateTime.Today);
StudentCount = memberships.Values.Count(m => GroupMembershipService.IsActiveOn(m, today));
foreach (var s in enrolled.OrderBy(s => s.LastName).ThenBy(s => s.FirstName))
{
memberships.TryGetValue(s.Id, out var membership);
var summary = new StudentSummary(s, membership) { OnChanged = SaveStudentNiveau };
if (membership is null) continue;
var summary = new StudentSummary(s, membership, today) { OnChanged = SaveStudentNiveau };
if (summary.IsFormer && !ShowFormerStudents) continue;
Students.Add(summary);
}
}
@@ -269,21 +275,36 @@ public partial class GroupDetailViewModel : ObservableObject
}
[RelayCommand(CanExecute = nameof(HasSelectedStudent))]
private void RemoveStudent()
private async Task WithdrawStudent()
{
if (Group is null || SelectedStudent is null) return;
var membership = _memberships.GetByStudentAndGroup(SelectedStudent.Id, Group.Id);
if (membership is null) return;
_memberships.Delete(membership.Id);
if (SelectedStudent is null || OnWithdrawStudent is null) return;
if (!await OnWithdrawStudent(SelectedStudent)) return;
LoadStudents();
SelectedStudent = null;
ParticipationTab.RefreshCurrentGrid();
}
partial void OnSelectedStudentChanged(StudentSummary? value) =>
RemoveStudentCommand.NotifyCanExecuteChanged();
[RelayCommand(CanExecute = nameof(CanReinstateSelectedStudent))]
private void ReinstateStudent()
{
if (Group is null || SelectedStudent is null) return;
var membership = _memberships.GetByStudentAndGroup(SelectedStudent.Id, Group.Id);
if (membership is null) return;
membership.LeftAt = null;
_memberships.Save(membership);
LoadStudents();
SelectedStudent = null;
ParticipationTab.RefreshCurrentGrid();
}
partial void OnSelectedStudentChanged(StudentSummary? value)
{
WithdrawStudentCommand.NotifyCanExecuteChanged();
ReinstateStudentCommand.NotifyCanExecuteChanged();
}
private bool HasSelectedStudent() => SelectedStudent is not null;
private bool CanReinstateSelectedStudent() => SelectedStudent?.HasExitDate == true;
[RelayCommand]
private async Task AddExam()
@@ -423,6 +444,10 @@ public partial class StudentSummary : ObservableObject
public Guid Id { get; }
public string FullName { get; }
public string PeriodLabel { get; }
public bool IsFormer { get; }
public bool HasExitDate { get; }
public string MembershipStatus { get; }
public string WithdrawActionLabel => HasExitDate ? "Austrittsdatum ändern" : "Austragen";
[ObservableProperty] private Niveau? _niveau;
@@ -434,17 +459,17 @@ public partial class StudentSummary : ObservableObject
public Action<StudentSummary>? OnChanged { get; set; }
public StudentSummary(Core.Models.Student s, GroupMembership? membership)
public StudentSummary(Core.Models.Student s, GroupMembership? membership, DateOnly? today = null)
{
Id = s.Id;
FullName = s.FullName;
PeriodLabel = membership?.Period switch
{
MembershipPeriod.H1Only => "H1",
MembershipPeriod.H2Only => "H2",
MembershipPeriod.Custom => BuildCustomLabel(membership),
_ => "",
};
PeriodLabel = membership is null ? "" : BuildPeriodLabel(membership);
HasExitDate = membership?.LeftAt.HasValue == true;
IsFormer = membership?.LeftAt is { } leftAt
&& leftAt < (today ?? DateOnly.FromDateTime(DateTime.Today));
MembershipStatus = membership?.LeftAt is { } exitDate
? IsFormer ? $"Ausgetreten am {exitDate:dd.MM.yyyy}" : $"Austritt am {exitDate:dd.MM.yyyy}"
: "Aktiv";
_niveau = membership?.Niveau;
}
@@ -454,12 +479,20 @@ public partial class StudentSummary : ObservableObject
OnChanged?.Invoke(this);
}
private static string BuildCustomLabel(GroupMembership membership)
private static string BuildPeriodLabel(GroupMembership membership)
{
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";
var period = membership.Period switch
{
MembershipPeriod.H1Only => "H1",
MembershipPeriod.H2Only => "H2",
MembershipPeriod.Custom => "Datum",
_ => "Ganzes Jahr",
};
if (membership.JoinedAt.HasValue && membership.LeftAt.HasValue)
return $"{period} · {membership.JoinedAt:dd.MM.}{membership.LeftAt:dd.MM.}";
if (membership.JoinedAt.HasValue) return $"{period} · ab {membership.JoinedAt:dd.MM.}";
if (membership.LeftAt.HasValue) return $"{period} · bis {membership.LeftAt:dd.MM.}";
return period;
}
}
@@ -591,6 +624,63 @@ public class StudentPickerItem
public StudentPickerItem(Student s) { Id = s.Id; FullName = s.FullName; }
}
// ── Dialog: Schüler aus einer Lerngruppe austragen ───────────────────────────
public partial class WithdrawStudentDialogViewModel : ObservableObject
{
private readonly IGroupMembershipRepository _memberships;
private readonly GroupMembership _membership;
[ObservableProperty] private DateTimeOffset? _selectedDate;
[ObservableProperty] private string _dateError = "";
public string StudentName { get; }
public string GroupName { get; }
public DateTimeOffset? EarliestDate { get; }
public bool WasSaved { get; private set; }
public WithdrawStudentDialogViewModel(IGroupMembershipRepository memberships,
GroupMembership membership, string studentName, string groupName, DateOnly? today = null)
{
_memberships = memberships;
_membership = membership;
StudentName = studentName;
GroupName = groupName;
var initialDate = membership.LeftAt ?? today ?? DateOnly.FromDateTime(DateTime.Today);
_selectedDate = ToDateTimeOffset(initialDate);
EarliestDate = membership.JoinedAt is { } joinedAt ? ToDateTimeOffset(joinedAt) : null;
}
[RelayCommand]
private void Save()
{
DateError = "";
if (SelectedDate is null)
{
DateError = "Bitte ein Austrittsdatum auswählen.";
return;
}
var exitDate = DateOnly.FromDateTime(SelectedDate.Value.LocalDateTime);
if (_membership.JoinedAt is { } joinedAt && exitDate < joinedAt)
{
DateError = $"Das Austrittsdatum darf nicht vor dem Eintritt am {joinedAt:dd.MM.yyyy} liegen.";
return;
}
_membership.LeftAt = exitDate;
_memberships.Save(_membership);
WasSaved = true;
}
private static DateTimeOffset ToDateTimeOffset(DateOnly date)
{
var localDateTime = date.ToDateTime(TimeOnly.MinValue);
return new DateTimeOffset(localDateTime, TimeZoneInfo.Local.GetUtcOffset(localDateTime));
}
}
// ── Dialog: Neue Lerngruppe anlegen ──────────────────────────────────────────
public partial class AddGroupDialogViewModel : ObservableObject
@@ -90,7 +90,7 @@ public partial class ParticipationGradeDialogViewModel : ObservableObject
{
var membership = membershipList.FirstOrDefault(e => e.StudentId == student.Id);
var relevantSessions = sessions
.Where(s => membership is null || IsMemberAtDate(membership, s.Date))
.Where(s => membership is null || GroupMembershipService.IsActiveOn(membership, s.Date))
.ToList();
var points = new List<(DateOnly Date, double Rating)>();
@@ -152,14 +152,6 @@ public partial class ParticipationGradeDialogViewModel : ObservableObject
_ => true,
};
private static bool IsMemberAtDate(GroupMembership membership, DateOnly date) => membership.Period switch
{
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,
};
}
// ── Gewichtung eines Aspekts (3.2.1) ─────────────────────────────────────────
@@ -2,6 +2,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using System.Collections.ObjectModel;
using System.Collections.Generic;
@@ -140,7 +141,7 @@ public partial class ParticipationTabViewModel : ObservableObject
foreach (var s in students)
{
var membership = memberships.FirstOrDefault(e => e.StudentId == s.Id);
if (membership is not null && !IsMemberAtDate(membership, sessionDate))
if (membership is not null && !GroupMembershipService.IsActiveOn(membership, sessionDate))
continue;
var entry = entries.FirstOrDefault(e => e.StudentId == s.Id)
@@ -157,15 +158,6 @@ public partial class ParticipationTabViewModel : ObservableObject
RebuildColumnsSignal++;
}
private static bool IsMemberAtDate(GroupMembership membership, DateOnly date) => membership.Period switch
{
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 entry = _entries.GetBySessionAndStudent(sessionId, studentId)
@@ -20,6 +20,7 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
private readonly IExamRepository _exams;
private readonly IExamResultRepository _results;
private readonly IGradeRepository _grades;
private readonly Dictionary<Guid, GroupMembership> _membershipsByStudent;
private readonly GradingService _grading;
private readonly Guid _groupId;
private readonly string _schoolYear;
@@ -69,7 +70,8 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
public ParticipationWizardDialogViewModel(
IParticipationSessionRepository sessions, IParticipationRepository entries,
IParticipationAspectRepository aspects, IParticipationSectionRepository sectionRepo,
IStudentRepository students, IExamRepository exams, IExamResultRepository results,
IStudentRepository students, IGroupMembershipRepository memberships,
IExamRepository exams, IExamResultRepository results,
IGradeRepository grades, GradingService grading,
Guid groupId, string schoolYear, GradingSystem gradingSystem, string groupLabel)
{
@@ -77,12 +79,17 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
_exams = exams; _results = results; _grades = grades; _grading = grading;
_groupId = groupId; _schoolYear = schoolYear; _gradingSystem = gradingSystem;
GroupLabel = groupLabel;
_membershipsByStudent = memberships.GetByGroup(groupId).ToDictionary(m => m.StudentId);
_aspectWeights = aspects.GetDefaults()
.Concat(aspects.GetByGroup(groupId))
.GroupBy(a => a.Key)
.ToDictionary(g => g.Key, g => g.Last().Weight);
_students = students.GetByGroup(groupId).OrderBy(s => s.LastName).ThenBy(s => s.FirstName).ToList();
var schoolYearRange = GroupMembershipService.SchoolYearPeriod(schoolYear, SchoolYearPeriodKind.FullYear);
_students = students.GetByGroup(groupId)
.Where(s => !_membershipsByStudent.TryGetValue(s.Id, out var membership)
|| GroupMembershipService.Overlaps(membership, schoolYearRange.From, schoolYearRange.To))
.OrderBy(s => s.LastName).ThenBy(s => s.FirstName).ToList();
_allSessions = sessions.GetByGroup(groupId).OrderBy(s => s.Date).ToList();
_sectionList = sectionRepo.GetByGroup(groupId).OrderBy(s => s.StartDate).ToList();
@@ -123,17 +130,20 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
foreach (var session in _allSessions)
{
if (!IsStudentActiveOn(studentId, session.Date)) continue;
var entry = _entries.GetBySessionAndStudent(session.Id, studentId);
points.Add((session.Date, BuildSessionPoint(session, entry, studentId)));
}
foreach (var exam in _exams.GetByGroup(_groupId).OrderBy(e => e.Date))
{
if (!IsStudentActiveOn(studentId, exam.Date)) continue;
var result = _results.GetByExamAndStudent(exam.Id, studentId);
if (result is null) continue;
points.Add((exam.Date, BuildExamPoint(exam, result)));
}
foreach (var grade in _grades.GetByStudentAndGroup(studentId, _groupId)
.Where(g => g.Category != GradeCategory.Participation))
.Where(g => g.Category != GradeCategory.Participation)
.Where(g => IsStudentActiveOn(studentId, g.Date)))
{
points.Add((grade.Date, BuildOtherGradePoint(grade)));
}
@@ -269,6 +279,7 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
foreach (var section in _sectionList)
{
if (!StudentOverlaps(studentId, section.StartDate, section.EndDate)) continue;
var grade = studentGrades.FirstOrDefault(g => g.Note == AbschnittPrefix + section.Label);
var row = new WizardSectionRow(section.Label, section.StartDate, section.EndDate,
grade?.Value ?? "", isOpen: false);
@@ -285,7 +296,8 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
private string? ComputeSuggestion(Guid studentId, DateOnly start, DateOnly end)
{
var points = new List<double>();
foreach (var session in _allSessions.Where(s => s.Date >= start && s.Date <= end))
foreach (var session in _allSessions.Where(s => s.Date >= start && s.Date <= end)
.Where(s => IsStudentActiveOn(studentId, s.Date)))
{
var entry = _entries.GetBySessionAndStudent(session.Id, studentId);
if (entry is null || entry.Ratings.Count == 0) continue;
@@ -375,6 +387,7 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
var sectionGrades = _grades.GetByStudentAndGroup(student.Id, _groupId)
.Where(g => g.Category == GradeCategory.Participation && g.Note is not null && g.Note.StartsWith(AbschnittPrefix))
.Where(g => InPeriod(g.Date, RollupPeriod.Period))
.Where(g => IsStudentActiveOn(student.Id, g.Date))
.Select(g => (g.Value, g.Weight))
.ToList();
if (sectionGrades.Count == 0) continue;
@@ -401,6 +414,14 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
ParticipationPeriod.H2 => date.Month >= 2 && date.Month <= 7,
_ => true,
};
private bool IsStudentActiveOn(Guid studentId, DateOnly date) =>
!_membershipsByStudent.TryGetValue(studentId, out var membership)
|| GroupMembershipService.IsActiveOn(membership, date);
private bool StudentOverlaps(Guid studentId, DateOnly from, DateOnly to) =>
!_membershipsByStudent.TryGetValue(studentId, out var membership)
|| GroupMembershipService.Overlaps(membership, from, to);
}
// ── Zeitleisten-Bausteine ────────────────────────────────────────────────────
@@ -25,6 +25,7 @@ public partial class ReportGradeDialogViewModel : ObservableObject
private readonly GroupType _groupType;
private readonly GradingSystem _gradingSystem;
private readonly string _groupLabel;
private readonly string _schoolYear;
[ObservableProperty] private ParticipationPeriodOption _selectedPeriod;
[ObservableProperty] private RoundingRule _roundingRule = RoundingRule.Commercial;
@@ -51,11 +52,12 @@ public partial class ReportGradeDialogViewModel : ObservableObject
public ReportGradeDialogViewModel(IGradeRepository grades, IExamRepository exams,
IExamResultRepository results, IStudentRepository students, IGroupMembershipRepository memberships,
IGradingSchemeRepository schemes, IReportGradeRepository reportGrades, GradingService grading,
Guid groupId, GroupType groupType, GradingSystem gradingSystem, string groupLabel)
Guid groupId, GroupType groupType, GradingSystem gradingSystem, string groupLabel, string schoolYear)
{
_grades = grades; _exams = exams; _results = results; _students = students;
_memberships = memberships; _schemes = schemes; _reportGrades = reportGrades; _grading = grading;
_groupId = groupId; _groupType = groupType; _gradingSystem = gradingSystem; _groupLabel = groupLabel;
_schoolYear = schoolYear;
_selectedPeriod = PeriodOptions[0];
Recompute();
@@ -77,18 +79,24 @@ public partial class ReportGradeDialogViewModel : ObservableObject
var period = SelectedPeriod.Period;
var periodTag = SelectedPeriod.Label;
var (periodFrom, periodTo) = GroupMembershipService.SchoolYearPeriod(_schoolYear, period switch
{
ParticipationPeriod.H1 => SchoolYearPeriodKind.H1,
ParticipationPeriod.H2 => SchoolYearPeriodKind.H2,
_ => SchoolYearPeriodKind.FullYear,
});
var students = _students.GetByGroup(_groupId);
var membershipsByStudent = _memberships.GetByGroup(_groupId).ToDictionary(m => m.StudentId);
var exams = _exams.GetByGroup(_groupId).Where(e => InPeriod(e.Date, period)).ToList();
var exams = _exams.GetByGroup(_groupId).Where(e => e.Date >= periodFrom && e.Date <= periodTo).ToList();
var resultsByExam = exams.ToDictionary(e => e.Id, e => _results.GetByExam(e.Id).ToDictionary(r => r.StudentId));
var allGrades = _grades.GetByGroup(_groupId).Where(g => InPeriod(g.Date, period)).ToList();
var allGrades = _grades.GetByGroup(_groupId).Where(g => g.Date >= periodFrom && g.Date <= periodTo).ToList();
Rows.Clear();
foreach (var student in students.OrderBy(s => s.LastName).ThenBy(s => s.FirstName))
{
membershipsByStudent.TryGetValue(student.Id, out var membership);
if (!StudentActiveInPeriod(membership, period)) continue;
if (membership is not null && !GroupMembershipService.Overlaps(membership, periodFrom, periodTo)) continue;
var existing = _reportGrades.GetByStudentGroupPeriod(student.Id, _groupId, periodTag);
@@ -101,6 +109,7 @@ public partial class ReportGradeDialogViewModel : ObservableObject
var examGrades = new List<(string Grade, double Weight)>();
foreach (var exam in exams)
{
if (membership is not null && !GroupMembershipService.IsActiveOn(membership, exam.Date)) continue;
if (exam.Niveau.HasValue && membership?.Niveau != exam.Niveau) continue;
if (!resultsByExam[exam.Id].TryGetValue(student.Id, out var result)) continue;
if (result.Absent || result.Grade is null) continue;
@@ -108,10 +117,12 @@ public partial class ReportGradeDialogViewModel : ObservableObject
}
var participationGrades = allGrades
.Where(g => g.StudentId == student.Id && g.Category == GradeCategory.Participation)
.Where(g => g.StudentId == student.Id && g.Category == GradeCategory.Participation
&& (membership is null || GroupMembershipService.IsActiveOn(membership, g.Date)))
.Select(g => (g.Value, g.Weight)).ToList();
var otherGrades = allGrades
.Where(g => g.StudentId == student.Id && g.Category != GradeCategory.Participation)
.Where(g => g.StudentId == student.Id && g.Category != GradeCategory.Participation
&& (membership is null || GroupMembershipService.IsActiveOn(membership, g.Date)))
.Select(g => (g.Value, g.Weight)).ToList();
var calculated = _grading.CalculateReportGrade(examGrades, participationGrades, otherGrades,
@@ -168,23 +179,6 @@ public partial class ReportGradeDialogViewModel : ObservableObject
return sb.ToString();
}
private static bool InPeriod(DateOnly date, ParticipationPeriod period) => period switch
{
ParticipationPeriod.H1 => date.Month >= 8 || date.Month <= 1,
ParticipationPeriod.H2 => date.Month >= 2 && date.Month <= 7,
_ => true,
};
private static bool StudentActiveInPeriod(GroupMembership? m, ParticipationPeriod period)
{
if (period == ParticipationPeriod.FullYear || m is null) return true;
return m.Period switch
{
MembershipPeriod.H1Only => period == ParticipationPeriod.H1,
MembershipPeriod.H2Only => period == ParticipationPeriod.H2,
_ => true,
};
}
}
// ── Rundungsregel-Anzeige ─────────────────────────────────────────────────────
@@ -48,6 +48,7 @@ public partial class GradeOverviewTabView : UserControl
var dialogVm = new CollectiveGradeDialogViewModel(
App.Services.GetRequiredService<IGradeRepository>(),
App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IGroupMembershipRepository>(),
_vm!.GroupId);
var dialog = new CollectiveGradeDialog { DataContext = dialogVm };
@@ -66,7 +67,7 @@ public partial class GradeOverviewTabView : UserControl
App.Services.GetRequiredService<IGradingSchemeRepository>(),
App.Services.GetRequiredService<IReportGradeRepository>(),
App.Services.GetRequiredService<GradingService>(),
_vm!.GroupId, _vm.GroupType, _vm.GradingSystem, _vm.GroupLabel);
_vm!.GroupId, _vm.GroupType, _vm.GradingSystem, _vm.GroupLabel, _vm.SchoolYear);
var dialog = new ReportGradeDialog { DataContext = dialogVm };
var owner = TopLevel.GetTopLevel(this) as Window;
@@ -20,9 +20,14 @@
<Run Text="{Binding StudentCount}"/>
<Run Text=" Schüler"/>
</TextBlock>
<CheckBox Content="Ausgetretene anzeigen" IsChecked="{Binding ShowFormerStudents}"
VerticalAlignment="Center"/>
<Button Content=" Schüler" Command="{Binding AddStudentCommand}"/>
<Button Content=" Austragen" Command="{Binding RemoveStudentCommand}"
<Button Content="{Binding SelectedStudent.WithdrawActionLabel}"
Command="{Binding WithdrawStudentCommand}"
IsVisible="{Binding SelectedStudent, Converter={x:Static ObjectConverters.IsNotNull}}"/>
<Button Content="Austragung zurücknehmen" Command="{Binding ReinstateStudentCommand}"
IsVisible="{Binding SelectedStudent.HasExitDate}"/>
<Button Content=" Klausur" Command="{Binding AddExamCommand}"/>
</StackPanel>
</Grid>
@@ -52,8 +57,9 @@
CanUserResizeColumns="True"
Margin="0">
<DataGrid.Columns>
<DataGridTextColumn Header="Name" Binding="{Binding FullName}" Width="*"/>
<DataGridTextColumn Header="Zeitraum" Binding="{Binding PeriodLabel}" Width="90"/>
<DataGridTextColumn Header="Name" Binding="{Binding FullName}" Width="*"/>
<DataGridTextColumn Header="Zeitraum" Binding="{Binding PeriodLabel}" Width="190"/>
<DataGridTextColumn Header="Status" Binding="{Binding MembershipStatus}" Width="170"/>
<DataGridTemplateColumn Header="Niveau" Width="130"
IsVisible="{Binding IsDifferentiated}">
<DataGridTemplateColumn.CellTemplate>
@@ -17,6 +17,7 @@ public partial class GroupDetailView : UserControl
if (DataContext is GroupDetailViewModel vm)
{
vm.OnAddStudent = ShowAddStudentDialog;
vm.OnWithdrawStudent = ShowWithdrawStudentDialog;
vm.OnAddExam = ShowAddExamDialog;
vm.OnEditExam = ShowEditExamDialog;
vm.OnDuplicateExam = ShowDuplicateExamDialog;
@@ -42,6 +43,20 @@ public partial class GroupDetailView : UserControl
return await dialog.ShowDialog<bool>(owner);
}
private async Task<bool> ShowWithdrawStudentDialog(StudentSummary student)
{
if (DataContext is not GroupDetailViewModel vm || vm.Group is null) return false;
var memberships = App.Services.GetRequiredService<IGroupMembershipRepository>();
var membership = memberships.GetByStudentAndGroup(student.Id, vm.Group.Id);
if (membership is null) return false;
var dialogVm = new WithdrawStudentDialogViewModel(
memberships, membership, student.FullName, vm.Group.Name);
var dialog = new WithdrawStudentDialog { DataContext = dialogVm };
var owner = TopLevel.GetTopLevel(this) as Window;
return owner is not null && await dialog.ShowDialog<bool>(owner);
}
private Task<bool> ShowAddExamDialog(Guid groupId) =>
ShowExamDialog(groupId, editingExam: null, duplicateSource: null);
@@ -290,6 +290,7 @@ public partial class ParticipationTabView : UserControl
App.Services.GetRequiredService<IParticipationAspectRepository>(),
App.Services.GetRequiredService<IParticipationSectionRepository>(),
App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IGroupMembershipRepository>(),
App.Services.GetRequiredService<IExamRepository>(),
App.Services.GetRequiredService<IExamResultRepository>(),
App.Services.GetRequiredService<IGradeRepository>(),
@@ -0,0 +1,42 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.WithdrawStudentDialog"
x:DataType="vm:WithdrawStudentDialogViewModel"
Title="Schüler austragen"
Width="440" Height="330"
CanResize="False" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="14">
<TextBlock Text="Schüler aus Lerngruppe austragen" Classes="dialogtitle"/>
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="6" Padding="12">
<Grid ColumnDefinitions="Auto,12,*" RowDefinitions="Auto,Auto">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Schüler" Opacity="0.65"/>
<TextBlock Grid.Row="0" Grid.Column="2" Text="{Binding StudentName}" FontWeight="SemiBold"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Lerngruppe" Opacity="0.65"/>
<TextBlock Grid.Row="1" Grid.Column="2" Text="{Binding GroupName}" FontWeight="SemiBold"/>
</Grid>
</Border>
<StackPanel Spacing="5">
<TextBlock Text="Austrittsdatum" FontSize="12" Opacity="0.7"/>
<CalendarDatePicker SelectedDate="{Binding SelectedDate}"
DisplayDateStart="{Binding EarliestDate}"
HorizontalAlignment="Stretch"/>
<TextBlock Text="{Binding DateError}" Foreground="Red" FontSize="12"
IsVisible="{Binding DateError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<TextBlock Text="Die bisherige Gruppenzugehörigkeit und alle zugehörigen Leistungen bleiben erhalten."
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,18,0,0">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="Austragen" HorizontalAlignment="Stretch" Click="OnSave"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,19 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.Groups;
namespace LehrerApp.Desktop.Views.Groups;
public partial class WithdrawStudentDialog : Window
{
public WithdrawStudentDialog() => InitializeComponent();
private void OnSave(object? sender, RoutedEventArgs e)
{
if (DataContext is not WithdrawStudentDialogViewModel vm) return;
vm.SaveCommand.Execute(null);
if (vm.WasSaved) Close(true);
}
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
}
@@ -0,0 +1,54 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using Xunit;
namespace LehrerApp.Tests;
public class GroupMembershipServiceTests
{
[Fact]
public void IsActiveOn_BeruecksichtigtEinUndAustrittAuchBeiGanzemJahr()
{
var membership = new GroupMembership
{
Period = MembershipPeriod.FullYear,
JoinedAt = new DateOnly(2025, 9, 1),
LeftAt = new DateOnly(2025, 10, 15),
};
Assert.False(GroupMembershipService.IsActiveOn(membership, new DateOnly(2025, 8, 31)));
Assert.True(GroupMembershipService.IsActiveOn(membership, new DateOnly(2025, 9, 1)));
Assert.True(GroupMembershipService.IsActiveOn(membership, new DateOnly(2025, 10, 15)));
Assert.False(GroupMembershipService.IsActiveOn(membership, new DateOnly(2025, 10, 16)));
}
[Theory]
[InlineData(2025, 12, 1, true)]
[InlineData(2026, 1, 31, true)]
[InlineData(2026, 2, 1, false)]
public void IsActiveOn_BeruecksichtigtHalbjahresmodus(int year, int month, int day, bool expected)
{
var membership = new GroupMembership { Period = MembershipPeriod.H1Only };
Assert.Equal(expected,
GroupMembershipService.IsActiveOn(membership, new DateOnly(year, month, day)));
}
[Fact]
public void Overlaps_FindetTeilweiseZugehoerigkeitImHalbjahr()
{
var membership = new GroupMembership
{
Period = MembershipPeriod.Custom,
JoinedAt = new DateOnly(2025, 12, 15),
LeftAt = new DateOnly(2026, 3, 10),
};
var h1 = GroupMembershipService.SchoolYearPeriod("2025/26", SchoolYearPeriodKind.H1);
var h2 = GroupMembershipService.SchoolYearPeriod("2025/26", SchoolYearPeriodKind.H2);
Assert.True(GroupMembershipService.Overlaps(membership, h1.From, h1.To));
Assert.True(GroupMembershipService.Overlaps(membership, h2.From, h2.To));
Assert.False(GroupMembershipService.Overlaps(membership,
new DateOnly(2026, 4, 1), new DateOnly(2026, 7, 31)));
}
}
+11 -3
View File
@@ -862,9 +862,17 @@ Hinweis in Kapitel 1 — betrifft auch Kurse, nicht nur Klassen.
Nachweise erhalten; ihr `GroupId` wird auf `null` gesetzt.
- [ ] **7.2.2** Gruppe ins neue Schuljahr übernehmen: Kopie mit gleicher Schülerschaft,
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).
- [x] **7.2.3** Schüler aus einer Gruppe austragen, ohne die Mitgliedschaft zu löschen — eigener
Dialog mit `CalendarDatePicker`, aktuellem Datum als Vorbelegung und frei wählbarem
Austrittsdatum. Setzt `GroupMembership.LeftAt`; Eintrittsdatum, Niveau und historische
Leistungen bleiben erhalten. Ehemalige Schüler können in der Gruppenansicht eingeblendet
und ihre Austragung zurückgenommen werden. Ein bereits gesetztes Austrittsdatum lässt sich
über denselben Dialog ändern.
- [x] **7.2.4** `JoinedAt`/`LeftAt` und `MembershipPeriod` werden über den gemeinsamen
`GroupMembershipService` in allen gruppenbezogenen Auswertungen berücksichtigt: Mitarbeit,
Mitarbeits-Assistent/-Aggregation, Klausur-Punkteeingabe, Sammelnoten, Notenübersicht und
Zeugnisnoten. Ein Schüler zählt nur an Tagen bzw. in Zeiträumen, in denen die Mitgliedschaft
tatsächlich aktiv war; der Austrittstag selbst zählt noch als zugehörig.
- [ ] **7.2.5** Archivansicht abgeschlossener Schuljahre (schreibgeschützt).
### 7.3 Import