Nachschreibarbeiten und Arbeitsarten (Test, kontrollierte Hausaufgabe)
CI / build-and-test (push) Canceled after 0s
CI / build-and-test (push) Canceled after 0s
Nachschreibarbeiten nutzen die Wiederholungs-Verknüpfung, ersetzen aber nur fehlende Noten; mehrere Folgearbeiten je erster Arbeit sind möglich. Exam.Kind trennt Klausuren (schriftlich) von Tests u. Ä. (Sonstige). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d7898d5678
commit
c0f8b98202
15 files changed
+238
-44
No files matched your search
@@ -25,6 +25,13 @@ public class Exam
|
||||
/// <summary>Optional verknüpfte ursprüngliche Arbeit derselben Lerngruppe.</summary>
|
||||
public Guid? RepeatOfExamId { get; set; }
|
||||
public RepeatGradeRule RepeatGradeRule { get; set; } = RepeatGradeRule.Better;
|
||||
/// <summary>Art der Leistungsüberprüfung. Nur <see cref="ExamKind.Klausur"/> zählt in die
|
||||
/// schriftliche Note; alle anderen Arten fließen unter "Sonstige" ein.</summary>
|
||||
public ExamKind Kind { get; set; } = ExamKind.Klausur;
|
||||
/// <summary>Nachschreibarbeit: inhaltlich eigene Arbeit für Schüler, die die verknüpfte
|
||||
/// erste Arbeit (<see cref="RepeatOfExamId"/>) versäumt haben. Anders als eine Wiederholung
|
||||
/// wird sie nur von diesen Schülern geschrieben und ersetzt deren fehlende Note.</summary>
|
||||
public bool IsMakeUp { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -68,3 +75,18 @@ public class ExamResult
|
||||
public enum ExamStatus { Planned, Conducted, Graded, Returned }
|
||||
public enum ExamVariant { A, B }
|
||||
public enum RepeatGradeRule { Better, Repeat, Original }
|
||||
/// <summary>Reihenfolge ist persistiert (LiteDB speichert den Enum-Wert) — nur hinten anhängen.</summary>
|
||||
public enum ExamKind { Klausur, Test, KontrollierteHausaufgabe, Sonstiges }
|
||||
|
||||
public static class ExamKindExtensions
|
||||
{
|
||||
public static bool CountsAsWritten(this ExamKind kind) => kind == ExamKind.Klausur;
|
||||
|
||||
public static string Label(this ExamKind kind) => kind switch
|
||||
{
|
||||
ExamKind.Test => "Test",
|
||||
ExamKind.KontrollierteHausaufgabe => "Kontrollierte Hausaufgabe",
|
||||
ExamKind.Sonstiges => "Sonstige Arbeit",
|
||||
_ => "Klausur",
|
||||
};
|
||||
}
|
||||
@@ -44,33 +44,54 @@ public static class ExamScoringService
|
||||
ExamVariant variant) => tasks.Select((task, index) =>
|
||||
AppliesTo(task, variant) && index < points.Count ? points[index] : 0).Sum();
|
||||
|
||||
/// <summary>Wählt pro verknüpfter Wiederholung genau eine Note. Andere Arbeiten bleiben erhalten.</summary>
|
||||
/// <summary>
|
||||
/// Wählt pro Arbeit samt allen verknüpften Folgearbeiten (Wiederholung, Nachschreibarbeit)
|
||||
/// genau eine Note. Andere Arbeiten bleiben erhalten. Fehlt die erste Arbeit (Schüler war
|
||||
/// abwesend), zählt die Nachschreibarbeit allein; hat der Schüler beide geschrieben, zählt bei
|
||||
/// einer Nachschreibarbeit immer die erste — sie ersetzt nur eine fehlende Note.
|
||||
/// </summary>
|
||||
public static List<string> EffectiveGrades(IEnumerable<(Exam Exam, string Grade)> entries,
|
||||
GradingSystem system)
|
||||
{
|
||||
var available = entries.ToList();
|
||||
var byId = available.ToDictionary(x => x.Exam.Id);
|
||||
var consumed = new HashSet<Guid>();
|
||||
var result = new List<string>();
|
||||
foreach (var entry in available)
|
||||
var chains = new List<List<(Exam Exam, string Grade)>>();
|
||||
var chainByRoot = new Dictionary<Guid, List<(Exam Exam, string Grade)>>();
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
if (!consumed.Add(entry.Exam.Id)) continue;
|
||||
if (entry.Exam.RepeatOfExamId is { } originalId && byId.TryGetValue(originalId, out var original))
|
||||
{
|
||||
consumed.Add(originalId);
|
||||
result.Add(Choose(original.Grade, entry.Grade, entry.Exam.RepeatGradeRule, system));
|
||||
}
|
||||
else if (available.FirstOrDefault(x => x.Exam.RepeatOfExamId == entry.Exam.Id) is var repeat
|
||||
&& repeat.Exam is not null)
|
||||
{
|
||||
consumed.Add(repeat.Exam.Id);
|
||||
result.Add(Choose(entry.Grade, repeat.Grade, repeat.Exam.RepeatGradeRule, system));
|
||||
}
|
||||
else result.Add(entry.Grade);
|
||||
var root = entry.Exam.RepeatOfExamId ?? entry.Exam.Id;
|
||||
if (!chainByRoot.TryGetValue(root, out var chain))
|
||||
chains.Add(chainByRoot[root] = chain = []);
|
||||
chain.Add(entry);
|
||||
}
|
||||
|
||||
var result = new List<string>();
|
||||
foreach (var chain in chains)
|
||||
{
|
||||
// Die erste Arbeit steht vorn, Folgearbeiten chronologisch dahinter.
|
||||
var ordered = chain.OrderBy(x => x.Exam.RepeatOfExamId is null ? 0 : 1)
|
||||
.ThenBy(x => x.Exam.Date).ToList();
|
||||
var current = ordered[0].Grade;
|
||||
foreach (var next in ordered.Skip(1))
|
||||
current = Choose(current, next.Grade,
|
||||
next.Exam.IsMakeUp ? RepeatGradeRule.Original : next.Exam.RepeatGradeRule, system);
|
||||
result.Add(current);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wie <see cref="EffectiveGrades"/>, trennt aber nach Art: Klausuren zählen als schriftliche
|
||||
/// Note, Tests u. Ä. fließen unter "Sonstige" ein. Verknüpfte Arbeiten erben die Art der
|
||||
/// ersten Arbeit (der Dialog erzwingt das), landen also immer im selben Topf.
|
||||
/// </summary>
|
||||
public static (List<string> Written, List<string> Other) EffectiveGradesByKind(
|
||||
IEnumerable<(Exam Exam, string Grade)> entries, GradingSystem system)
|
||||
{
|
||||
var list = entries.ToList();
|
||||
return (
|
||||
EffectiveGrades(list.Where(x => x.Exam.Kind.CountsAsWritten()), system),
|
||||
EffectiveGrades(list.Where(x => !x.Exam.Kind.CountsAsWritten()), system));
|
||||
}
|
||||
|
||||
private static string Choose(string original, string repeat, RepeatGradeRule rule, GradingSystem system)
|
||||
{
|
||||
if (rule == RepeatGradeRule.Original) return original;
|
||||
|
||||
@@ -10,7 +10,8 @@ namespace LehrerApp.Core.Services;
|
||||
/// fehlt, ist die persönliche Belastung der Lehrkraft: mehr eigene Klausuren in derselben Woche
|
||||
/// bedeuten unabhängig von der Klasse mehr Erstellungsaufwand vorher und mehr Korrekturaufwand
|
||||
/// danach. Da eine LiteDB-Datei genau einem Nutzer gehört (siehe CLAUDE.md), sind "alle
|
||||
/// Klausuren in der Datenbank" bereits gleichbedeutend mit "meine Klausuren".
|
||||
/// Klausuren in der Datenbank" bereits gleichbedeutend mit "meine Klausuren". Tests und andere
|
||||
/// kurze Arbeiten (<see cref="ExamKind"/>) zählen bewusst nicht mit — sie sind kein Klausurblock.
|
||||
/// </summary>
|
||||
public static class ExamWeekLoadService
|
||||
{
|
||||
@@ -24,7 +25,8 @@ public static class ExamWeekLoadService
|
||||
public static int CountInSameWeek(IEnumerable<Exam> allExams, DateOnly date, Guid? excludeExamId = null)
|
||||
{
|
||||
var (year, week) = IsoWeek(date);
|
||||
return allExams.Count(e => e.Id != excludeExamId && IsoWeek(e.Date) == (year, week));
|
||||
return allExams.Count(e => e.Id != excludeExamId && e.Kind.CountsAsWritten()
|
||||
&& IsoWeek(e.Date) == (year, week));
|
||||
}
|
||||
|
||||
/// Gruppiert die übergebenen Klausuren nach ISO-Kalenderwoche und liefert nur die Wochen mit
|
||||
@@ -32,6 +34,7 @@ public static class ExamWeekLoadService
|
||||
/// vorausschauenden Überblick (z.B. Dashboard), nicht für die Einzelprüfung beim Anlegen.
|
||||
public static List<ExamWeekLoad> FindOverloadedWeeks(IEnumerable<Exam> exams) =>
|
||||
exams
|
||||
.Where(e => e.Kind.CountsAsWritten())
|
||||
.GroupBy(e => IsoWeek(e.Date))
|
||||
.Where(g => g.Count() >= WarningThreshold)
|
||||
.Select(g => new ExamWeekLoad(g.Key.Year, g.Key.Week, WeekStart(g.Key.Year, g.Key.Week), g.Count()))
|
||||
|
||||
@@ -42,6 +42,9 @@ public partial class ExamEvaluationDialogViewModel : ObservableObject
|
||||
public ObservableCollection<RepeatComparisonItem> RepeatComparison { get; } = [];
|
||||
public bool HasRepeatComparison => RepeatComparison.Count > 0;
|
||||
public string RepeatComparisonTitle { get; private set; } = "";
|
||||
/// "Wiederholung" bzw. "Nachschreibarbeit" — Beschriftung der Folgearbeit im Vergleich.
|
||||
public string FollowUpLabel { get; private set; } = "Wiederholung";
|
||||
public string FollowUpComparisonHeading => $"Vergleich mit der {FollowUpLabel}";
|
||||
public ObservableCollection<GradingKeyEntryEditItem> GradingKeyEntries { get; } = [];
|
||||
|
||||
public ExamEvaluationDialogViewModel(IExamRepository exams, IExamResultRepository results,
|
||||
@@ -66,15 +69,17 @@ public partial class ExamEvaluationDialogViewModel : ObservableObject
|
||||
var original = _exam.RepeatOfExamId is { } originalId ? _exams.GetById(originalId) : _exam;
|
||||
if (original is null) return;
|
||||
var repeat = _exam.RepeatOfExamId is not null ? _exam
|
||||
: _exams.GetByGroup(_exam.GroupId).FirstOrDefault(e => e.RepeatOfExamId == _exam.Id);
|
||||
: _exams.GetByGroup(_exam.GroupId).Where(e => e.RepeatOfExamId == _exam.Id)
|
||||
.OrderBy(e => e.Date).FirstOrDefault();
|
||||
if (repeat is null) return;
|
||||
var rule = repeat.RepeatGradeRule switch
|
||||
FollowUpLabel = repeat.IsMakeUp ? "Nachschreibarbeit" : "Wiederholung";
|
||||
var rule = repeat.IsMakeUp ? "ersetzt fehlende Note" : repeat.RepeatGradeRule switch
|
||||
{
|
||||
RepeatGradeRule.Repeat => "Wiederholung zählt",
|
||||
RepeatGradeRule.Original => "Erste Arbeit zählt",
|
||||
_ => "Bessere zählt",
|
||||
};
|
||||
RepeatComparisonTitle = $"Erste Arbeit: {original.Title} · Wiederholung: {repeat.Title} · {rule}";
|
||||
RepeatComparisonTitle = $"Erste Arbeit: {original.Title} · {FollowUpLabel}: {repeat.Title} · {rule}";
|
||||
var first = _results.GetByExam(original.Id).Where(r => !r.Absent && r.Grade is not null)
|
||||
.ToDictionary(r => r.StudentId);
|
||||
var second = _results.GetByExam(repeat.Id).Where(r => !r.Absent && r.Grade is not null)
|
||||
@@ -96,6 +101,7 @@ public partial class ExamEvaluationDialogViewModel : ObservableObject
|
||||
}
|
||||
OnPropertyChanged(nameof(HasRepeatComparison));
|
||||
OnPropertyChanged(nameof(RepeatComparisonTitle));
|
||||
OnPropertyChanged(nameof(FollowUpLabel));
|
||||
}
|
||||
|
||||
private void LoadResults()
|
||||
@@ -271,7 +277,7 @@ public partial class ExamEvaluationDialogViewModel : ObservableObject
|
||||
if (HasRepeatComparison)
|
||||
{
|
||||
csv.AddBlankRow().AddRow("Vergleich", RepeatComparisonTitle)
|
||||
.AddRow("Schüler", "Erste Arbeit", "Wiederholung", "Zählt");
|
||||
.AddRow("Schüler", "Erste Arbeit", FollowUpLabel, "Zählt");
|
||||
foreach (var item in RepeatComparison)
|
||||
csv.AddRow(item.StudentName, item.OriginalGrade, item.RepeatGrade, item.EffectiveGrade);
|
||||
}
|
||||
|
||||
@@ -54,6 +54,14 @@ public partial class ExamGradingDialogViewModel : ObservableObject
|
||||
var membershipList = memberships.GetByGroup(groupId);
|
||||
var existing = results.GetByExam(exam.Id).ToDictionary(r => r.StudentId);
|
||||
|
||||
// Nachschreibarbeit: nur Schüler, die die erste Arbeit nicht mitgeschrieben haben. Wer dort
|
||||
// schon eine Note hat, taucht nicht auf — außer es gibt hier bereits ein Ergebnis (nichts
|
||||
// verstecken, was schon eingetragen wurde).
|
||||
var wroteOriginal = new HashSet<Guid>();
|
||||
if (exam.IsMakeUp && exam.RepeatOfExamId is { } originalId)
|
||||
foreach (var r in results.GetByExam(originalId).Where(r => !r.Absent && r.Grade is not null))
|
||||
wroteOriginal.Add(r.StudentId);
|
||||
|
||||
foreach (var s in enrolled.OrderBy(s => s.LastName).ThenBy(s => s.FirstName))
|
||||
{
|
||||
var membership = membershipList.FirstOrDefault(e => e.StudentId == s.Id);
|
||||
@@ -64,6 +72,7 @@ public partial class ExamGradingDialogViewModel : ObservableObject
|
||||
if (exam.Niveau.HasValue && membership?.Niveau != exam.Niveau) continue;
|
||||
|
||||
existing.TryGetValue(s.Id, out var result);
|
||||
if (result is null && wroteOriginal.Contains(s.Id)) continue;
|
||||
var row = new ExamResultRow(s.Id, s.FullName, Tasks, result, _exam.GradingKey,
|
||||
ExamScoringService.Maximum(Tasks, ExamVariant.A), _grading);
|
||||
row.OnChanged = SaveRow;
|
||||
|
||||
@@ -46,11 +46,21 @@ public partial class ExamDialogViewModel : ObservableObject
|
||||
[ObservableProperty] private string _repeatRuleName = "Bessere zählt";
|
||||
[ObservableProperty] private string _repeatError = "";
|
||||
[ObservableProperty] private string _taskPointsError = "";
|
||||
[ObservableProperty] private string _kindName = ExamKind.Klausur.Label();
|
||||
[ObservableProperty] private bool _isMakeUp;
|
||||
|
||||
public bool IsDifferentiated { get; }
|
||||
public string[] NiveauOptions => NiveauDisplay.Options;
|
||||
public string[] KindOptions { get; } = Enum.GetValues<ExamKind>().Select(k => k.Label()).ToArray();
|
||||
public string[] RepeatRuleOptions => ["Bessere zählt", "Wiederholung zählt", "Erste Arbeit zählt"];
|
||||
public bool HasSelectedOriginalExam => SelectedOriginalExam is not null;
|
||||
/// Art und Zählweise sind bei einer verknüpften Folgearbeit festgelegt: sie erbt die Art der
|
||||
/// ersten Arbeit, eine Nachschreibarbeit ersetzt nur fehlende Noten (keine Regelwahl nötig).
|
||||
public bool CanChooseKind => !HasSelectedOriginalExam;
|
||||
public bool ShowRepeatRule => HasSelectedOriginalExam && !IsMakeUp;
|
||||
public string LinkedExamLabel => IsMakeUp
|
||||
? "Nachschreibarbeit zu (optional)" : "Wiederholung oder Nachschreibarbeit zu (optional)";
|
||||
private ExamKind Kind => Enum.GetValues<ExamKind>().FirstOrDefault(k => k.Label() == KindName);
|
||||
public ObservableCollection<Exam> AvailableOriginalExams { get; } = [];
|
||||
|
||||
public bool TotalPointsWarning => TotalPoints <= 0;
|
||||
@@ -63,7 +73,7 @@ public partial class ExamDialogViewModel : ObservableObject
|
||||
public ObservableCollection<GradingKeyTemplate> AvailableTemplates { get; } = [];
|
||||
|
||||
public Exam? Result { get; private set; }
|
||||
public string DialogTitle => _editingExam is null ? "Neue Klausur anlegen" : "Klausur bearbeiten";
|
||||
public string DialogTitle => _editingExam is null ? "Neue Arbeit anlegen" : "Arbeit bearbeiten";
|
||||
public string SaveButtonText => _editingExam is null ? "Anlegen" : "Speichern";
|
||||
|
||||
/// Fach kommt von der Lerngruppe, nicht editierbar (jede Gruppe unterrichtet ein Fach).
|
||||
@@ -87,9 +97,10 @@ public partial class ExamDialogViewModel : ObservableObject
|
||||
_subjectName = defaultSubjectName;
|
||||
IsDifferentiated = isDifferentiated;
|
||||
var groupExams = _exams.GetByGroup(groupId);
|
||||
// Mehrere Folgearbeiten je erster Arbeit sind erlaubt (z. B. Nachschreibtermin und
|
||||
// später eine Wiederholung); Folgearbeiten selbst sind keine Bezugsarbeit.
|
||||
foreach (var exam in groupExams
|
||||
.Where(e => e.Id != editingExam?.Id && e.RepeatOfExamId is null)
|
||||
.Where(e => !groupExams.Any(r => r.RepeatOfExamId == e.Id && r.Id != editingExam?.Id))
|
||||
.OrderByDescending(e => e.Date))
|
||||
AvailableOriginalExams.Add(exam);
|
||||
|
||||
@@ -113,9 +124,11 @@ public partial class ExamDialogViewModel : ObservableObject
|
||||
// Beim Duplizieren bewusst kein Niveau übernehmen — meist wird dupliziert, um
|
||||
// gerade eine andere Niveau-Variante (E/G/Förder) derselben Klausur anzulegen.
|
||||
SelectedNiveauName = isDuplicate ? "–" : NiveauDisplay.ToName(source.Niveau);
|
||||
KindName = source.Kind.Label();
|
||||
if (!isDuplicate)
|
||||
{
|
||||
SelectedOriginalExam = AvailableOriginalExams.FirstOrDefault(e => e.Id == source.RepeatOfExamId);
|
||||
IsMakeUp = source.IsMakeUp && SelectedOriginalExam is not null;
|
||||
RepeatRuleName = source.RepeatGradeRule switch
|
||||
{
|
||||
RepeatGradeRule.Repeat => "Wiederholung zählt",
|
||||
@@ -145,7 +158,23 @@ public partial class ExamDialogViewModel : ObservableObject
|
||||
{
|
||||
foreach (var t in Tasks) t.ShowWeight = value;
|
||||
}
|
||||
partial void OnSelectedOriginalExamChanged(Exam? value) => OnPropertyChanged(nameof(HasSelectedOriginalExam));
|
||||
partial void OnSelectedOriginalExamChanged(Exam? value)
|
||||
{
|
||||
if (value is not null) KindName = value.Kind.Label();
|
||||
else IsMakeUp = false;
|
||||
OnPropertyChanged(nameof(HasSelectedOriginalExam));
|
||||
OnPropertyChanged(nameof(CanChooseKind));
|
||||
OnPropertyChanged(nameof(ShowRepeatRule));
|
||||
UpdateWeekLoadHint();
|
||||
}
|
||||
|
||||
partial void OnIsMakeUpChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(ShowRepeatRule));
|
||||
OnPropertyChanged(nameof(LinkedExamLabel));
|
||||
}
|
||||
|
||||
partial void OnKindNameChanged(string value) => UpdateWeekLoadHint();
|
||||
|
||||
[RelayCommand] private void ClearOriginalExam() => SelectedOriginalExam = null;
|
||||
|
||||
@@ -156,7 +185,8 @@ public partial class ExamDialogViewModel : ObservableObject
|
||||
/// ExamWeekLoadService), hier geht es um die eigene Häufung über alle Kurse hinweg.
|
||||
private void UpdateWeekLoadHint()
|
||||
{
|
||||
if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date))
|
||||
if (!Kind.CountsAsWritten()
|
||||
|| !DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date))
|
||||
{
|
||||
WeekLoadHint = "";
|
||||
return;
|
||||
@@ -354,7 +384,9 @@ public partial class ExamDialogViewModel : ObservableObject
|
||||
if (gradingKeyError is not null) { GradingKeyValidation = gradingKeyError; valid = false; }
|
||||
if (SelectedOriginalExam is not null && SelectedOriginalExam.Date >= date)
|
||||
{
|
||||
RepeatError = "Die erste Arbeit muss vor der Wiederholung liegen.";
|
||||
RepeatError = IsMakeUp
|
||||
? "Die erste Arbeit muss vor der Nachschreibarbeit liegen."
|
||||
: "Die erste Arbeit muss vor der Wiederholung liegen.";
|
||||
valid = false;
|
||||
}
|
||||
if (Tasks.Any(t => t.VariantName != "Beide") &&
|
||||
@@ -376,6 +408,8 @@ public partial class ExamDialogViewModel : ObservableObject
|
||||
Result.Tasks = Tasks.Select(t => t.ToModel()).ToList();
|
||||
Result.GradingKey = gradingKey;
|
||||
Result.RepeatOfExamId = SelectedOriginalExam?.Id;
|
||||
Result.Kind = SelectedOriginalExam?.Kind ?? Kind;
|
||||
Result.IsMakeUp = IsMakeUp && SelectedOriginalExam is not null;
|
||||
Result.RepeatGradeRule = RepeatRuleName switch
|
||||
{
|
||||
"Wiederholung zählt" => RepeatGradeRule.Repeat,
|
||||
|
||||
@@ -192,8 +192,8 @@ public partial class GradeOverviewTabViewModel : ObservableObject
|
||||
cells.Add(display);
|
||||
if (result.Grade is not null) examEntries.Add((exam, result.Grade));
|
||||
}
|
||||
numeric.AddRange(ExamScoringService.EffectiveGrades(examEntries, _gradingSystem)
|
||||
.Select(grade => (Grade: grade, Weight: 1.0)));
|
||||
var (writtenGrades, testGrades) = ExamScoringService.EffectiveGradesByKind(examEntries, _gradingSystem);
|
||||
numeric.AddRange(writtenGrades.Concat(testGrades).Select(grade => (Grade: grade, Weight: 1.0)));
|
||||
|
||||
foreach (var key in gradeColumnKeys)
|
||||
{
|
||||
|
||||
@@ -213,7 +213,7 @@ public partial class GroupOverviewViewModel : ObservableObject
|
||||
.Select(r => (r.StudentId, Exam: e, Grade: r.Grade!)))
|
||||
.GroupBy(x => x.StudentId)
|
||||
.SelectMany(g => ExamScoringService.EffectiveGrades(
|
||||
g.Select(x => (x.Exam, x.Grade)), system))
|
||||
g.Where(x => x.Exam.Kind.CountsAsWritten()).Select(x => (x.Exam, x.Grade)), system))
|
||||
.Select(grade => (Grade: grade, Weight: 1.0)).ToList();
|
||||
return grades.Count == 0 ? null : (_grading.WeightedAverage(grades), grades.Count);
|
||||
}
|
||||
|
||||
@@ -630,8 +630,15 @@ public class ExamSummary
|
||||
Id = e.Id; Title = e.Title; Date = e.Date.ToString("dd.MM.yyyy"); Status = e.Status;
|
||||
ReturnedAtDisplay = e.ReturnedAt?.ToString("dd.MM.yyyy") ?? "";
|
||||
NiveauLabel = e.Niveau.HasValue ? NiveauDisplay.ToName(e.Niveau) : "alle";
|
||||
TypeLabel = e.RepeatOfExamId is not null ? (e.Tasks.Any(t => t.Variant is not null) ? "Wdh. · A/B" : "Wiederholung")
|
||||
: e.Tasks.Any(t => t.Variant is not null) ? "A/B" : "Klausur";
|
||||
var hasVariants = e.Tasks.Any(t => t.Variant is not null);
|
||||
var followUp = e.RepeatOfExamId is null ? null
|
||||
: e.IsMakeUp ? (hasVariants ? "Nachschr. · A/B" : "Nachschreiben")
|
||||
: hasVariants ? "Wdh. · A/B" : "Wiederholung";
|
||||
// Folgearbeiten behalten ihre Art im Hintergrund (sie erben sie), die Spalte nennt nur
|
||||
// die Beziehung; bei eigenständigen Arbeiten steht die Art selbst.
|
||||
TypeLabel = followUp ?? (e.Kind == ExamKind.Klausur
|
||||
? (hasVariants ? "A/B" : "Klausur")
|
||||
: hasVariants ? $"{e.Kind.Label()} · A/B" : e.Kind.Label());
|
||||
StatusLabel = e.Status switch
|
||||
{
|
||||
ExamStatus.Planned => "Geplant",
|
||||
|
||||
@@ -141,8 +141,8 @@ public partial class ReportGradeDialogViewModel : ObservableObject
|
||||
if (result.Absent || result.Grade is null) continue;
|
||||
examEntries.Add((exam, result.Grade));
|
||||
}
|
||||
var examGrades = ExamScoringService.EffectiveGrades(examEntries, _gradingSystem)
|
||||
.Select(grade => (Grade: grade, Weight: 1.0)).ToList();
|
||||
var (writtenGrades, testGrades) = ExamScoringService.EffectiveGradesByKind(examEntries, _gradingSystem);
|
||||
var examGrades = writtenGrades.Select(grade => (Grade: grade, Weight: 1.0)).ToList();
|
||||
|
||||
var participationGrades = allGrades
|
||||
.Where(g => g.StudentId == student.Id && g.Category == GradeCategory.Participation
|
||||
@@ -152,6 +152,8 @@ public partial class ReportGradeDialogViewModel : ObservableObject
|
||||
.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();
|
||||
// Tests, kontrollierte Hausaufgaben u. Ä. aus der Klausurfunktion zählen als Sonstige.
|
||||
otherGrades.AddRange(testGrades.Select(grade => (grade, 1.0)));
|
||||
|
||||
var calculated = _grading.CalculateReportGrade(examGrades, participationGrades, otherGrades,
|
||||
scheme, _gradingSystem, RoundingRule);
|
||||
|
||||
@@ -181,8 +181,8 @@ public partial class StudentPerformanceOverviewViewModel : ObservableObject
|
||||
noteEquivalent, warnings.Count > 0, string.Join(" · ", warnings)));
|
||||
if (noteEquivalent.HasValue) previousNoteEquivalent = noteEquivalent;
|
||||
}
|
||||
_examGradesRaw = ExamScoringService.EffectiveGrades(effectiveExamEntries, _gradingSystem)
|
||||
.Select(grade => (Grade: grade, Weight: 1.0)).ToList();
|
||||
var (writtenGrades, testGrades) = ExamScoringService.EffectiveGradesByKind(effectiveExamEntries, _gradingSystem);
|
||||
_examGradesRaw = writtenGrades.Select(grade => (Grade: grade, Weight: 1.0)).ToList();
|
||||
_examsAverage = _examGradesRaw.Count > 0 ? _grading.WeightedAverage(_examGradesRaw) : null;
|
||||
|
||||
// ── Mitarbeit & Sonstige ──────────────────────────────────────────
|
||||
@@ -207,6 +207,9 @@ public partial class StudentPerformanceOverviewViewModel : ObservableObject
|
||||
_otherGradesRaw.Add((g.Value, g.Weight));
|
||||
OtherRows.Add(new StudentSimpleGradeRow(g, SaveGrade));
|
||||
}
|
||||
// Tests u. Ä. aus der Klausurfunktion zählen mit, sind aber hier nicht editierbar
|
||||
// (sie werden in der Punkteeingabe der Arbeit gepflegt).
|
||||
_otherGradesRaw.AddRange(testGrades.Select(grade => (grade, 1.0)));
|
||||
_otherAverage = _otherGradesRaw.Count > 0 ? _grading.WeightedAverage(_otherGradesRaw) : null;
|
||||
|
||||
ExamsAverageDisplay = _examsAverage.HasValue ? FormatGrade(_examsAverage.Value) : null;
|
||||
|
||||
@@ -23,6 +23,13 @@
|
||||
IsVisible="{Binding TitleError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Art der Arbeit" FontSize="12" Opacity="0.7"/>
|
||||
<ComboBox ItemsSource="{Binding KindOptions}" SelectedItem="{Binding KindName}"
|
||||
IsEnabled="{Binding CanChooseKind}" HorizontalAlignment="Stretch"
|
||||
ToolTip.Tip="Nur Klausuren zählen als schriftliche Note. Tests, kontrollierte Hausaufgaben und sonstige Arbeiten fließen unter "Sonstige" in die Zeugnisnote ein. Folgearbeiten übernehmen die Art der ersten Arbeit."/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4" IsVisible="{Binding IsDifferentiated}">
|
||||
<TextBlock Text="Niveau" FontSize="12" Opacity="0.7"/>
|
||||
<ComboBox ItemsSource="{Binding NiveauOptions}" SelectedItem="{Binding SelectedNiveauName}"
|
||||
@@ -53,10 +60,10 @@
|
||||
</Grid>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Wiederholung einer Arbeit (optional)" FontSize="12" Opacity="0.7"/>
|
||||
<TextBlock Text="{Binding LinkedExamLabel}" FontSize="12" Opacity="0.7"/>
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="8">
|
||||
<ComboBox ItemsSource="{Binding AvailableOriginalExams}" SelectedItem="{Binding SelectedOriginalExam}"
|
||||
PlaceholderText="Keine Wiederholung" HorizontalAlignment="Stretch">
|
||||
PlaceholderText="Keine Verknüpfung" HorizontalAlignment="Stretch">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="models:Exam">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
@@ -69,8 +76,11 @@
|
||||
<Button Grid.Column="1" Content="Auswahl löschen" Command="{Binding ClearOriginalExamCommand}"
|
||||
IsVisible="{Binding HasSelectedOriginalExam}"/>
|
||||
</Grid>
|
||||
<CheckBox Content="Nachschreibarbeit für Schüler, die die erste Arbeit versäumt haben"
|
||||
IsChecked="{Binding IsMakeUp}" IsVisible="{Binding HasSelectedOriginalExam}"
|
||||
ToolTip.Tip="Inhaltlich eigene Arbeit nur für Fehlende. Die Note ersetzt die fehlende Note der ersten Arbeit; die Punkteeingabe zeigt nur die Schüler, die die erste Arbeit nicht mitgeschrieben haben. Ohne Haken gilt es als Wiederholung für alle."/>
|
||||
<ComboBox ItemsSource="{Binding RepeatRuleOptions}" SelectedItem="{Binding RepeatRuleName}"
|
||||
IsVisible="{Binding HasSelectedOriginalExam}"/>
|
||||
IsVisible="{Binding ShowRepeatRule}"/>
|
||||
<TextBlock Text="{Binding RepeatError}" Foreground="Red" FontSize="11"
|
||||
IsVisible="{Binding RepeatError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
|
||||
@@ -112,12 +112,12 @@
|
||||
<Separator/>
|
||||
|
||||
<StackPanel Spacing="6" IsVisible="{Binding HasRepeatComparison}">
|
||||
<TextBlock Text="Vergleich mit der Wiederholung" FontSize="14" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding FollowUpComparisonHeading}" FontSize="14" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding RepeatComparisonTitle}" FontSize="11" Opacity="0.65"/>
|
||||
<Grid ColumnDefinitions="2*,*,*,*">
|
||||
<TextBlock Text="Schüler" FontWeight="Bold" FontSize="11"/>
|
||||
<TextBlock Grid.Column="1" Text="Erste" FontWeight="Bold" FontSize="11"/>
|
||||
<TextBlock Grid.Column="2" Text="Wiederholung" FontWeight="Bold" FontSize="11"/>
|
||||
<TextBlock Grid.Column="2" Text="{Binding FollowUpLabel}" FontWeight="Bold" FontSize="11"/>
|
||||
<TextBlock Grid.Column="3" Text="Zählt" FontWeight="Bold" FontSize="11"/>
|
||||
</Grid>
|
||||
<ItemsControl ItemsSource="{Binding RepeatComparison}">
|
||||
|
||||
@@ -75,4 +75,67 @@ public class ExamScoringServiceTests
|
||||
Assert.Equal(["2"], ExamScoringService.EffectiveGrades(
|
||||
[(original, "2")], GradingSystem.Grades1To6));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nachschreibarbeit_ErsetztFehlendeNote()
|
||||
{
|
||||
var original = new Exam();
|
||||
var makeUp = new Exam { RepeatOfExamId = original.Id, IsMakeUp = true };
|
||||
|
||||
// Schüler war bei der ersten Arbeit abwesend: nur die Nachschreibarbeit liegt vor.
|
||||
Assert.Equal(["4"], ExamScoringService.EffectiveGrades([(makeUp, "4")], GradingSystem.Grades1To6));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nachschreibarbeit_VerdraengtErsteNoteNie()
|
||||
{
|
||||
var original = new Exam();
|
||||
var makeUp = new Exam { RepeatOfExamId = original.Id, IsMakeUp = true };
|
||||
|
||||
// Hat der Schüler beide geschrieben, zählt die erste — auch wenn die zweite besser ist.
|
||||
Assert.Equal(["4"], ExamScoringService.EffectiveGrades(
|
||||
[(original, "4"), (makeUp, "1")], GradingSystem.Grades1To6));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MehrereFolgearbeiten_ZaehlenGenauEinmal()
|
||||
{
|
||||
var original = new Exam { Date = new DateOnly(2026, 9, 1) };
|
||||
var makeUp = new Exam { RepeatOfExamId = original.Id, IsMakeUp = true, Date = new DateOnly(2026, 9, 15) };
|
||||
var repeat = new Exam { RepeatOfExamId = original.Id, Date = new DateOnly(2026, 10, 1) };
|
||||
|
||||
// Original versäumt, Nachschreiben mit 4, Wiederholung mit 3 (Regel "Bessere") → eine Note.
|
||||
Assert.Equal(["3"], ExamScoringService.EffectiveGrades(
|
||||
[(makeUp, "4"), (repeat, "3")], GradingSystem.Grades1To6));
|
||||
// Alle drei geschrieben: Original 3, Nachschreiben ignoriert, Wiederholung 2 → 2.
|
||||
Assert.Equal(["2"], ExamScoringService.EffectiveGrades(
|
||||
[(original, "3"), (makeUp, "1"), (repeat, "2")], GradingSystem.Grades1To6));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EffectiveGradesByKind_TrenntKlausurenVonTests()
|
||||
{
|
||||
var klausur = new Exam();
|
||||
var test = new Exam { Kind = ExamKind.Test };
|
||||
var testMakeUp = new Exam { Kind = ExamKind.Test, RepeatOfExamId = test.Id, IsMakeUp = true };
|
||||
|
||||
var (written, other) = ExamScoringService.EffectiveGradesByKind(
|
||||
[(klausur, "2"), (test, "3"), (testMakeUp, "5")], GradingSystem.Grades1To6);
|
||||
|
||||
Assert.Equal(["2"], written);
|
||||
Assert.Equal(["3"], other);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExamWeekLoad_ZaehltTestsNichtAlsKlausur()
|
||||
{
|
||||
var monday = new DateOnly(2026, 9, 7);
|
||||
var exams = new[]
|
||||
{
|
||||
new Exam { Date = monday },
|
||||
new Exam { Date = monday.AddDays(1), Kind = ExamKind.Test },
|
||||
};
|
||||
|
||||
Assert.Equal(1, ExamWeekLoadService.CountInSameWeek(exams, monday.AddDays(2)));
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,20 @@ Diese Regeln gelten für jede Aufgabe unten und müssen nicht wiederholt werden.
|
||||
Im Wochenstundenplan stehen Klausur und letzte Stunde davor als Textbadges
|
||||
oben in der Kachel; Klausurstunden haben zusätzlich einen gelben Rand.
|
||||
|
||||
- [x] **Nachtrag 2026-10: Nachschreibarbeiten und Arbeitsarten (Test, kontrollierte Hausaufgabe).**
|
||||
(a) `Exam.IsMakeUp` erweitert die Wiederholungs-Verknüpfung (`RepeatOfExamId`): eine
|
||||
Nachschreibarbeit ist eine inhaltlich eigene Arbeit (eigene Aufgaben/Schlüssel) nur für
|
||||
Schüler, die die erste Arbeit versäumt haben. Ihre Note ersetzt die fehlende; haben Schüler
|
||||
beide geschrieben, zählt immer die erste. Die Punkteeingabe blendet Schüler mit vorhandener
|
||||
Note der ersten Arbeit aus. An einer ersten Arbeit können nun mehrere Folgearbeiten hängen
|
||||
(`ExamScoringService.EffectiveGrades` faltet die Kette). (b) `Exam.Kind` (`ExamKind`:
|
||||
Klausur/Test/Kontrollierte Hausaufgabe/Sonstige Arbeit): dieselben Klausurfeatures
|
||||
(Aufgaben, Schlüssel, Punkteeingabe, Auswertung), aber nur `Klausur` zählt in die
|
||||
schriftliche Note; alle anderen Arten laufen als „Sonstige“ in Zeugnisnote und
|
||||
Schülerleistung (`EffectiveGradesByKind`), tauchen in der Notenübersicht auf und zählen
|
||||
nicht in Klausurwochen-Belastung und Vorjahresvergleich. Folgearbeiten erben die Art.
|
||||
Bestehende Klausuren bleiben unverändert (Default `Klausur`).
|
||||
|
||||
Modelle `Exam`, `ExamTask`, `GradingKeyEntry`, `ExamResult` existieren bereits in
|
||||
[Exam.cs](LehrerApp.Core/Models/Exam.cs), Repositories ebenfalls. Die komplette UI fehlt.
|
||||
|
||||
|
||||
Reference in new issue
Block a user