2 Commits
Author SHA1 Message Date
adminandClaude Sonnet 5 84c03962ef Klausurschnitt-Vorjahresvergleich beim Hochstufen
CI / build-and-test (push) Canceled after 0s
LearningGroup.PreviousGroupId wird von GroupRolloverService jetzt automatisch
gesetzt; neue Karte "Vorjahresvergleich" im Kurs-Tab Übersicht zeigt den
gepoolten Klausurdurchschnitt neben dem der Vorgängergruppe, sobald dort
Ergebnisse vorliegen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 02:33:07 +02:00
admin b7c7d46ee4 Doppelstunden im Stundenplan korrekt anzeigen 2026-08-30 02:29:50 +02:00
10 changed files with 207 additions and 21 deletions
+7
View File
@@ -26,6 +26,13 @@ public class LearningGroup
/// deshalb händisch je Lerngruppe/Schuljahr gepflegt werden - keine JSON-RPC-Methode liefert sie. /// deshalb händisch je Lerngruppe/Schuljahr gepflegt werden - keine JSON-RPC-Methode liefert sie.
/// </summary> /// </summary>
public int? WebUntisLessonId { get; set; } public int? WebUntisLessonId { get; set; }
/// <summary>
/// Vorgängergruppe aus dem Hochstufen (<see cref="Services.GroupRolloverService"/>) —
/// ermöglicht einen Schuljahresvergleich (z.B. Klausurschnitt) über die Kette hinweg.
/// Wird ausschließlich beim Hochstufen automatisch gesetzt; vor Einführung dieses Felds
/// hochgestufte Gruppen bleiben unverknüpft (kein rückwirkendes Verknüpfen vorgesehen).
/// </summary>
public Guid? PreviousGroupId { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
} }
@@ -60,6 +60,7 @@ public sealed class GroupRolloverService(
IsActive = true, IsActive = true,
IsOwnClass = source.IsOwnClass, IsOwnClass = source.IsOwnClass,
IsDifferentiated = source.IsDifferentiated, IsDifferentiated = source.IsDifferentiated,
PreviousGroupId = source.Id,
// WebUntisLessonId bewusst nicht übernommen: WebUntis vergibt sie pro Schuljahr neu, // WebUntisLessonId bewusst nicht übernommen: WebUntis vergibt sie pro Schuljahr neu,
// eine übernommene alte lsid würde in der Folgegruppe stumm falsche Fehlzeiten liefern. // eine übernommene alte lsid würde in der Folgegruppe stumm falsche Fehlzeiten liefern.
}; };
@@ -18,8 +18,8 @@ public sealed class GroupDetailViewModelTests
var grades = new FakeGrades(); var grades = new FakeGrades();
var vm = new GroupDetailViewModel(groups, students, memberships, subjects, exams, grades, tasks, var vm = new GroupDetailViewModel(groups, students, memberships, subjects, exams, grades, tasks,
new GroupOverviewViewModel(new FakeLessons(), exams, new FakeSessions([]), new FakeEntries(), students, new GroupOverviewViewModel(new FakeLessons(), exams, new FakeResults(), new FakeSessions([]), new FakeEntries(), students,
new FakeDocumentation(), tasks, new AttendanceBalanceService(), new SchoolYearService()), new FakeDocumentation(), tasks, groups, new AttendanceBalanceService(), new GradingService(), new SchoolYearService()),
new ParticipationTabViewModel(new FakeSessions([]), new FakeEntries(), new FakeAspects(), new ParticipationTabViewModel(new FakeSessions([]), new FakeEntries(), new FakeAspects(),
students, memberships, groups, new FakeCompetencyDomains()), students, memberships, groups, new FakeCompetencyDomains()),
new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new GradingService()), new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new GradingService()),
@@ -12,10 +12,13 @@ public sealed class GroupOverviewViewModelTests
{ {
private static GroupOverviewViewModel NewVm(FakeLessons? lessons = null, FakeExams? exams = null, private static GroupOverviewViewModel NewVm(FakeLessons? lessons = null, FakeExams? exams = null,
FakeSessions? sessions = null, FakeEntries? entries = null, FakeStudents? students = null, FakeSessions? sessions = null, FakeEntries? entries = null, FakeStudents? students = null,
FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null) => FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null,
new(lessons ?? new FakeLessons(), exams ?? new FakeExams([]), sessions ?? new FakeSessions([]), FakeResults? results = null, FakeGroups? groups = null) =>
new(lessons ?? new FakeLessons(), exams ?? new FakeExams([]), results ?? new FakeResults(),
sessions ?? new FakeSessions([]),
entries ?? new FakeEntries(), students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(), entries ?? new FakeEntries(), students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(),
tasks ?? new FakeWorkTasks(), new AttendanceBalanceService(), new SchoolYearService()); tasks ?? new FakeWorkTasks(), groups ?? new FakeGroups([]), new AttendanceBalanceService(),
new GradingService(), new SchoolYearService());
private static (GroupOverviewViewModel Vm, FakeLessons Lessons, FakeExams Exams, private static (GroupOverviewViewModel Vm, FakeLessons Lessons, FakeExams Exams,
FakeSessions Sessions, FakeEntries Entries, FakeStudents Students, Guid GroupId) BuildScenario( FakeSessions Sessions, FakeEntries Entries, FakeStudents Students, Guid GroupId) BuildScenario(
@@ -76,6 +79,86 @@ public sealed class GroupOverviewViewModelTests
Assert.Contains("Klassenarbeit 2", vm.NextExamLabel); Assert.Contains("Klassenarbeit 2", vm.NextExamLabel);
} }
[Fact]
public void Vorjahresvergleich_OhneVerknuepfteVorgaengergruppe_BleibtVerborgen()
{
var groupId = Guid.NewGuid();
var groups = new FakeGroups([new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6 }]);
var vm = NewVm(groups: groups);
vm.Initialize(groupId, "Testkurs");
Assert.False(vm.HasYearComparison);
}
[Fact]
public void Vorjahresvergleich_VorgaengerOhneKlausurergebnisse_BleibtVerborgen()
{
var previousId = Guid.NewGuid();
var groupId = Guid.NewGuid();
var groups = new FakeGroups([
new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6, PreviousGroupId = previousId },
new LearningGroup { Id = previousId, GradingSystem = GradingSystem.Grades1To6 },
]);
var vm = NewVm(groups: groups);
vm.Initialize(groupId, "Testkurs");
Assert.False(vm.HasYearComparison);
}
[Fact]
public void Vorjahresvergleich_ZeigtDurchschnitteBeiderJahreAn()
{
var previousId = Guid.NewGuid();
var groupId = Guid.NewGuid();
var groups = new FakeGroups([
new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6, PreviousGroupId = previousId },
new LearningGroup { Id = previousId, GradingSystem = GradingSystem.Grades1To6 },
]);
var previousExam = new Exam { Id = Guid.NewGuid(), GroupId = previousId };
var currentExam = new Exam { Id = Guid.NewGuid(), GroupId = groupId };
var exams = new FakeExams([previousExam, currentExam]);
var results = new FakeResults();
results.Add(new ExamResult { ExamId = previousExam.Id, StudentId = Guid.NewGuid(), Grade = "2" });
results.Add(new ExamResult { ExamId = previousExam.Id, StudentId = Guid.NewGuid(), Grade = "4" });
results.Add(new ExamResult { ExamId = currentExam.Id, StudentId = Guid.NewGuid(), Grade = "1" });
// Abwesende und leere Ergebnisse fließen bewusst nicht in den Schnitt ein.
results.Add(new ExamResult { ExamId = currentExam.Id, StudentId = Guid.NewGuid(), Absent = true, Grade = "6" });
var vm = NewVm(exams: exams, results: results, groups: groups);
vm.Initialize(groupId, "Testkurs");
Assert.True(vm.HasYearComparison);
Assert.Contains("Dieses Jahr: Ø 1,0 (1 Klausuren)", vm.YearComparisonLabel);
Assert.Contains("Vorjahr: Ø 3,0 (2 Klausuren)", vm.YearComparisonLabel);
}
[Fact]
public void Vorjahresvergleich_OhneEigeneKlausurenZeigtNurVorjahr()
{
var previousId = Guid.NewGuid();
var groupId = Guid.NewGuid();
var groups = new FakeGroups([
new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6, PreviousGroupId = previousId },
new LearningGroup { Id = previousId, GradingSystem = GradingSystem.Grades1To6 },
]);
var previousExam = new Exam { Id = Guid.NewGuid(), GroupId = previousId };
var exams = new FakeExams([previousExam]);
var results = new FakeResults();
results.Add(new ExamResult { ExamId = previousExam.Id, StudentId = Guid.NewGuid(), Grade = "3" });
var vm = NewVm(exams: exams, results: results, groups: groups);
vm.Initialize(groupId, "Testkurs");
Assert.True(vm.HasYearComparison);
Assert.Contains("noch keine Klausur", vm.YearComparisonLabel);
}
[Fact] [Fact]
public void Mitarbeit_OhneSitzungenZeigtHinweisOhneWarnung() public void Mitarbeit_OhneSitzungenZeigtHinweisOhneWarnung()
{ {
@@ -712,6 +712,31 @@ public sealed class TimetableViewModelTests
Assert.Equal("1", period2.BadgeText); Assert.Equal("1", period2.BadgeText);
} }
[Fact]
public void Load_Doppelstunde_ZeigtLessonThemaInBeidenKacheln_AuchOhnePhasenUndStundenraster()
{
var date = DateInCurrentWeek(DayOfWeek.Monday);
var group = new LearningGroup { Name = "10c" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = date.DayOfWeek, PeriodNumber = 3 });
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = date.DayOfWeek, PeriodNumber = 4 });
var lesson = new Lesson
{
GroupId = group.Id, Date = date, LessonNumber = 3, Topic = "Werkstoffprüfung",
Phases = [],
};
var lessons = new FakeLessons();
lessons.Add(lesson);
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons);
var first = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == date.DayOfWeek && c.PeriodNumber == 3);
var second = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == date.DayOfWeek && c.PeriodNumber == 4);
Assert.Equal("Werkstoffprüfung", first.Topic);
Assert.Equal("Werkstoffprüfung", second.Topic);
Assert.Equal(lesson.Id, second.Lesson?.Id);
}
[Fact] [Fact]
public void Load_KeineAnstehendenFerien_KeinBadge() public void Load_KeineAnstehendenFerien_KeinBadge()
{ {
@@ -33,12 +33,15 @@ public partial class GroupOverviewViewModel : ObservableObject
private readonly ILessonRepository _lessons; private readonly ILessonRepository _lessons;
private readonly IExamRepository _exams; private readonly IExamRepository _exams;
private readonly IExamResultRepository _examResults;
private readonly IParticipationSessionRepository _sessions; private readonly IParticipationSessionRepository _sessions;
private readonly IParticipationRepository _entries; private readonly IParticipationRepository _entries;
private readonly IStudentRepository _students; private readonly IStudentRepository _students;
private readonly IDocumentationRepository _documentation; private readonly IDocumentationRepository _documentation;
private readonly IWorkTaskRepository _tasks; private readonly IWorkTaskRepository _tasks;
private readonly IGroupRepository _groups;
private readonly AttendanceBalanceService _attendanceBalance; private readonly AttendanceBalanceService _attendanceBalance;
private readonly GradingService _grading;
private readonly SchoolYearService _schoolYear; private readonly SchoolYearService _schoolYear;
private Guid _groupId; private Guid _groupId;
@@ -48,6 +51,8 @@ public partial class GroupOverviewViewModel : ObservableObject
[ObservableProperty] private string _nextLessonLabel = ""; [ObservableProperty] private string _nextLessonLabel = "";
[ObservableProperty] private bool _hasNextExam; [ObservableProperty] private bool _hasNextExam;
[ObservableProperty] private string _nextExamLabel = ""; [ObservableProperty] private string _nextExamLabel = "";
[ObservableProperty] private bool _hasYearComparison;
[ObservableProperty] private string _yearComparisonLabel = "";
[ObservableProperty] private string _participationHintLabel = ""; [ObservableProperty] private string _participationHintLabel = "";
[ObservableProperty] private bool _participationHintIsStale; [ObservableProperty] private bool _participationHintIsStale;
[ObservableProperty] private bool _hasOpenHomeworkCheck; [ObservableProperty] private bool _hasOpenHomeworkCheck;
@@ -105,13 +110,14 @@ public partial class GroupOverviewViewModel : ObservableObject
} }
public GroupOverviewViewModel(ILessonRepository lessons, IExamRepository exams, public GroupOverviewViewModel(ILessonRepository lessons, IExamRepository exams,
IParticipationSessionRepository sessions, IParticipationRepository entries, IExamResultRepository examResults, IParticipationSessionRepository sessions,
IStudentRepository students, IDocumentationRepository documentation, IWorkTaskRepository tasks, IParticipationRepository entries, IStudentRepository students,
AttendanceBalanceService attendanceBalance, SchoolYearService schoolYear) IDocumentationRepository documentation, IWorkTaskRepository tasks, IGroupRepository groups,
AttendanceBalanceService attendanceBalance, GradingService grading, SchoolYearService schoolYear)
{ {
_lessons = lessons; _exams = exams; _sessions = sessions; _lessons = lessons; _exams = exams; _examResults = examResults; _sessions = sessions;
_entries = entries; _students = students; _documentation = documentation; _tasks = tasks; _entries = entries; _students = students; _documentation = documentation; _tasks = tasks;
_attendanceBalance = attendanceBalance; _schoolYear = schoolYear; _groups = groups; _attendanceBalance = attendanceBalance; _grading = grading; _schoolYear = schoolYear;
} }
public void Initialize(Guid groupId, string groupName) public void Initialize(Guid groupId, string groupName)
@@ -126,6 +132,7 @@ public partial class GroupOverviewViewModel : ObservableObject
var today = DateOnly.FromDateTime(DateTime.Today); var today = DateOnly.FromDateTime(DateTime.Today);
LoadNextLesson(today); LoadNextLesson(today);
LoadNextExam(today); LoadNextExam(today);
LoadYearComparison();
LoadParticipationHint(today); LoadParticipationHint(today);
LoadOpenHomeworkCheck(today); LoadOpenHomeworkCheck(today);
LoadOpenExcuses(today); LoadOpenExcuses(today);
@@ -156,6 +163,38 @@ public partial class GroupOverviewViewModel : ObservableObject
NextExamLabel = next is null ? "" : $"{next.Date:dd.MM.yyyy} — {next.Title}"; NextExamLabel = next is null ? "" : $"{next.Date:dd.MM.yyyy} — {next.Title}";
} }
// ── Klausurschnitt-Vorjahresvergleich (Nutzer-Feedback) ──────────────────
//
// Setzt voraus, dass diese Gruppe über GroupRolloverService aus einer Vorgängergruppe
// hochgestuft wurde (LearningGroup.PreviousGroupId) — vor Einführung dieses Felds
// hochgestufte Gruppen bleiben unverknüpft und zeigen keinen Vergleich. Nur sichtbar, wenn
// im Vorjahr tatsächlich Klausurergebnisse vorliegen; ohne die ist kein Vergleich möglich,
// auch wenn eine Verknüpfung besteht.
private void LoadYearComparison()
{
var group = _groups.GetById(_groupId);
var previous = group?.PreviousGroupId is Guid previousId ? ComputeExamAverage(previousId) : null;
if (previous is null) { HasYearComparison = false; return; }
var current = ComputeExamAverage(_groupId);
YearComparisonLabel = current is null
? $"Vorjahr: Ø {previous.Value.Average:0.0} ({previous.Value.Count} Klausuren) — dieses Jahr noch keine Klausur."
: $"Dieses Jahr: Ø {current.Value.Average:0.0} ({current.Value.Count} Klausuren) · " +
$"Vorjahr: Ø {previous.Value.Average:0.0} ({previous.Value.Count} Klausuren)";
HasYearComparison = true;
}
private (double Average, int Count)? ComputeExamAverage(Guid groupId)
{
var grades = _exams.GetByGroup(groupId)
.SelectMany(e => _examResults.GetByExam(e.Id))
.Where(r => !r.Absent && !string.IsNullOrWhiteSpace(r.Grade))
.Select(r => (Grade: r.Grade!, Weight: 1.0))
.ToList();
return grades.Count == 0 ? null : (_grading.WeightedAverage(grades), grades.Count);
}
/// Erinnert nicht an eine Note, sondern schlicht daran, überhaupt wieder eine Sitzung /// Erinnert nicht an eine Note, sondern schlicht daran, überhaupt wieder eine Sitzung
/// anzulegen — genau das vergisst man in Kursen, die man seltener unterrichtet, zuerst. /// anzulegen — genau das vergisst man in Kursen, die man seltener unterrichtet, zuerst.
private void LoadParticipationHint(DateOnly today) private void LoadParticipationHint(DateOnly today)
@@ -570,9 +570,10 @@ public partial class TimetableViewModel : ObservableObject
p.Material?.Contains("Experiment", StringComparison.OrdinalIgnoreCase) == true)) == true; p.Material?.Contains("Experiment", StringComparison.OrdinalIgnoreCase) == true)) == true;
/// Eine Doppelstunde wird als eine Lesson an der ersten Periode gespeichert. Für die zweite /// Eine Doppelstunde wird als eine Lesson an der ersten Periode gespeichert. Für die zweite
/// Rasterzelle liefern wir dieselbe Lesson nur dann, wenn der Stundenplan dort unmittelbar /// Rasterzelle liefern wir dieselbe Lesson, wenn der Stundenplan dort unmittelbar mit derselben
/// fortgesetzt wird und der geplante Verlauf länger als die erste Periode ist. Eine exakt an /// Lerngruppe fortgesetzt wird. Das darf nicht von bereits eingetragenen Phasendauern abhängen:
/// der Zielperiode verankerte Lesson hat immer Vorrang. /// Gerade ein früher Entwurf hat oft erst Datum und Thema, gehört aber trotzdem zum kompletten
/// Doppelstundenblock. Eine exakt an der Zielperiode verankerte Lesson hat immer Vorrang.
private Lesson? FindLessonForSlot(Guid groupId, DateOnly date, int period) private Lesson? FindLessonForSlot(Guid groupId, DateOnly date, int period)
{ {
var lessons = _lessons.GetByGroupAndDate(groupId, date); var lessons = _lessons.GetByGroupAndDate(groupId, date);
@@ -584,14 +585,22 @@ public partial class TimetableViewModel : ObservableObject
var withoutPeriod = lessons.Where(l => l.LessonNumber is null).ToList(); var withoutPeriod = lessons.Where(l => l.LessonNumber is null).ToList();
if (withoutPeriod.Count == 1) return withoutPeriod[0]; if (withoutPeriod.Count == 1) return withoutPeriod[0];
var previous = lessons.Where(l => l.LessonNumber is int p && p == period - 1) var periodsInBlock = _slots.GetByGroup(groupId)
.Where(s => s.Weekday == date.DayOfWeek)
.Select(s => s.PeriodNumber)
.ToHashSet();
// Vom angefragten Slot innerhalb desselben lückenlosen Blocks rückwärts zur verankerten
// Lesson laufen. Damit funktionieren neben Doppelstunden auch seltene Dreifachblöcke.
for (var candidatePeriod = period - 1;
periodsInBlock.Contains(candidatePeriod) && periodsInBlock.Contains(candidatePeriod + 1);
candidatePeriod--)
{
var blockLesson = lessons.Where(l => l.LessonNumber == candidatePeriod)
.OrderByDescending(l => l.UpdatedAt).FirstOrDefault(); .OrderByDescending(l => l.UpdatedAt).FirstOrDefault();
if (previous?.LessonNumber is not int anchor) return null; if (blockLesson is not null) return blockLesson;
var isConsecutiveSlot = _slots.GetByGroup(groupId) }
.Any(s => s.Weekday == date.DayOfWeek && s.PeriodNumber == period); return null;
var firstPeriodMinutes = _periodSchedule.GetDurationMinutes(anchor);
return isConsecutiveSlot && firstPeriodMinutes > 0 &&
previous.Phases.Sum(p => p.DurationMinutes) > firstPeriodMinutes ? previous : null;
} }
/// <summary>4.5.4: Hat die letzte vor <paramref name="date"/> liegende Lesson dieser Gruppe eine /// <summary>4.5.4: Hat die letzte vor <paramref name="date"/> liegende Lesson dieser Gruppe eine
@@ -66,6 +66,14 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Klausurschnitt-Vorjahresvergleich (Nutzer-Feedback) -->
<Border Classes="card" IsVisible="{Binding HasYearComparison}">
<StackPanel>
<TextBlock Text="VORJAHRESVERGLEICH" Classes="cardTitle"/>
<TextBlock Text="{Binding YearComparisonLabel}" TextWrapping="Wrap" Classes="cardBody"/>
</StackPanel>
</Border>
<!-- Mitarbeit --> <!-- Mitarbeit -->
<Border Classes="card"> <Border Classes="card">
<StackPanel> <StackPanel>
@@ -40,6 +40,7 @@ public sealed class GroupRolloverServiceTests
Assert.True(target.IsDifferentiated); Assert.True(target.IsDifferentiated);
Assert.Null(target.WebUntisLessonId); Assert.Null(target.WebUntisLessonId);
Assert.False(source.IsActive); Assert.False(source.IsActive);
Assert.Equal(source.Id, target.PreviousGroupId);
var copied = Assert.Single(memberships.GetByGroup(target.Id)); var copied = Assert.Single(memberships.GetByGroup(target.Id));
Assert.NotEqual(oldMembership.Id, copied.Id); Assert.NotEqual(oldMembership.Id, copied.Id);
+13
View File
@@ -2530,6 +2530,19 @@ Hinweis in Kapitel 1 — betrifft auch Kurse, nicht nur Klassen.
die einzige Bereichsnavigation. Das Verwaltungsmenü sitzt direkt an jeder Karte und ist per die einzige Bereichsnavigation. Das Verwaltungsmenü sitzt direkt an jeder Karte und ist per
Automation-Namen zugänglich. `GroupListViewModelTests` prüfen Direktnavigation, kontextuelle Automation-Namen zugänglich. `GroupListViewModelTests` prüfen Direktnavigation, kontextuelle
Bearbeitung, Suche und Archivierung. Bearbeitung, Suche und Archivierung.
- [x] **7.2.7** Schuljahresübergreifende Auswertung, erster Baustein: Klausurschnitt-Vergleich
mit der Vorgängergruppe (Nutzer-Feedback). Neues Feld `LearningGroup.PreviousGroupId`, von
`GroupRolloverService.RollOver` (7.2.2) automatisch auf die Ausgangsgruppe gesetzt — vor
dieser Ergänzung hochgestufte Gruppen bleiben unverknüpft, ein rückwirkendes manuelles
Verknüpfen ist bewusst nicht Teil dieses Durchgangs. Neue Karte "Vorjahresvergleich" im
Kurs-Tab "Übersicht" (`GroupOverviewViewModel`) zeigt den gepoolten Klausurdurchschnitt
(alle nicht-abwesenden `ExamResult`-Einträge über alle Klausuren der Gruppe, via
`GradingService.WeightedAverage`) dieses Jahres neben dem der verknüpften Vorgängergruppe;
nur sichtbar, wenn im Vorjahr tatsächlich Klausurergebnisse vorliegen. **Bewusst
zurückgestellt:** ein Vergleich, welcher Notenschlüssel bei welcher Aufgabenstruktur wie
gewirkt hat — `Exam.GradingKey` ist eine reine Momentaufnahme ohne Verweis auf die
verwendete `GradingKeyTemplate`, eine saubere Gruppierung dafür bräuchte zunächst diesen
Verweis.
### 7.3 Import ### 7.3 Import