Files
LehrerApp/LehrerApp.Desktop/ViewModels/Groups/ExamRevealViewModels.cs
T
adminandClaude Opus 5.5 4f61c1fbd2
CI / build-and-test (push) Canceled after 0s
Datenschutzkonforme Einzelansicht für Klausurergebnisse
Neuer Dialog ExamRevealDialog: zeigt zunächst nur den Namen eines Schülers,
erst nach „Aufdecken“ Note, Punktzahl und Punkte je Aufgabe. Blättern und
gezielte Auswahl oben; jeder Schülerwechsel deckt wieder zu. Erreichbar über
„Einzelansicht“ im Klausuren-Tab und auf der Klausuren-Hauptseite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:15:45 +02:00

126 lines
5.4 KiB
C#

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using System.Globalization;
namespace LehrerApp.Desktop.ViewModels.Groups;
// ── Datenschutzkonforme Einzelansicht (1.7) ──────────────────────────────────
/// Zeigt die Leistung genau eines Schülers — zunächst nur den Namen, das Ergebnis erst nach
/// "Aufdecken". So kann ein einzelner Schüler nach vorne geholt werden, ohne dass er (oder die
/// Klasse über den Beamer) Ergebnisse anderer sieht. Rein lesend.
public partial class ExamRevealDialogViewModel : ObservableObject
{
private readonly Exam _exam;
public string ExamTitle => _exam.Title;
public string ExamDateLabel => _exam.Date.ToString("dd.MM.yyyy");
public List<ExamRevealStudent> Students { get; } = [];
public bool HasStudents => Students.Count > 0;
[ObservableProperty] private ExamRevealStudent? _selectedStudent;
[ObservableProperty] private bool _isRevealed;
public string PositionLabel => SelectedStudent is null
? ""
: $"{Students.IndexOf(SelectedStudent) + 1} / {Students.Count}";
public ExamRevealDialogViewModel(IExamResultRepository results, IStudentRepository students,
GradingService grading, Exam exam)
{
_exam = exam;
var tasks = exam.Tasks.OrderBy(t => t.Nr).ToList();
var labels = ExamScoringService.TaskLabels(tasks);
var enrolled = students.GetByGroup(exam.GroupId).ToDictionary(s => s.Id);
// Grundlage sind die eingetragenen Ergebnisse: wer keins hat (anderes Niveau, nie
// angefasste Zeile), hat hier auch nichts zu sehen. Ausgetretene Schüler mit Ergebnis
// bleiben über GetById auffindbar.
Students = results.GetByExam(exam.Id)
.Select(r => (Result: r, Student: enrolled.GetValueOrDefault(r.StudentId) ?? students.GetById(r.StudentId)))
.Where(x => x.Student is not null)
.OrderBy(x => x.Student!.LastName).ThenBy(x => x.Student!.FirstName)
.Select(x => new ExamRevealStudent(x.Student!, x.Result, tasks, labels, exam.GradingKey, grading))
.ToList();
_selectedStudent = Students.FirstOrDefault();
}
/// Beim Wechsel wird immer wieder zugedeckt — sonst stünde beim Durchblättern sofort das
/// Ergebnis des nächsten Schülers offen auf dem Bildschirm.
partial void OnSelectedStudentChanged(ExamRevealStudent? value)
{
IsRevealed = false;
OnPropertyChanged(nameof(PositionLabel));
PreviousCommand.NotifyCanExecuteChanged();
NextCommand.NotifyCanExecuteChanged();
RevealCommand.NotifyCanExecuteChanged();
}
private int SelectedIndex => SelectedStudent is null ? -1 : Students.IndexOf(SelectedStudent);
private bool CanGoPrevious() => SelectedIndex > 0;
private bool CanGoNext() => SelectedIndex >= 0 && SelectedIndex < Students.Count - 1;
[RelayCommand(CanExecute = nameof(CanGoPrevious))]
private void Previous() => SelectedStudent = Students[SelectedIndex - 1];
[RelayCommand(CanExecute = nameof(CanGoNext))]
private void Next() => SelectedStudent = Students[SelectedIndex + 1];
[RelayCommand(CanExecute = nameof(CanReveal))]
private void Reveal() => IsRevealed = true;
private bool CanReveal() => SelectedStudent is not null;
[RelayCommand]
private void Hide() => IsRevealed = false;
}
/// Ergebnis eines Schülers, fertig aufbereitet für die Einzelansicht.
public class ExamRevealStudent
{
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
/// "Nachname, Vorname" — für die sortierte Auswahlliste.
public string ListName { get; }
/// "Vorname Nachname" — für die große Anzeige.
public string DisplayName { get; }
public bool Absent { get; }
public bool HasGrade => !Absent;
public string GradeDisplay { get; }
public string PointsDisplay { get; }
public List<ExamRevealTaskRow> Tasks { get; } = [];
public ExamRevealStudent(Student student, ExamResult result, IReadOnlyList<ExamTask> orderedTasks,
IReadOnlyList<string> taskLabels, List<GradingKeyEntry> gradingKey, GradingService grading)
{
ListName = student.FullName;
DisplayName = $"{student.FirstName} {student.LastName}".Trim();
Absent = result.Absent;
if (Absent)
{
GradeDisplay = "abwesend";
PointsDisplay = "";
return;
}
var maximum = ExamScoringService.Maximum(orderedTasks, result.Variant);
GradeDisplay = result.Grade ?? grading.CalculateGrade(result.TotalPoints, maximum, gradingKey);
PointsDisplay = $"{Format(result.TotalPoints)} von {Format(maximum)} Punkten";
// Nur die Aufgaben der geschriebenen Fassung — die der anderen gab es für den Schüler nicht.
for (var i = 0; i < orderedTasks.Count; i++)
{
if (!ExamScoringService.AppliesTo(orderedTasks[i], result.Variant)) continue;
var points = i < result.Points.Count ? result.Points[i] : 0;
Tasks.Add(new ExamRevealTaskRow(taskLabels[i], Format(points), Format(orderedTasks[i].MaxPoints)));
}
}
private static string Format(double value) => value.ToString("0.##", German);
}
public record ExamRevealTaskRow(string Label, string Points, string MaxPoints);