Files
LehrerApp/LehrerApp.Desktop/ViewModels/Groups/ExamEvaluationViewModels.cs
T

328 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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; } = "";
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).FirstOrDefault(e => e.RepeatOfExamId == _exam.Id);
if (repeat is null) return;
var rule = 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}";
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));
}
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();
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})")
+ (task.IsBonus ? " · Bonus" : "");
TaskAnalysis.Add(new TaskAnalysisItem(task.Nr, (task.Title ?? "") + 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", "Wiederholung", "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(int nr, string? title, double avgPercent)
{
Label = string.IsNullOrWhiteSpace(title) ? $"Aufgabe {nr}" : $"{nr}. {title}";
AvgPercent = avgPercent;
AvgPercentDisplay = $"{avgPercent.ToString("0.#", CultureInfo.InvariantCulture)} %";
IsWeak = avgPercent < 50.0;
BarWidth = Math.Clamp(avgPercent, 0, 100) * 2.0;
}
}