Add configurable student group generator
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
admin committed 2026-09-29 14:22:59 +02:00
1 parent c79d96c5ec
commit d4b1419f9a
7 files changed
+951

No files matched your search

@@ -0,0 +1,185 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class StudentGroupGeneratorViewModelTests
{
private static (StudentGroupGeneratorViewModel Vm, List<Student> Students, LearningGroup Group,
FakeSessions Sessions, FakeEntries Entries) Build(int studentCount = 10)
{
var group = new LearningGroup { Name = "8a", GradingSystem = GradingSystem.Grades1To6 };
var students = Enumerable.Range(1, studentCount).Select(i => new Student
{
FirstName = $"Kind{i:00}", LastName = "Test",
}).ToList();
var memberships = students.Select(s => new GroupMembership
{
GroupId = group.Id, StudentId = s.Id,
}).ToList();
var sessions = new FakeSessions([]);
var entries = new FakeEntries();
var vm = new StudentGroupGeneratorViewModel(
new FakeStudents(students), new FakeGroups([group]), new FakeMemberships(memberships),
sessions, entries, new FakeGrades(), group.Id, new Random(7));
return (vm, students, group, sessions, entries);
}
[Fact]
public void FesteZielgroesse_ErzeugtPassendeAnzahlMitKleinerAusnahme()
{
var (vm, _, _, _, _) = Build(10);
vm.TargetGroupSize = 4;
vm.TargetGroupCount = 2;
vm.SizePriority = 100;
vm.CountPriority = 20;
vm.GenerateCommand.Execute(null);
Assert.Equal(3, vm.GeneratedGroups.Count);
Assert.Equal(new[] { 3, 3, 4 }, vm.GeneratedGroups.Select(g => g.Members.Count).OrderBy(x => x));
Assert.Contains("nicht gleichzeitig", vm.WarningText);
}
[Fact]
public void FesteGruppenanzahl_HatVorrangVorZielgroesse()
{
var (vm, _, _, _, _) = Build(10);
vm.TargetGroupSize = 3;
vm.TargetGroupCount = 2;
vm.SizePriority = 20;
vm.CountPriority = 100;
vm.GenerateCommand.Execute(null);
Assert.Equal(2, vm.GeneratedGroups.Count);
Assert.All(vm.GeneratedGroups, group => Assert.Equal(5, group.Members.Count));
}
[Fact]
public void AdHoc_SchliesstHeuteAbwesendeAus()
{
var (vm, students, group, sessions, entries) = Build(6);
var session = new ParticipationSession { GroupId = group.Id, Date = DateOnly.FromDateTime(DateTime.Today) };
sessions.Save(session);
entries.Add(new ParticipationEntry
{
SessionId = session.Id, StudentId = students[0].Id, Attendance = AttendanceStatus.Excused,
});
vm.ParticipantModeName = StudentGroupGeneratorViewModel.AdHocModeLabel;
vm.TargetGroupCount = 2;
vm.CountPriority = 100;
vm.GenerateCommand.Execute(null);
var names = vm.GeneratedGroups.SelectMany(g => g.Members).Select(m => m.Name).ToList();
Assert.Equal(5, names.Count);
Assert.DoesNotContain(students[0].FullName, names);
Assert.Contains("fehlt heute", vm.WarningText);
}
[Fact]
public void AdHoc_OhneHeutigeErfassung_WarntUndNutztAlleAktiven()
{
var (vm, students, _, _, _) = Build(6);
vm.ParticipantModeName = StudentGroupGeneratorViewModel.AdHocModeLabel;
vm.GenerateCommand.Execute(null);
Assert.Equal(students.Count, vm.GeneratedGroups.SelectMany(g => g.Members).Count());
Assert.Contains("keine Anwesenheit", vm.WarningText);
}
[Fact]
public void Leistungsverteilung_UnterscheidetHomogeneUndHeterogeneGruppen()
{
var group = new LearningGroup { Name = "Kurs", GradingSystem = GradingSystem.Grades1To6 };
var students = new[] { "A", "B", "C", "D" }.Select(name => new Student
{
FirstName = name, LastName = "Test",
}).ToList();
var memberships = students.Select(s => new GroupMembership { GroupId = group.Id, StudentId = s.Id }).ToList();
var grades = new FakeGrades();
var values = new[] { "1", "2", "5", "6" };
for (var i = 0; i < students.Count; i++)
grades.Add(new Grade { GroupId = group.Id, StudentId = students[i].Id, Value = values[i] });
var vm = new StudentGroupGeneratorViewModel(
new FakeStudents(students), new FakeGroups([group]), new FakeMemberships(memberships),
new FakeSessions([]), new FakeEntries(), grades, group.Id, new Random(3))
{
TargetGroupCount = 2,
CountPriority = 100,
PerformanceDistribution = -100,
};
vm.GenerateCommand.Execute(null);
var homogeneousFirst = vm.GeneratedGroups[0].Members.Select(m => m.Name).ToHashSet();
Assert.Contains(students[0].FullName, homogeneousFirst);
Assert.Contains(students[1].FullName, homogeneousFirst);
vm.PerformanceDistribution = 100;
vm.GenerateCommand.Execute(null);
Assert.All(vm.GeneratedGroups, generated =>
{
var names = generated.Members.Select(m => m.Name).ToHashSet();
Assert.True(names.Overlaps([students[0].FullName, students[1].FullName]));
Assert.True(names.Overlaps([students[2].FullName, students[3].FullName]));
});
}
[Fact]
public void Paarregeln_ErzwingenZusammenUndTrennenMitHoherPrioritaet()
{
var (vm, students, _, _, _) = Build(6);
vm.TargetGroupCount = 2;
vm.CountPriority = 100;
vm.PerformanceDistribution = 0;
vm.ForcedPairPriority = 100;
vm.SelectedPairStudentA = vm.PairCandidates.Single(s => s.Id == students[0].Id);
vm.SelectedPairStudentB = vm.PairCandidates.Single(s => s.Id == students[1].Id);
vm.AddForcedPairCommand.Execute(null);
vm.SelectedPairStudentA = vm.PairCandidates.Single(s => s.Id == students[2].Id);
vm.SelectedPairStudentB = vm.PairCandidates.Single(s => s.Id == students[3].Id);
vm.AddExcludedPairCommand.Execute(null);
vm.GenerateCommand.Execute(null);
var groupByName = vm.GeneratedGroups.SelectMany((group, index) =>
group.Members.Select(member => (member.Name, index)))
.ToDictionary(x => x.Name, x => x.index);
Assert.Equal(groupByName[students[0].FullName], groupByName[students[1].FullName]);
Assert.NotEqual(groupByName[students[2].FullName], groupByName[students[3].FullName]);
}
[Fact]
public void FoerderschuelerWerdenMoeglichstGleichmaessigVerteilt()
{
var group = new LearningGroup { Name = "Kurs" };
var students = Enumerable.Range(1, 8).Select(i => new Student
{
FirstName = $"Kind{i}", LastName = "Test",
}).ToList();
var memberships = students.Select((student, index) => new GroupMembership
{
GroupId = group.Id,
StudentId = student.Id,
Niveau = index < 4 ? Niveau.Foerder : null,
}).ToList();
var vm = new StudentGroupGeneratorViewModel(
new FakeStudents(students), new FakeGroups([group]), new FakeMemberships(memberships),
new FakeSessions([]), new FakeEntries(), new FakeGrades(), group.Id, new Random(4))
{
TargetGroupCount = 2,
CountPriority = 100,
PerformanceDistribution = -100,
DistributeSupportStudents = true,
};
vm.GenerateCommand.Execute(null);
var supportNames = students.Take(4).Select(s => s.FullName).ToHashSet();
Assert.All(vm.GeneratedGroups, generated =>
Assert.Equal(2, generated.Members.Count(member => supportNames.Contains(member.Name))));
}
}
@@ -0,0 +1,486 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Groups;
public sealed record StudentGroupMember(string Name);
public sealed record StudentPairCandidate(Guid Id, string Name);
public sealed class StudentPairRule
{
public Guid FirstId { get; }
public Guid SecondId { get; }
public string Display { get; }
public IRelayCommand RemoveCommand { get; }
public StudentPairRule(StudentPairCandidate first, StudentPairCandidate second, Action<StudentPairRule> remove)
{
FirstId = first.Id;
SecondId = second.Id;
Display = $"{first.Name} + {second.Name}";
RemoveCommand = new RelayCommand(() => remove(this));
}
public bool Matches(Guid first, Guid second) =>
FirstId == first && SecondId == second || FirstId == second && SecondId == first;
}
public sealed class GeneratedStudentGroup
{
public string Title { get; }
public ObservableCollection<StudentGroupMember> Members { get; } = [];
public GeneratedStudentGroup(int number, IEnumerable<Student> students)
{
Title = $"Gruppe {number}";
foreach (var student in students.OrderBy(s => s.LastName, StringComparer.CurrentCultureIgnoreCase)
.ThenBy(s => s.FirstName, StringComparer.CurrentCultureIgnoreCase))
Members.Add(new StudentGroupMember(student.FullName));
}
}
/// <summary>Erzeugt Gruppen fuer eine spaetere Planung oder aus der heutigen Anwesenheit.</summary>
public partial class StudentGroupGeneratorViewModel : ObservableObject
{
public const string FutureModeLabel = "Für später · alle Aktiven";
public const string AdHocModeLabel = "Ad hoc · heute Anwesende";
private readonly IStudentRepository _students;
private readonly IGroupMembershipRepository _memberships;
private readonly IParticipationSessionRepository _sessions;
private readonly IParticipationRepository _participation;
private readonly IGradeRepository _grades;
private readonly IGroupRepository _groupRepository;
private readonly Random _random;
private readonly List<Student> _participants = [];
private readonly Dictionary<Guid, double?> _performance = [];
private readonly HashSet<Guid> _supportStudentIds = [];
private string _attendanceWarning = "";
[ObservableProperty] private StudentDrawGroup? _selectedGroup;
[ObservableProperty] private string _participantModeName = FutureModeLabel;
[ObservableProperty] private int _targetGroupSize = 4;
[ObservableProperty] private int _targetGroupCount = 6;
[ObservableProperty] private double _sizePriority = 75;
[ObservableProperty] private double _countPriority = 50;
[ObservableProperty] private double _performanceDistribution = 65;
[ObservableProperty] private bool _distributeSupportStudents = true;
[ObservableProperty] private StudentPairCandidate? _selectedPairStudentA;
[ObservableProperty] private StudentPairCandidate? _selectedPairStudentB;
[ObservableProperty] private double _forcedPairPriority = 85;
[ObservableProperty] private double _excludedPairPriority = 85;
[ObservableProperty] private string _pairingMessage = "";
[ObservableProperty] private string _statusText = "Optionen wählen und Gruppen zusammenstellen.";
[ObservableProperty] private string _warningText = "";
[ObservableProperty] private bool _hasGeneratedGroups;
public ObservableCollection<StudentDrawGroup> Groups { get; } = [];
public ObservableCollection<GeneratedStudentGroup> GeneratedGroups { get; } = [];
public ObservableCollection<StudentPairCandidate> PairCandidates { get; } = [];
public ObservableCollection<StudentPairRule> ForcedPairs { get; } = [];
public ObservableCollection<StudentPairRule> ExcludedPairs { get; } = [];
public string[] ParticipantModeOptions { get; } = [FutureModeLabel, AdHocModeLabel];
public bool CanGenerate => SelectedGroup is not null && _participants.Count >= 2;
public string SizePriorityLabel => PriorityLabel(SizePriority);
public string CountPriorityLabel => PriorityLabel(CountPriority);
public string PerformanceDistributionLabel => PerformanceDistribution switch
{
<= -70 => "stark homogen",
< -20 => "eher homogen",
<= 20 => "zufällig gemischt",
< 70 => "eher heterogen",
_ => "stark heterogen",
};
public string ForcedPairPriorityLabel => PriorityLabel(ForcedPairPriority);
public string ExcludedPairPriorityLabel => PriorityLabel(ExcludedPairPriority);
public StudentGroupGeneratorViewModel(
IStudentRepository students,
IGroupRepository groups,
IGroupMembershipRepository memberships,
IParticipationSessionRepository sessions,
IParticipationRepository participation,
IGradeRepository grades,
Guid? initialGroupId = null,
Random? random = null)
{
_students = students;
_groupRepository = groups;
_memberships = memberships;
_sessions = sessions;
_participation = participation;
_grades = grades;
_random = random ?? Random.Shared;
foreach (var group in groups.GetAll().OrderBy(g => g.Name, StringComparer.CurrentCultureIgnoreCase))
Groups.Add(new StudentDrawGroup(group.Id, group.Name));
SelectedGroup = Groups.FirstOrDefault(g => g.Id == initialGroupId) ?? Groups.FirstOrDefault();
}
partial void OnSelectedGroupChanged(StudentDrawGroup? value)
{
ForcedPairs.Clear();
ExcludedPairs.Clear();
ReloadParticipants();
}
partial void OnParticipantModeNameChanged(string value) => ReloadParticipants();
partial void OnTargetGroupSizeChanged(int value) => InvalidateResult();
partial void OnTargetGroupCountChanged(int value) => InvalidateResult();
partial void OnSizePriorityChanged(double value)
{
OnPropertyChanged(nameof(SizePriorityLabel));
InvalidateResult();
}
partial void OnCountPriorityChanged(double value)
{
OnPropertyChanged(nameof(CountPriorityLabel));
InvalidateResult();
}
partial void OnPerformanceDistributionChanged(double value)
{
OnPropertyChanged(nameof(PerformanceDistributionLabel));
InvalidateResult();
}
partial void OnDistributeSupportStudentsChanged(bool value) => InvalidateResult();
partial void OnForcedPairPriorityChanged(double value)
{
OnPropertyChanged(nameof(ForcedPairPriorityLabel));
InvalidateResult();
}
partial void OnExcludedPairPriorityChanged(double value)
{
OnPropertyChanged(nameof(ExcludedPairPriorityLabel));
InvalidateResult();
}
[RelayCommand]
private void AddForcedPair() => AddPairRule(ForcedPairs, ExcludedPairs);
[RelayCommand]
private void AddExcludedPair() => AddPairRule(ExcludedPairs, ForcedPairs);
[RelayCommand(CanExecute = nameof(CanGenerate))]
private void Generate()
{
var groupCount = ResolveGroupCount(_participants.Count);
var capacities = Enumerable.Range(0, groupCount)
.Select(i => _participants.Count / groupCount + (i < _participants.Count % groupCount ? 1 : 0))
.ToArray();
var assignments = BuildAssignments(capacities);
GeneratedGroups.Clear();
for (var i = 0; i < assignments.Count; i++)
GeneratedGroups.Add(new GeneratedStudentGroup(i + 1, assignments[i]));
HasGeneratedGroups = true;
StatusText = $"{_participants.Count} Schüler · {groupCount} Gruppen · Größen " +
string.Join("/", capacities.Distinct().OrderBy(x => x));
UpdateWarning(groupCount, assignments);
}
private void ReloadParticipants()
{
_participants.Clear();
_performance.Clear();
_supportStudentIds.Clear();
PairCandidates.Clear();
_attendanceWarning = "";
if (SelectedGroup is not null)
{
var today = DateOnly.FromDateTime(DateTime.Today);
var activeMemberships = _memberships.GetByGroup(SelectedGroup.Id)
.Where(m => GroupMembershipService.IsActiveOn(m, today))
.ToList();
var activeIds = activeMemberships.Select(m => m.StudentId).ToHashSet();
foreach (var membership in activeMemberships.Where(m => m.Niveau == Niveau.Foerder))
_supportStudentIds.Add(membership.StudentId);
var active = _students.GetByGroup(SelectedGroup.Id)
.Where(s => s.IsActive && activeIds.Contains(s.Id)).ToList();
_participants.AddRange(ParticipantModeName == AdHocModeLabel
? FilterByTodayAttendance(SelectedGroup.Id, active, today)
: active);
LoadPerformance(SelectedGroup.Id);
foreach (var student in _participants.OrderBy(s => s.LastName).ThenBy(s => s.FirstName))
PairCandidates.Add(new StudentPairCandidate(student.Id, student.FullName));
}
SelectedPairStudentA = null;
SelectedPairStudentB = null;
InvalidateResult();
OnPropertyChanged(nameof(CanGenerate));
GenerateCommand.NotifyCanExecuteChanged();
}
private IEnumerable<Student> FilterByTodayAttendance(Guid groupId, List<Student> active, DateOnly today)
{
var todaySessions = _sessions.GetByGroup(groupId).Where(s => s.Date == today)
.OrderBy(s => s.CreatedAt).ToList();
if (todaySessions.Count == 0)
{
_attendanceWarning = "Heute wurde noch keine Anwesenheit erfasst; deshalb werden alle aktiven Schüler einbezogen.";
return active;
}
var latestStatus = new Dictionary<Guid, AttendanceStatus?>();
foreach (var session in todaySessions)
foreach (var entry in _participation.GetBySession(session.Id))
if (entry.Attendance is not null) latestStatus[entry.StudentId] = entry.Attendance;
var unknown = active.Count(s => !latestStatus.ContainsKey(s.Id));
if (unknown > 0)
_attendanceWarning = $"Für {unknown} Schüler fehlt heute ein Anwesenheitsstatus; sie werden vorsichtshalber einbezogen.";
return active.Where(s => !latestStatus.TryGetValue(s.Id, out var status) || status == AttendanceStatus.Present);
}
private void LoadPerformance(Guid groupId)
{
var system = _groupRepository.GetById(groupId)?.GradingSystem ?? GradingSystem.Grades1To6;
var gradesByStudent = _grades.GetByGroup(groupId).GroupBy(g => g.StudentId)
.ToDictionary(g => g.Key, g => g.Select(x => ParseGradeScore(x.Value, system)).Where(x => x.HasValue)
.Select(x => x!.Value).ToList());
var entries = _sessions.GetByGroup(groupId).SelectMany(s => _participation.GetBySession(s.Id)).ToList();
foreach (var student in _participants)
{
var signals = gradesByStudent.GetValueOrDefault(student.Id, []).ToList();
signals.AddRange(entries.Where(e => e.StudentId == student.Id).SelectMany(e => e.Ratings)
.Select(r => (Math.Clamp(r.Value, -2, 2) + 2) / 4.0));
_performance[student.Id] = signals.Count == 0 ? null : signals.Average();
}
}
private List<List<Student>> BuildAssignments(int[] capacities)
{
var result = capacities.Select(_ => new List<Student>()).ToList();
var strength = Math.Abs(PerformanceDistribution) / 100.0;
var mean = _performance.Values.Where(x => x.HasValue).Select(x => x!.Value).DefaultIfEmpty(0.5).Average();
var ranked = _participants.Select(s => new
{
Student = s,
Score = (_performance.GetValueOrDefault(s.Id) ?? mean) * strength + _random.NextDouble() * (1 - strength),
}).OrderByDescending(x => x.Score).ToList();
if (PerformanceDistribution < -20)
{
var cursor = 0;
for (var i = 0; i < result.Count; i++)
for (var j = 0; j < capacities[i]; j++) result[i].Add(ranked[cursor++].Student);
}
else if (PerformanceDistribution > 20)
{
var sums = new double[result.Count];
foreach (var item in ranked)
{
var target = Enumerable.Range(0, result.Count).Where(i => result[i].Count < capacities[i])
.OrderBy(i => result[i].Count == 0 ? -1 : sums[i] / result[i].Count)
.ThenBy(_ => _random.NextDouble()).First();
result[target].Add(item.Student);
sums[target] += item.Score;
}
}
else
{
var shuffled = ranked.OrderBy(_ => _random.Next()).Select(x => x.Student).ToList();
var cursor = 0;
for (var i = 0; i < result.Count; i++)
for (var j = 0; j < capacities[i]; j++) result[i].Add(shuffled[cursor++]);
}
OptimizeAssignments(result);
return result;
}
private void OptimizeAssignments(List<List<Student>> groups)
{
var best = AssignmentPenalty(groups);
var improved = true;
var passes = 0;
while (improved && passes++ < 100)
{
improved = false;
(int AGroup, int AIndex, int BGroup, int BIndex, double Score)? bestSwap = null;
for (var firstGroup = 0; firstGroup < groups.Count; firstGroup++)
for (var secondGroup = firstGroup + 1; secondGroup < groups.Count; secondGroup++)
for (var firstIndex = 0; firstIndex < groups[firstGroup].Count; firstIndex++)
for (var secondIndex = 0; secondIndex < groups[secondGroup].Count; secondIndex++)
{
(groups[firstGroup][firstIndex], groups[secondGroup][secondIndex]) =
(groups[secondGroup][secondIndex], groups[firstGroup][firstIndex]);
var score = AssignmentPenalty(groups);
(groups[firstGroup][firstIndex], groups[secondGroup][secondIndex]) =
(groups[secondGroup][secondIndex], groups[firstGroup][firstIndex]);
if (score + 0.000001 < best && (bestSwap is null || score < bestSwap.Value.Score))
bestSwap = (firstGroup, firstIndex, secondGroup, secondIndex, score);
}
if (bestSwap is { } swap)
{
(groups[swap.AGroup][swap.AIndex], groups[swap.BGroup][swap.BIndex]) =
(groups[swap.BGroup][swap.BIndex], groups[swap.AGroup][swap.AIndex]);
best = swap.Score;
improved = true;
}
}
}
private double AssignmentPenalty(List<List<Student>> groups)
{
var mean = _performance.Values.Where(x => x.HasValue).Select(x => x!.Value).DefaultIfEmpty(0.5).Average();
var distributionWeight = Math.Abs(PerformanceDistribution) / 100.0;
double performancePenalty;
if (PerformanceDistribution < -20)
{
performancePenalty = groups.Sum(group =>
{
var values = group.Select(s => _performance.GetValueOrDefault(s.Id) ?? mean).ToList();
var groupMean = values.Average();
return values.Sum(value => Math.Pow(value - groupMean, 2));
});
}
else if (PerformanceDistribution > 20)
{
var averages = groups.Select(group => group.Average(s => _performance.GetValueOrDefault(s.Id) ?? mean)).ToList();
performancePenalty = averages.Sum(value => Math.Pow(value - averages.Average(), 2));
}
else performancePenalty = 0;
var supportPenalty = 0.0;
if (DistributeSupportStudents && _supportStudentIds.Count > 0)
{
var counts = groups.Select(g => g.Count(s => _supportStudentIds.Contains(s.Id))).ToList();
supportPenalty = counts.Sum(count => Math.Pow(count - counts.Average(), 2)) * 2.0;
}
var groupByStudent = groups.SelectMany((group, index) => group.Select(s => (s.Id, index)))
.ToDictionary(x => x.Id, x => x.index);
var forcedPenalty = ForcedPairs.Count(rule => PairViolated(rule, groupByStudent, mustBeTogether: true)) *
PairWeight(ForcedPairPriority);
var excludedPenalty = ExcludedPairs.Count(rule => PairViolated(rule, groupByStudent, mustBeTogether: false)) *
PairWeight(ExcludedPairPriority);
return performancePenalty * distributionWeight + supportPenalty + forcedPenalty + excludedPenalty;
}
private int ResolveGroupCount(int participantCount)
{
var maxGroups = Math.Max(1, participantCount / 2);
var requestedCount = Math.Clamp(TargetGroupCount, 1, maxGroups);
var sizeBasedCount = Math.Clamp((int)Math.Ceiling(participantCount / (double)Math.Max(2, TargetGroupSize)), 1, maxGroups);
if (CountPriority >= 99 && SizePriority < 99) return requestedCount;
if (SizePriority >= 99 && CountPriority < 99) return sizeBasedCount;
return Enumerable.Range(1, maxGroups).MinBy(count =>
{
var sizeDeviation = Math.Abs(participantCount / (double)count - TargetGroupSize) / Math.Max(2, TargetGroupSize);
var countDeviation = Math.Abs(count - requestedCount) / (double)Math.Max(1, requestedCount);
return sizeDeviation * (0.05 + SizePriority / 100.0) +
countDeviation * (0.05 + CountPriority / 100.0);
});
}
private void InvalidateResult()
{
GeneratedGroups.Clear();
HasGeneratedGroups = false;
StatusText = _participants.Count >= 2
? $"{_participants.Count} Schüler stehen zur Einteilung bereit."
: "Für eine Gruppeneinteilung werden mindestens zwei Schüler benötigt.";
UpdateWarning(null);
}
private void UpdateWarning(int? resolvedCount, List<List<Student>>? assignments = null)
{
var warnings = new List<string>();
if (_attendanceWarning.Length > 0) warnings.Add(_attendanceWarning);
if (_participants.Count >= 2)
{
if (_participants.Count != Math.Max(2, TargetGroupSize) * TargetGroupCount)
{
var resolution = resolvedCount is null ? "" : $" Verwendet werden {resolvedCount} Gruppen.";
warnings.Add($"Zielgröße {TargetGroupSize} und {TargetGroupCount} Gruppen sind bei {_participants.Count} Schülern nicht gleichzeitig exakt erfüllbar.{resolution}");
}
}
if (assignments is not null)
{
var groupByStudent = assignments.SelectMany((group, index) => group.Select(s => (s.Id, index)))
.ToDictionary(x => x.Id, x => x.index);
var unavailable = ForcedPairs.Concat(ExcludedPairs).Count(rule =>
!groupByStudent.ContainsKey(rule.FirstId) || !groupByStudent.ContainsKey(rule.SecondId));
var forcedOpen = ForcedPairs.Count(rule => PairViolated(rule, groupByStudent, mustBeTogether: true));
var excludedOpen = ExcludedPairs.Count(rule => PairViolated(rule, groupByStudent, mustBeTogether: false));
if (unavailable > 0) warnings.Add($"{unavailable} Paarregel(n) sind wegen fehlender Teilnehmender heute nicht anwendbar.");
if (forcedOpen > 0) warnings.Add($"{forcedOpen} gewünschte Paarung(en) konnten mit den aktuellen Prioritäten nicht erfüllt werden.");
if (excludedOpen > 0) warnings.Add($"{excludedOpen} ausgeschlossene Paarung(en) konnten mit den aktuellen Prioritäten nicht getrennt werden.");
}
WarningText = string.Join(" ", warnings);
}
private void AddPairRule(ObservableCollection<StudentPairRule> target,
ObservableCollection<StudentPairRule> opposite)
{
PairingMessage = "";
if (SelectedPairStudentA is null || SelectedPairStudentB is null)
{
PairingMessage = "Bitte zwei Schüler auswählen.";
return;
}
if (SelectedPairStudentA.Id == SelectedPairStudentB.Id)
{
PairingMessage = "Bitte zwei verschiedene Schüler auswählen.";
return;
}
if (target.Any(rule => rule.Matches(SelectedPairStudentA.Id, SelectedPairStudentB.Id)))
{
PairingMessage = "Diese Paarregel ist bereits vorhanden.";
return;
}
var conflicting = opposite.FirstOrDefault(rule => rule.Matches(SelectedPairStudentA.Id, SelectedPairStudentB.Id));
if (conflicting is not null) opposite.Remove(conflicting);
StudentPairRule? created = null;
created = new StudentPairRule(SelectedPairStudentA, SelectedPairStudentB, rule =>
{
target.Remove(rule);
InvalidateResult();
});
target.Add(created);
SelectedPairStudentA = null;
SelectedPairStudentB = null;
InvalidateResult();
}
private static bool PairViolated(StudentPairRule rule, IReadOnlyDictionary<Guid, int> groups,
bool mustBeTogether)
{
if (!groups.TryGetValue(rule.FirstId, out var first) || !groups.TryGetValue(rule.SecondId, out var second))
return false;
return mustBeTogether ? first != second : first == second;
}
private static double PairWeight(double priority) => 0.05 + priority / 100.0 * 8.0;
private static string PriorityLabel(double value) => value switch
{
>= 90 => "nahezu fix",
>= 65 => "hohe Priorität",
>= 35 => "mittlere Priorität",
>= 10 => "flexibel",
_ => "nur Richtwert",
};
private static double? ParseGradeScore(string value, GradingSystem system)
{
var text = value.Trim();
var tendency = 0.0;
if (text.EndsWith('+')) { tendency = system == GradingSystem.Grades1To6 ? -1.0 / 3 : 1.0 / 3; text = text[..^1]; }
else if (text.EndsWith('-')) { tendency = system == GradingSystem.Grades1To6 ? 1.0 / 3 : -1.0 / 3; text = text[..^1]; }
if (!double.TryParse(text, System.Globalization.NumberStyles.Number,
System.Globalization.CultureInfo.InvariantCulture, out var parsed)) return null;
parsed += tendency;
return system == GradingSystem.Grades1To6
? Math.Clamp((6 - parsed) / 5.0, 0, 1)
: Math.Clamp(parsed / 15.0, 0, 1);
}
}
@@ -0,0 +1,217 @@
<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.StudentGroupGeneratorWindow"
x:DataType="vm:StudentGroupGeneratorViewModel"
Title="Gruppen zusammenstellen"
Width="780" Height="650" MinWidth="620" MinHeight="480"
WindowStartupLocation="CenterScreen">
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="3" Margin="0,0,0,16">
<TextBlock Text="Gruppen zusammenstellen" Classes="dialogtitle"/>
<TextBlock Text="Eigenständiges Fenster für die Bildschirmfreigabe"
FontSize="12" Opacity="0.55"/>
</StackPanel>
<ScrollViewer Grid.Row="1">
<Grid x:Name="GroupResults">
<Grid.Transitions>
<Transitions>
<DoubleTransition Property="Opacity" Duration="0:0:0.18"/>
</Transitions>
</Grid.Transitions>
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="12"
IsVisible="{Binding !HasGeneratedGroups}">
<TextBlock Text="◇ ◇ ◇" FontSize="26" Opacity="0.35" HorizontalAlignment="Center"/>
<TextBlock Text="Noch keine Gruppen erstellt" FontSize="20" FontWeight="SemiBold"
HorizontalAlignment="Center"/>
<TextBlock Text="{Binding StatusText}" Opacity="0.6" TextAlignment="Center"
TextWrapping="Wrap" MaxWidth="460"/>
</StackPanel>
<ItemsControl ItemsSource="{Binding GeneratedGroups}"
IsVisible="{Binding HasGeneratedGroups}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" HorizontalAlignment="Center"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:GeneratedStudentGroup">
<Border Width="220" Margin="7" Padding="16"
Background="{DynamicResource AppCardBackgroundBrush}"
BorderBrush="{DynamicResource AppCardBorderBrush}"
BorderThickness="1" CornerRadius="12">
<StackPanel Spacing="9">
<TextBlock Text="{Binding Title}" FontSize="16" FontWeight="SemiBold"
Foreground="{DynamicResource AppAccentTextBrush}"/>
<ItemsControl ItemsSource="{Binding Members}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:StudentGroupMember">
<TextBlock Text="{Binding Name}" Margin="0,3" TextWrapping="Wrap"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid>
</ScrollViewer>
<StackPanel Grid.Row="2" Spacing="8" Margin="0,12,0,10">
<Button Content="Gruppen zusammenstellen" Click="OnGenerate"
IsEnabled="{Binding CanGenerate}" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center" Height="48" FontSize="16" FontWeight="SemiBold"/>
<TextBlock Text="{Binding StatusText}" HorizontalAlignment="Center" Opacity="0.65"
IsVisible="{Binding HasGeneratedGroups}"/>
<Border Background="{DynamicResource AppChipBackgroundBrush}" CornerRadius="8" Padding="10,7"
IsVisible="{Binding WarningText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding WarningText}" TextWrapping="Wrap" FontSize="12"
Foreground="{DynamicResource AppStatusWarningBrush}"/>
</Border>
</StackPanel>
<Expander Grid.Row="3" Header="Optionen" IsExpanded="False">
<ScrollViewer MaxHeight="340">
<StackPanel Spacing="11" Margin="0,10,6,4">
<Grid ColumnDefinitions="*,16,*">
<StackPanel Grid.Column="0" Spacing="5">
<TextBlock Text="Lerngruppe" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding Groups}" SelectedItem="{Binding SelectedGroup}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:StudentDrawGroup">
<TextBlock Text="{Binding Name}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="5">
<TextBlock Text="Teilnehmende" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding ParticipantModeOptions}"
SelectedItem="{Binding ParticipantModeName}"/>
</StackPanel>
</Grid>
<Grid ColumnDefinitions="*,16,*" Margin="0,5,0,0">
<StackPanel Grid.Column="0" Spacing="5">
<TextBlock Text="Zielgröße je Gruppe" FontSize="12" Opacity="0.7"/>
<NumericUpDown Value="{Binding TargetGroupSize}" Minimum="2" Maximum="20"
Increment="1" FormatString="0"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="5">
<TextBlock Text="Zielanzahl Gruppen" FontSize="12" Opacity="0.7"/>
<NumericUpDown Value="{Binding TargetGroupCount}" Minimum="1" Maximum="20"
Increment="1" FormatString="0"/>
</StackPanel>
</Grid>
<Grid ColumnDefinitions="160,*,110" RowDefinitions="Auto,Auto,Auto" Margin="0,6,0,0">
<TextBlock Grid.Row="0" Text="Priorität Zielgröße" VerticalAlignment="Center"/>
<Slider Grid.Row="0" Grid.Column="1" Minimum="0" Maximum="100"
Value="{Binding SizePriority}" Margin="10,0"/>
<TextBlock Grid.Row="0" Grid.Column="2" Text="{Binding SizePriorityLabel}"
HorizontalAlignment="Right" VerticalAlignment="Center" FontSize="12" Opacity="0.7"/>
<TextBlock Grid.Row="1" Text="Priorität Gruppenanzahl" VerticalAlignment="Center"/>
<Slider Grid.Row="1" Grid.Column="1" Minimum="0" Maximum="100"
Value="{Binding CountPriority}" Margin="10,0"/>
<TextBlock Grid.Row="1" Grid.Column="2" Text="{Binding CountPriorityLabel}"
HorizontalAlignment="Right" VerticalAlignment="Center" FontSize="12" Opacity="0.7"/>
<TextBlock Grid.Row="2" Text="Leistungsverteilung" VerticalAlignment="Center"/>
<Slider Grid.Row="2" Grid.Column="1" Minimum="-100" Maximum="100"
Value="{Binding PerformanceDistribution}" Margin="10,0"/>
<TextBlock Grid.Row="2" Grid.Column="2" Text="{Binding PerformanceDistributionLabel}"
HorizontalAlignment="Right" VerticalAlignment="Center" FontSize="12" Opacity="0.7"/>
</Grid>
<Grid ColumnDefinitions="*,*" Margin="170,-7,110,0">
<TextBlock Text="homogen" FontSize="10" Opacity="0.5"/>
<TextBlock Grid.Column="1" Text="heterogen" HorizontalAlignment="Right" FontSize="10" Opacity="0.5"/>
</Grid>
<CheckBox Content="Förderschüler möglichst gleichmäßig verteilen"
IsChecked="{Binding DistributeSupportStudents}" Margin="0,5,0,0"/>
<Border Height="1" Background="{DynamicResource AppCardBorderBrush}" Margin="0,5"/>
<TextBlock Text="Paarregeln" FontWeight="SemiBold"/>
<TextBlock Text="Zwei Schüler auswählen und anschließend festlegen, ob sie zusammen oder getrennt arbeiten sollen."
FontSize="11" Opacity="0.55" TextWrapping="Wrap"/>
<Grid ColumnDefinitions="*,12,*" Margin="0,2,0,0">
<ComboBox Grid.Column="0" ItemsSource="{Binding PairCandidates}"
SelectedItem="{Binding SelectedPairStudentA}" PlaceholderText="Erster Schüler">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:StudentPairCandidate">
<TextBlock Text="{Binding Name}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<ComboBox Grid.Column="2" ItemsSource="{Binding PairCandidates}"
SelectedItem="{Binding SelectedPairStudentB}" PlaceholderText="Zweiter Schüler">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:StudentPairCandidate">
<TextBlock Text="{Binding Name}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</Grid>
<Grid ColumnDefinitions="*,12,*">
<Button Grid.Column="0" Content="Zusammen einteilen" Command="{Binding AddForcedPairCommand}"
HorizontalAlignment="Stretch"/>
<Button Grid.Column="2" Content="Voneinander trennen" Command="{Binding AddExcludedPairCommand}"
HorizontalAlignment="Stretch"/>
</Grid>
<TextBlock Text="{Binding PairingMessage}" Foreground="{DynamicResource AppStatusWarningBrush}"
FontSize="11" IsVisible="{Binding PairingMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Grid ColumnDefinitions="160,*,110" RowDefinitions="Auto,Auto">
<TextBlock Grid.Row="0" Text="Priorität zusammen" VerticalAlignment="Center"/>
<Slider Grid.Row="0" Grid.Column="1" Minimum="0" Maximum="100"
Value="{Binding ForcedPairPriority}" Margin="10,0"/>
<TextBlock Grid.Row="0" Grid.Column="2" Text="{Binding ForcedPairPriorityLabel}"
HorizontalAlignment="Right" VerticalAlignment="Center" FontSize="12" Opacity="0.7"/>
<TextBlock Grid.Row="1" Text="Priorität trennen" VerticalAlignment="Center"/>
<Slider Grid.Row="1" Grid.Column="1" Minimum="0" Maximum="100"
Value="{Binding ExcludedPairPriority}" Margin="10,0"/>
<TextBlock Grid.Row="1" Grid.Column="2" Text="{Binding ExcludedPairPriorityLabel}"
HorizontalAlignment="Right" VerticalAlignment="Center" FontSize="12" Opacity="0.7"/>
</Grid>
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0" Spacing="5">
<TextBlock Text="Zusammen" FontSize="12" Opacity="0.7"/>
<ItemsControl ItemsSource="{Binding ForcedPairs}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:StudentPairRule">
<Grid ColumnDefinitions="*,Auto" Margin="0,2">
<TextBlock Text="{Binding Display}" TextWrapping="Wrap" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="×" Command="{Binding RemoveCommand}"
ToolTip.Tip="Regel entfernen" Padding="8,2"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="5">
<TextBlock Text="Getrennt" FontSize="12" Opacity="0.7"/>
<ItemsControl ItemsSource="{Binding ExcludedPairs}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:StudentPairRule">
<Grid ColumnDefinitions="*,Auto" Margin="0,2">
<TextBlock Text="{Binding Display}" TextWrapping="Wrap" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="×" Command="{Binding RemoveCommand}"
ToolTip.Tip="Regel entfernen" Padding="8,2"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Grid>
</StackPanel>
</ScrollViewer>
</Expander>
</Grid>
</Window>
@@ -0,0 +1,25 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.Groups;
namespace LehrerApp.Desktop.Views.Groups;
public partial class StudentGroupGeneratorWindow : Window
{
private bool _isAnimating;
public StudentGroupGeneratorWindow() => InitializeComponent();
private async void OnGenerate(object? sender, RoutedEventArgs e)
{
if (_isAnimating || DataContext is not StudentGroupGeneratorViewModel vm ||
!vm.GenerateCommand.CanExecute(null)) return;
_isAnimating = true;
var results = this.FindControl<Grid>("GroupResults");
if (results is not null) results.Opacity = 0.15;
await Task.Delay(140);
vm.GenerateCommand.Execute(null);
if (results is not null) results.Opacity = 1;
_isAnimating = false;
}
}
+1
View File
@@ -48,6 +48,7 @@
</MenuItem>
<MenuItem Header="Unterricht">
<MenuItem Header="Schüler ziehen…" Click="OnOpenRandomStudentPicker"/>
<MenuItem Header="Gruppen zusammenstellen…" Click="OnOpenStudentGroupGenerator"/>
</MenuItem>
</Menu>
<DrawerPage x:Name="RootDrawer"
@@ -23,6 +23,7 @@ public partial class MainWindow : Window
private bool _finalSyncStarted;
private bool _closeAfterFinalSync;
private RandomStudentPickerWindow? _randomStudentPickerWindow;
private StudentGroupGeneratorWindow? _studentGroupGeneratorWindow;
public MainWindow()
{
@@ -93,6 +94,28 @@ public partial class MainWindow : Window
_randomStudentPickerWindow.Show();
}
private void OnOpenStudentGroupGenerator(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (_studentGroupGeneratorWindow is not null)
{
_studentGroupGeneratorWindow.Activate();
return;
}
var currentGroupId = (DataContext as MainWindowViewModel)?.CurrentGroupDetail?.Group?.Id;
var vm = new StudentGroupGeneratorViewModel(
App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IGroupRepository>(),
App.Services.GetRequiredService<IGroupMembershipRepository>(),
App.Services.GetRequiredService<IParticipationSessionRepository>(),
App.Services.GetRequiredService<IParticipationRepository>(),
App.Services.GetRequiredService<IGradeRepository>(),
currentGroupId);
_studentGroupGeneratorWindow = new StudentGroupGeneratorWindow { DataContext = vm };
_studentGroupGeneratorWindow.Closed += (_, _) => _studentGroupGeneratorWindow = null;
_studentGroupGeneratorWindow.Show();
}
private void OnWindowKeyDown(object? sender, KeyEventArgs e)
{
if (DataContext is not MainWindowViewModel vm) return;
+14
View File
@@ -3691,6 +3691,20 @@ Hinweis in Kapitel 1 — betrifft auch Kurse, nicht nur Klassen.
Wiederholungen werden vermieden. Die Ziehhistorie bleibt rein im Arbeitsspeicher und wird
beim Schließen oder Zurücksetzen verworfen.
- [x] **7.1.7** Eigenständiges Fenster „Gruppen zusammenstellen“ im Menü Unterricht. Es erstellt
Gruppen wahlweise für die Zukunft aus allen aktiven Mitgliedern oder ad hoc unter
Berücksichtigung der heute erfassten Anwesenheit. Zielgröße und Zielanzahl sind gemeinsam
vorgebbar; je ein Prioritätsregler entscheidet bei mathematisch unvereinbaren Vorgaben,
welches Ziel stärker gewichtet wird. Der Konflikt und die tatsächlich verwendete Aufteilung
werden sichtbar erklärt. Ein weiterer Regler reicht von homogenen bis heterogenen
Leistungsgruppen und nutzt vorhandene Noten/Mitarbeitsbewertungen, ohne diese Werte im
teilbaren Fenster anzuzeigen. Die Optionen sind standardmäßig eingeklappt.
**Nachtrag:** Förderschüler (`GroupMembership.Niveau = Foerder`) können möglichst
gleichmäßig verteilt werden. Frei definierbare Schülerpaare lassen sich entweder bevorzugt
zusammen einteilen oder voneinander trennen; beide Regelarten besitzen einen eigenen
Prioritätsregler und melden nach der Berechnung transparent, falls eine Regel nicht
anwendbar oder im Zusammenspiel mit den übrigen Zielen nicht erfüllbar war.
### 7.2 Gruppen
- [x] **7.2.1** Gruppe bearbeiten und löschen — bereits vorhanden (`EditGroupCommand`/`DeleteGroupCommand`/
`ToggleArchiveCommand` in `GroupListViewModel`), nicht Teil der aktuellen Klausuren-Arbeit,