8 Commits

6 changed files with 335 additions and 26 deletions
+98 -9
View File
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
@@ -59,14 +60,14 @@ public partial class MainWindow : Window
}
}
private void ChangeStudentCourse(Sport targetSport)
private async void ChangeStudentCourse(Sport targetSport)
{
if (LbResult.SelectedItem is not ResultEntry selectedEntry)
return;
try
{
if (ApplyStudentCourseChange(selectedEntry.Student, selectedEntry.Semester, targetSport))
if (await ApplyStudentCourseChange(selectedEntry.Student, selectedEntry.Semester, targetSport))
{
CourseCrafter.ReloadResult();
RefreshResultView();
@@ -79,7 +80,7 @@ public partial class MainWindow : Window
}
}
private bool ApplyStudentCourseChange(Student student, int semester, Sport targetSport)
private async Task<bool> ApplyStudentCourseChange(Student student, int semester, Sport targetSport)
{
if (semester < 1 || semester > 4)
return false;
@@ -92,14 +93,24 @@ public partial class MainWindow : Window
.Where(course => course.Instance.Students.Contains(student.ID))
.ToList();
CourseCrafter.CourseInstance? targetCourse = currentCourses
.FirstOrDefault(course => course.Instance.Sport.Name == targetSport.Name)
.Instance;
string? oldSportName = currentCourses
.Select(course => course.Instance.Sport.Name)
.FirstOrDefault();
if (targetCourse == null)
var existingTargetCourses = semesterCourses
.Where(course => course.Instance.Sport.Name == targetSport.Name)
.ToList();
CourseCrafter.CourseInstance? targetCourse = existingTargetCourses
.Where(course => !course.Instance.Students.Contains(student.ID) &&
course.Instance.Students.Count < course.Instance.Sport.MaxStudents)
.OrderBy(course => course.Instance.Students.Count)
.Select(course => course.Instance)
.FirstOrDefault();
if (targetCourse == null && existingTargetCourses.Any())
{
targetCourse = semesterCourses
.Where(course => course.Instance.Sport.Name == targetSport.Name)
targetCourse = existingTargetCourses
.OrderBy(course => course.Instance.Students.Count)
.Select(course => course.Instance)
.FirstOrDefault();
@@ -140,9 +151,87 @@ public partial class MainWindow : Window
changed = true;
}
if (changed && !string.IsNullOrEmpty(oldSportName) &&
IsRebalanceNeededForSportSemester(oldSportName, semester))
{
var result = await MessageBox.Show(this,
$"Der Kursplan für {oldSportName} im {semester}. Semester ist nach der Änderung unausgeglichen. Soll die alte Sportart umverteilt werden?",
"Umverteilung der alten Sportart", MessageBoxButton.YesNo);
if (result == MessageBoxResult.Yes)
BalanceSportSemester(oldSportName, semester);
}
return changed;
}
private bool IsRebalanceNeededForSportSemester(string sportName, int semester)
{
var courses = CourseCrafter.GeneratedCourses
.Where(course => course.Semester == semester && course.Instance.Sport.Name == sportName)
.ToList();
if (courses.Count <= 1)
return false;
var ordered = courses
.OrderBy(course => course.Instance.Students.Count)
.ToList();
int minCount = ordered.First().Instance.Students.Count;
int maxCount = ordered.Last().Instance.Students.Count;
if (maxCount - minCount <= 1)
return false;
if (ordered.Last().Instance.Students.Count - 1 < GetEffectiveMinStudents(ordered.Last().Instance.Sport, semester))
return false;
if (ordered.First().Instance.Students.Count >= ordered.First().Instance.Sport.MaxStudents)
return false;
return true;
}
private void BalanceSportSemester(string sportName, int semester)
{
bool changed;
do
{
changed = false;
var courses = CourseCrafter.GeneratedCourses
.Where(course => course.Semester == semester && course.Instance.Sport.Name == sportName)
.OrderBy(course => course.Instance.Students.Count)
.ToList();
if (courses.Count <= 1)
break;
var target = courses.First();
var source = courses.Last();
if (source.Instance.Students.Count <= target.Instance.Students.Count + 1)
break;
if (source.Instance.Students.Count - 1 < GetEffectiveMinStudents(source.Instance.Sport, semester))
break;
if (target.Instance.Students.Count >= target.Instance.Sport.MaxStudents)
break;
var studentId = source.Instance.Students[^1];
source.Instance.Students.RemoveAt(source.Instance.Students.Count - 1);
target.Instance.Students.Add(studentId);
changed = true;
} while (changed);
}
private int GetEffectiveMinStudents(Sport sport, int semester)
{
int reduction = (semester >= 3) ? 2 : 0;
return Math.Max(1, sport.MinStudents - reduction);
}
private async void MnuExpSettings_OnClick(object? sender, RoutedEventArgs e)
{
await ExportConfigurationAsync();
+101 -1
View File
@@ -38,6 +38,8 @@ public static class PdfExportUtility
return;
}
RenderCoursesOverview(document, courses);
var studentsById = Settings.Instance.Students
.GroupBy(student => student.ID, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
@@ -109,7 +111,7 @@ public static class PdfExportUtility
if (pageIndex == 0)
{
gfx.DrawString("Inkludiert nur SuS, die in mindestens einem Semester keine Zuordnung erhalten haben.",
gfx.DrawString("Es werden nur Schülerinnen und Schüler aufgeführt, die in mindestens einem Semester keine Zuordnung erhalten haben.",
subtitleFont, XBrushes.Gray, new XRect(Margin, y, contentWidth, 20), XStringFormats.TopLeft);
y += 24;
}
@@ -178,6 +180,104 @@ public static class PdfExportUtility
DrawFooter(gfx, page);
}
private static void RenderCoursesOverview(
PdfDocument document,
IReadOnlyList<(int Semester, CourseCrafter.CourseInstance Instance)> courses)
{
var overviewFont = new XFont(FontFamily, 18, XFontStyleEx.Bold);
var subtitleFont = new XFont(FontFamily, 10, XFontStyleEx.Regular);
var tableHeaderFont = new XFont(FontFamily, 9, XFontStyleEx.Bold);
var tableBodyFont = new XFont(FontFamily, 8.5, XFontStyleEx.Regular);
int count = 0;
var coursesPerSemester = courses
.GroupBy(course => course.Semester)
.ToDictionary(group => group.Key, group => group.Count());
var courseIndexBySemester = new Dictionary<int, int>();
var rows = courses.Select(course =>
{
count++;
var key = course.Semester;
courseIndexBySemester.TryGetValue(key, out var index);
index++;
courseIndexBySemester[key] = index;
return new[]
{
count.ToString(),
course.Semester.ToString(),
course.Instance.Sport.Name,
$"{index}/{coursesPerSemester[key]}",
course.Instance.Students.Count.ToString()
};
}).ToList();
var columns = new[] { 50.0, 70.0, 150.0, 150.0, 100.0 };
var headerRow = new[]
{
"Nr.",
"Semester",
"Sportart",
"Kurs (Semester)",
"Anzahl SuS"
};
int rowIndex = 0;
int pageIndex = 0;
while (rowIndex < rows.Count || pageIndex == 0)
{
var page = document.AddPage();
ConfigurePage(page);
using var gfx = XGraphics.FromPdfPage(page);
double pageWidth = page.Width.Point;
double pageHeight = page.Height.Point;
double contentWidth = pageWidth - (Margin * 2);
double contentBottom = pageHeight - Margin - FooterHeight;
double y = Margin;
var headerTitle = pageIndex == 0
? "Übersicht generierter Kurse"
: "Fortsetzung: Übersicht generierter Kurse";
gfx.DrawString(headerTitle, overviewFont, XBrushes.Black,
new XRect(Margin, y, contentWidth, 24), XStringFormats.TopLeft);
y += 26;
if (pageIndex == 0)
{
gfx.DrawString("Tabellarische Übersicht aller generierten Kurse nach Semester und Sportart.",
subtitleFont, XBrushes.Gray, new XRect(Margin, y, contentWidth, 20), XStringFormats.TopLeft);
y += 24;
}
y += DrawTableRow(gfx, y, columns, headerRow, tableHeaderFont, fillHeader: true);
while (rowIndex < rows.Count)
{
var row = rows[rowIndex];
double rowHeight = MeasureRowHeight(gfx, columns, row, tableBodyFont);
if (y + rowHeight > contentBottom)
break;
y += DrawTableRow(gfx, y, columns, row, tableBodyFont, fillHeader: false);
rowIndex++;
}
DrawFooter(gfx, page);
pageIndex++;
if (rowIndex >= rows.Count)
break;
}
}
private static void RenderCourse(
PdfDocument document,
(int Semester, CourseCrafter.CourseInstance Instance) course,
+134 -1
View File
@@ -383,6 +383,8 @@ public class CourseCrafter
}
}
BalanceCoursesBetweenSameSportAndSemester();
// ---------------------------------------------------------------------------
// Lokale Hilfsfunktionen
// ---------------------------------------------------------------------------
@@ -406,6 +408,54 @@ public class CourseCrafter
return true;
}
void BalanceCoursesBetweenSameSportAndSemester()
{
bool changed;
do
{
changed = false;
var groups = GeneratedCourses
.GroupBy(c => (c.Semester, c.Instance.Sport.Name))
.Where(g => g.Count() > 1);
foreach (var group in groups)
{
var courses = group
.OrderBy(c => c.Instance.Students.Count)
.ToList();
for (int sourceIndex = courses.Count - 1; sourceIndex > 0; sourceIndex--)
{
var sourceCourse = courses[sourceIndex];
for (int targetIndex = 0; targetIndex < sourceIndex; targetIndex++)
{
var targetCourse = courses[targetIndex];
if (sourceCourse.Instance.Students.Count <= targetCourse.Instance.Students.Count + 1)
break;
if (targetCourse.Instance.Students.Count >= targetCourse.Instance.Sport.MaxStudents)
continue;
if (sourceCourse.Instance.Students.Count - 1 < getEffectiveMinStudents(sourceCourse.Instance.Sport, sourceCourse.Semester))
continue;
var studentId = sourceCourse.Instance.Students[^1];
sourceCourse.Instance.Students.RemoveAt(sourceCourse.Instance.Students.Count - 1);
targetCourse.Instance.Students.Add(studentId);
changed = true;
break;
}
if (changed)
break;
}
if (changed)
break;
}
} while (changed);
}
bool requestExit()
{
globalCount++;
@@ -694,6 +744,87 @@ public class CourseCrafter
return true;
}
bool EnsureFirstSemesterCoverage()
{
bool changed = false;
foreach (var student in Settings.Instance.Students)
{
if (student.SelectedCourseNames.Count == 0)
continue;
var assignments = GetAssignmentsBySemester(student.ID);
if (assignments[0] != null)
continue;
if (TryAssignStudentToFirstSemester(student))
changed = true;
}
return changed;
}
bool TryAssignStudentToFirstSemester(Student student)
{
var desiredSports = student.SelectedCourseNames
.Select(ResolveSportFromSelection)
.Where(sp => sp != null && sp.Semester[0] != 0)
.DistinctBy(sp => sp!.Name)
.Select(sp => sp!)
.ToList();
foreach (var sport in desiredSports)
{
// Direkt in bestehendes Kursangebot eintragen
var existingCourse = GeneratedCourses
.FirstOrDefault(c => c.Semester == 1 && c.Instance.Sport.Name == sport.Name &&
c.Instance.Students.Count < c.Instance.Sport.MaxStudents);
if (existingCourse.Instance != null)
{
existingCourse.Instance.Students.Add(student.ID);
students_in_semester[0].Add(student.ID);
return true;
}
// Versuche einen Teilnehmer aus einem bestehenden Sem.1-Kurs umzudisponieren
var firstSemesterCourses = GeneratedCourses
.Where(c => c.Semester == 1 && c.Instance.Sport.Name == sport.Name)
.ToList();
foreach (var course in firstSemesterCourses)
{
if (course.Instance.Students.Count <= getEffectiveMinStudents(sport, 1))
continue;
foreach (var otherStudent in course.Instance.Students.ToList())
{
if (otherStudent == student.ID)
continue;
var targetCourse = GeneratedCourses
.FirstOrDefault(c => c.Semester != 1 && c.Instance.Sport.Name == sport.Name &&
c.Instance.Students.Count < c.Instance.Sport.MaxStudents &&
!students_in_semester[c.Semester - 1].Contains(otherStudent));
if (targetCourse.Instance == null)
continue;
course.Instance.Students.Remove(otherStudent);
students_in_semester[0].Remove(otherStudent);
targetCourse.Instance.Students.Add(otherStudent);
students_in_semester[targetCourse.Semester - 1].Add(otherStudent);
course.Instance.Students.Add(student.ID);
students_in_semester[0].Add(student.ID);
return true;
}
}
}
return false;
}
(int Semester, CourseInstance Instance)?[] GetAssignmentsBySemester(string studentId)
{
var assignments = new (int Semester, CourseInstance Instance)?[4];
@@ -750,7 +881,8 @@ public class CourseCrafter
if (freeInterestedStudents > maxFreeInterestedStudents ||
(freeInterestedStudents == maxFreeInterestedStudents &&
totalCoursesPerSemester[i] < minCourses))
(totalCoursesPerSemester[i] < minCourses ||
(totalCoursesPerSemester[i] == minCourses && semesterNumber < bestSem))))
{
maxFreeInterestedStudents = freeInterestedStudents;
minCourses = totalCoursesPerSemester[i];
@@ -761,6 +893,7 @@ public class CourseCrafter
return bestSem;
}
EnsureFirstSemesterCoverage();
ReloadResult();
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

After

Width:  |  Height:  |  Size: 207 KiB

+2 -15
View File
@@ -19,21 +19,8 @@
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="Lucide.Avalonia" Version="0.1.35"/>
</ItemGroup>
<ItemGroup>
<Reference Include="PdfSharp">
<HintPath>lib/pdfsharp/PdfSharp.dll</HintPath>
<Private>true</Private>
</Reference>
<Reference Include="PdfSharp.System">
<HintPath>lib/pdfsharp/PdfSharp.System.dll</HintPath>
<Private>true</Private>
</Reference>
<Reference Include="Microsoft.Extensions.Logging.Abstractions">
<HintPath>lib/pdfsharp/Microsoft.Extensions.Logging.Abstractions.dll</HintPath>
<Private>true</Private>
</Reference>
<PackageReference Include="PdfSharp" Version="6.2.4"/>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8"/>
</ItemGroup>
<ItemGroup>