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>
334 lines
15 KiB
C#
334 lines
15 KiB
C#
using CommunityToolkit.Mvvm.ComponentModel;
|
||
using CommunityToolkit.Mvvm.Input;
|
||
using LehrerApp.Core.Interfaces;
|
||
using LehrerApp.Core.Models;
|
||
using LehrerApp.Core.Services;
|
||
using LehrerApp.Desktop.Services;
|
||
using System.Collections.ObjectModel;
|
||
using System.Globalization;
|
||
|
||
namespace LehrerApp.Desktop.ViewModels.Groups;
|
||
|
||
// ── Klausurauswertung (1.5) ───────────────────────────────────────────────────
|
||
|
||
public partial class ExamEvaluationDialogViewModel : ObservableObject
|
||
{
|
||
private readonly IExamRepository _exams;
|
||
private readonly IExamResultRepository _results;
|
||
private readonly GradingService _grading;
|
||
private readonly Exam _exam;
|
||
private readonly GradingSystem _gradingSystem;
|
||
private readonly IStudentRepository? _students;
|
||
private readonly double _examMaxPoints;
|
||
private List<ExamResult> _gradedResults = [];
|
||
|
||
public string ExamTitle => _exam.Title;
|
||
public string ExamDateLabel => _exam.Date.ToString("dd.MM.yyyy");
|
||
public string NiveauLabel => _exam.Niveau.HasValue ? NiveauDisplay.ToName(_exam.Niveau) : "";
|
||
|
||
[ObservableProperty] private int _gradedCount;
|
||
[ObservableProperty] private int _absentCount;
|
||
[ObservableProperty] private string _averageDisplay = "–";
|
||
[ObservableProperty] private string _medianDisplay = "–";
|
||
[ObservableProperty] private string _belowThresholdLabel1 = "";
|
||
[ObservableProperty] private string _belowThresholdDisplay1 = "";
|
||
[ObservableProperty] private string _belowThresholdLabel2 = "";
|
||
[ObservableProperty] private string _belowThresholdDisplay2 = "";
|
||
[ObservableProperty] private string _gradingKeyValidation = "";
|
||
[ObservableProperty] private string _gradingKeyStatus = "";
|
||
|
||
public ObservableCollection<GradeBarItem> GradeDistribution { get; } = [];
|
||
public ObservableCollection<TaskAnalysisItem> TaskAnalysis { get; } = [];
|
||
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,
|
||
GradingService grading, Exam exam, GradingSystem gradingSystem,
|
||
IStudentRepository? students = null)
|
||
{
|
||
_exams = exams; _results = results; _grading = grading; _exam = exam;
|
||
_gradingSystem = gradingSystem; _students = students;
|
||
_examMaxPoints = ExamScoringService.Maximum(exam.Tasks, ExamVariant.A);
|
||
|
||
foreach (var e in exam.GradingKey.OrderByDescending(e => e.MinPercent))
|
||
AddGradingKeyRowInternal(e.Grade, e.MinPercent);
|
||
|
||
LoadResults();
|
||
LoadRepeatComparison(students);
|
||
}
|
||
|
||
private void LoadRepeatComparison(IStudentRepository? students)
|
||
{
|
||
RepeatComparison.Clear();
|
||
RepeatComparisonTitle = "";
|
||
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).Where(e => e.RepeatOfExamId == _exam.Id)
|
||
.OrderBy(e => e.Date).FirstOrDefault();
|
||
if (repeat is null) return;
|
||
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} · {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)
|
||
.ToDictionary(r => r.StudentId);
|
||
var names = students?.GetByGroup(_exam.GroupId).ToDictionary(s => s.Id, s => s.FullName);
|
||
foreach (var studentId in first.Keys.Union(second.Keys)
|
||
.OrderBy(id => names?.GetValueOrDefault(id) ?? id.ToString()))
|
||
{
|
||
first.TryGetValue(studentId, out var originalResult);
|
||
second.TryGetValue(studentId, out var repeatResult);
|
||
var entries = new List<(Exam Exam, string Grade)>();
|
||
if (originalResult?.Grade is { } g1) entries.Add((original, g1));
|
||
if (repeatResult?.Grade is { } g2) entries.Add((repeat, g2));
|
||
var effective = ExamScoringService.EffectiveGrades(entries, _gradingSystem);
|
||
RepeatComparison.Add(new RepeatComparisonItem(
|
||
names?.GetValueOrDefault(studentId) ?? studentId.ToString(),
|
||
originalResult?.Grade ?? "–", repeatResult?.Grade ?? "–",
|
||
effective.FirstOrDefault() ?? "–"));
|
||
}
|
||
OnPropertyChanged(nameof(HasRepeatComparison));
|
||
OnPropertyChanged(nameof(RepeatComparisonTitle));
|
||
OnPropertyChanged(nameof(FollowUpLabel));
|
||
}
|
||
|
||
private void LoadResults()
|
||
{
|
||
var all = _results.GetByExam(_exam.Id);
|
||
AbsentCount = all.Count(r => r.Absent);
|
||
_gradedResults = all.Where(r => !r.Absent).ToList();
|
||
GradedCount = _gradedResults.Count;
|
||
Recompute();
|
||
}
|
||
|
||
// ── Notenschlüssel-Editor (1.5.3) ────────────────────────────────────────
|
||
|
||
[RelayCommand]
|
||
private void AddGradingKeyRow() => AddGradingKeyRowInternal("", 0);
|
||
|
||
private void AddGradingKeyRowInternal(string grade, double minPercent)
|
||
{
|
||
var item = new GradingKeyEntryEditItem
|
||
{
|
||
Grade = grade,
|
||
MinPercent = minPercent,
|
||
OnChanged = OnGradingKeyRowChanged,
|
||
OnRemove = RemoveGradingKeyRow,
|
||
};
|
||
GradingKeyEntries.Add(item);
|
||
RecomputeGradingKeyAbsolutes();
|
||
}
|
||
|
||
private void OnGradingKeyRowChanged()
|
||
{
|
||
RecomputeGradingKeyAbsolutes();
|
||
Recompute();
|
||
}
|
||
|
||
private void RemoveGradingKeyRow(GradingKeyEntryEditItem item)
|
||
{
|
||
GradingKeyEntries.Remove(item);
|
||
RecomputeGradingKeyAbsolutes();
|
||
Recompute();
|
||
}
|
||
|
||
private void RecomputeGradingKeyAbsolutes()
|
||
{
|
||
foreach (var e in GradingKeyEntries)
|
||
e.AbsolutePointsDisplay = _examMaxPoints <= 0
|
||
? "–"
|
||
: _exam.Tasks.Any(t => t.Variant is not null)
|
||
? $"A ab {e.MinPercent / 100.0 * _examMaxPoints:0.##} / " +
|
||
$"B ab {e.MinPercent / 100.0 * ExamScoringService.Maximum(_exam.Tasks, ExamVariant.B):0.##} Punkten"
|
||
: $"ab {(e.MinPercent / 100.0 * _examMaxPoints).ToString("0.##", CultureInfo.InvariantCulture)} " +
|
||
$"von {_examMaxPoints.ToString("0.##", CultureInfo.InvariantCulture)} Punkten";
|
||
}
|
||
|
||
private List<GradingKeyEntry> BuildCurrentGradingKey() => GradingKeyEntries
|
||
.Select(e => new GradingKeyEntry { Grade = e.Grade.Trim(), MinPercent = e.MinPercent })
|
||
.OrderByDescending(e => e.MinPercent)
|
||
.ToList();
|
||
|
||
[RelayCommand]
|
||
private void ApplyGradingKey()
|
||
{
|
||
var key = BuildCurrentGradingKey();
|
||
var error = _grading.ValidateGradingKey(key);
|
||
if (error is not null) { GradingKeyValidation = error; GradingKeyStatus = ""; return; }
|
||
GradingKeyValidation = "";
|
||
_exam.GradingKey = key;
|
||
_exams.Save(_exam);
|
||
var results = _results.GetByExam(_exam.Id);
|
||
foreach (var result in results.Where(r => !r.Absent))
|
||
result.Grade = _grading.CalculateGrade(result.TotalPoints,
|
||
ExamScoringService.Maximum(_exam.Tasks, result.Variant), key);
|
||
_results.SaveMany(results);
|
||
LoadRepeatComparison(_students);
|
||
GradingKeyStatus = "Notenschlüssel für diese Klausur übernommen.";
|
||
}
|
||
|
||
// ── Auswertung neu berechnen ──────────────────────────────────────────────
|
||
|
||
private void Recompute()
|
||
{
|
||
var currentKey = BuildCurrentGradingKey();
|
||
GradingKeyValidation = _grading.ValidateGradingKey(currentKey) ?? "";
|
||
|
||
var grades = _gradedResults
|
||
.Select(r => _grading.CalculateGrade(r.TotalPoints,
|
||
ExamScoringService.Maximum(_exam.Tasks, r.Variant), currentKey))
|
||
.ToList();
|
||
|
||
GradeDistribution.Clear();
|
||
var counts = grades.GroupBy(g => g).ToDictionary(g => g.Key, g => g.Count());
|
||
var maxCount = counts.Count == 0 ? 0 : counts.Values.Max();
|
||
foreach (var entry in currentKey.OrderByDescending(e => e.MinPercent))
|
||
{
|
||
counts.TryGetValue(entry.Grade, out var count);
|
||
GradeDistribution.Add(new GradeBarItem(entry.Grade, count, grades.Count, maxCount));
|
||
}
|
||
|
||
AverageDisplay = grades.Count == 0
|
||
? "–"
|
||
: _grading.WeightedAverage(grades.Select(g => (Grade: g, Weight: 1.0)).ToList())
|
||
.ToString("0.00", CultureInfo.InvariantCulture);
|
||
|
||
var numeric = grades.Select(g => int.TryParse(g, out var n) ? (int?)n : null)
|
||
.Where(n => n.HasValue).Select(n => n!.Value)
|
||
.OrderBy(n => n).ToList();
|
||
MedianDisplay = numeric.Count == 0 ? "–" : ComputeMedian(numeric).ToString("0.##", CultureInfo.InvariantCulture);
|
||
|
||
if (_gradingSystem == GradingSystem.Points0To15)
|
||
{
|
||
BelowThresholdLabel1 = "Anteil < 5 Punkte";
|
||
BelowThresholdDisplay1 = Percent(numeric.Count(n => n < 5), numeric.Count);
|
||
BelowThresholdLabel2 = "Anteil < 4 Punkte";
|
||
BelowThresholdDisplay2 = Percent(numeric.Count(n => n < 4), numeric.Count);
|
||
}
|
||
else
|
||
{
|
||
BelowThresholdLabel1 = "Anteil nicht ausreichend (Note 5/6)";
|
||
BelowThresholdDisplay1 = Percent(numeric.Count(n => n >= 5), numeric.Count);
|
||
BelowThresholdLabel2 = "";
|
||
BelowThresholdDisplay2 = "";
|
||
}
|
||
|
||
RecomputeTaskAnalysis();
|
||
}
|
||
|
||
private void RecomputeTaskAnalysis()
|
||
{
|
||
TaskAnalysis.Clear();
|
||
var tasks = _exam.Tasks.OrderBy(t => t.Nr).ToList();
|
||
var labels = ExamScoringService.TaskLabels(tasks);
|
||
for (var i = 0; i < tasks.Count; i++)
|
||
{
|
||
var task = tasks[i];
|
||
var achieved = _gradedResults.Where(r => i < r.Points.Count &&
|
||
ExamScoringService.AppliesTo(task, r.Variant)).Select(r => r.Points[i]).ToList();
|
||
var avgPercent = task.MaxPoints <= 0 || achieved.Count == 0
|
||
? 0
|
||
: achieved.Average() / task.MaxPoints * 100.0;
|
||
var suffix = task.Variant is null ? "" : $" ({task.Variant})";
|
||
TaskAnalysis.Add(new TaskAnalysisItem(labels[i] + suffix, avgPercent));
|
||
}
|
||
}
|
||
|
||
private static double ComputeMedian(List<int> sorted)
|
||
{
|
||
var n = sorted.Count;
|
||
return n % 2 == 1 ? sorted[n / 2] : (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0;
|
||
}
|
||
|
||
private static string Percent(int count, int total) =>
|
||
total == 0 ? "–" : $"{(count * 100.0 / total).ToString("0.#", CultureInfo.InvariantCulture)} %";
|
||
|
||
// ── Export (1.5.4) ────────────────────────────────────────────────────────
|
||
|
||
public string ExportCsv()
|
||
{
|
||
var csv = new CsvBuilder()
|
||
.AddRow("Klausur", _exam.Title)
|
||
.AddRow("Datum", _exam.Date.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture))
|
||
.AddBlankRow()
|
||
.AddRow("Notenspiegel")
|
||
.AddRow("Note", "Anzahl", "Anteil");
|
||
foreach (var g in GradeDistribution)
|
||
csv.AddRow(g.Grade, g.Count, Percent(g.Count, GradedCount));
|
||
csv.AddBlankRow()
|
||
.AddRow("Durchschnitt", AverageDisplay)
|
||
.AddRow("Median", MedianDisplay);
|
||
if (!string.IsNullOrEmpty(BelowThresholdLabel1)) csv.AddRow(BelowThresholdLabel1, BelowThresholdDisplay1);
|
||
if (!string.IsNullOrEmpty(BelowThresholdLabel2)) csv.AddRow(BelowThresholdLabel2, BelowThresholdDisplay2);
|
||
csv.AddRow("Bewertet", GradedCount)
|
||
.AddRow("Abwesend", AbsentCount);
|
||
if (HasRepeatComparison)
|
||
{
|
||
csv.AddBlankRow().AddRow("Vergleich", RepeatComparisonTitle)
|
||
.AddRow("Schüler", "Erste Arbeit", FollowUpLabel, "Zählt");
|
||
foreach (var item in RepeatComparison)
|
||
csv.AddRow(item.StudentName, item.OriginalGrade, item.RepeatGrade, item.EffectiveGrade);
|
||
}
|
||
csv.AddBlankRow()
|
||
.AddRow("Aufgabenanalyse")
|
||
.AddRow("Aufgabe", "Ø Erfüllungsgrad", "Auffällig schwach");
|
||
foreach (var t in TaskAnalysis)
|
||
csv.AddRow(t.Label, t.AvgPercentDisplay, t.IsWeak ? "ja" : "");
|
||
return csv.ToString();
|
||
}
|
||
}
|
||
|
||
// ── Notenspiegel-Balken ────────────────────────────────────────────────────
|
||
|
||
public record RepeatComparisonItem(string StudentName, string OriginalGrade,
|
||
string RepeatGrade, string EffectiveGrade);
|
||
|
||
public class GradeBarItem
|
||
{
|
||
public string Grade { get; }
|
||
public int Count { get; }
|
||
public string CountDisplay { get; }
|
||
public double BarWidth { get; }
|
||
|
||
public GradeBarItem(string grade, int count, int totalGraded, int maxCount)
|
||
{
|
||
Grade = grade;
|
||
Count = count;
|
||
var percent = totalGraded == 0 ? 0 : count * 100.0 / totalGraded;
|
||
CountDisplay = $"{count} ({percent.ToString("0.#", CultureInfo.InvariantCulture)} %)";
|
||
BarWidth = maxCount <= 0 ? 0 : count * 200.0 / maxCount;
|
||
}
|
||
}
|
||
|
||
// ── Aufgabenanalyse-Zeile ──────────────────────────────────────────────────
|
||
|
||
public class TaskAnalysisItem
|
||
{
|
||
public string Label { get; }
|
||
public double AvgPercent { get; }
|
||
public string AvgPercentDisplay { get; }
|
||
public bool IsWeak { get; }
|
||
public double BarWidth { get; }
|
||
|
||
public TaskAnalysisItem(string label, double avgPercent)
|
||
{
|
||
Label = label;
|
||
AvgPercent = avgPercent;
|
||
AvgPercentDisplay = $"{avgPercent.ToString("0.#", CultureInfo.InvariantCulture)} %";
|
||
IsWeak = avgPercent < 50.0;
|
||
BarWidth = Math.Clamp(avgPercent, 0, 100) * 2.0;
|
||
}
|
||
}
|