Files
LehrerApp/LehrerApp.Desktop/ViewModels/Groups/ExamCoverSheetDialogViewModel.cs
T
adminandClaude Opus 5.5 dd92128464 Klausur-Deckblätter als Sammel-PDF aus Vorlagen vom Typ exam-cover
Neuer Dialog "Deckblätter…" bei Klausuren, Punktetabelle als Drawing mit
festen Spaltenbreiten, FLOWDRAWBOX-Attribut fit=content, Stapel-Rendering
mit Seitenzahl je Datensatz, Kursbezeichnung und konfigurierbares Kürzel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:39:09 +02:00

117 lines
6.2 KiB
C#

using CommunityToolkit.Mvvm.ComponentModel;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Templating;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Groups;
/// <summary>Erzeugt zu einer Klausur einen Satz individueller Deckblätter als ein Sammel-PDF.
/// Zur Auswahl stehen nur Vorlagen mit <c>report-type=exam-cover</c>, egal in welcher der beiden
/// Vorlagenbibliotheken sie importiert wurden. Mit <see cref="FillResults"/> trägt dieselbe
/// Vorlage Punkte und Note ein oder bleibt als Blanko-Deckblatt zum Ausfüllen von Hand.</summary>
public partial class ExamCoverSheetDialogViewModel : ObservableObject
{
private readonly Exam _exam;
private readonly LearningGroup _group;
private readonly string _subjectName;
private readonly GradingService _grading;
private readonly TemplateStore _loader;
private readonly ITemplateBatchRenderer _renderer;
[ObservableProperty] private LetterTemplateChoice? _selectedTemplate;
[ObservableProperty] private bool _fillResults;
[ObservableProperty] private string _statusMessage = "";
/// <summary>Vorbelegt aus den Einstellungen, hier nur für diesen Druck änderbar (z. B. zwei
/// Kürzel bei einem gemeinsam unterrichteten Kurs) — die Einstellung bleibt unberührt.</summary>
[ObservableProperty] private string _teacherAbbreviation;
public string ExamLabel => $"{_exam.Title} · {_group.Name} · {_exam.Date:dd.MM.yyyy}";
public ObservableCollection<LetterTemplateChoice> Templates { get; } = [];
public ObservableCollection<ExamCoverStudentChoice> Students { get; } = [];
public bool HasNoTemplates => Templates.Count == 0;
public string SuggestedFileName => SanitizeFileName($"Deckblätter_{_group.Name}_{_exam.Title}.pdf");
public ExamCoverSheetDialogViewModel(Exam exam, LearningGroup group, string subjectName,
IStudentRepository students, IGroupMembershipRepository memberships, IExamResultRepository results,
GradingService grading, IReadOnlyList<TemplateStore> templateStores, ITemplateBatchRenderer renderer,
string teacherAbbreviation = "")
{
_teacherAbbreviation = teacherAbbreviation;
_exam = exam; _group = group; _subjectName = subjectName; _grading = grading; _renderer = renderer;
_loader = templateStores[0];
foreach (var template in templateStores.SelectMany(x => x.GetTemplates()).Where(x => x.IsExamCover)
.DistinctBy(x => x.Id).OrderBy(x => x.Name, StringComparer.CurrentCultureIgnoreCase))
Templates.Add(new(template));
SelectedTemplate = Templates.FirstOrDefault();
var membershipList = memberships.GetByGroup(group.Id);
var existing = results.GetByExam(exam.Id).ToDictionary(r => r.StudentId);
var wroteOriginal = ExamParticipantService.WroteOriginal(exam, results.GetByExam);
foreach (var student in students.GetByGroup(group.Id).OrderBy(s => s.LastName).ThenBy(s => s.FirstName))
{
var membership = membershipList.FirstOrDefault(m => m.StudentId == student.Id);
existing.TryGetValue(student.Id, out var result);
if (!ExamParticipantService.Includes(exam, membership, result is not null, wroteOriginal.Contains(student.Id)))
continue;
Students.Add(new(student, result, membership?.Niveau));
}
// Vor der Korrektur ist das Blanko-Deckblatt der Normalfall, danach das ausgefüllte.
FillResults = Students.Any(s => s.IsCorrected);
}
/// <summary>Schreibt das Sammel-PDF und meldet den Erfolg.</summary>
public bool Generate(string outputPath)
{
StatusMessage = "";
if (SelectedTemplate is null) return false;
var included = Students.Where(s => s.IsIncluded).ToList();
if (included.Count == 0) { StatusMessage = "Kein Schüler ausgewählt."; return false; }
try
{
var loaded = _loader.Load(SelectedTemplate.Model);
var today = DateOnly.FromDateTime(DateTime.Now);
var data = included.Select(s => (ITemplateDataProvider)new LetterDataProvider(
ExamCoverPlaceholderBuilder.Build(_exam, _group, _subjectName, s.Model, s.Result, FillResults,
_grading, loaded, today, s.Niveau, TeacherAbbreviation))).ToList();
var result = _renderer.RenderBatchToPdf(loaded, data);
File.WriteAllBytes(outputPath, result.Pdf);
var message = $"{included.Count} Deckblätter erzeugt.";
var blank = FillResults ? included.Count(s => !s.IsCorrected) : 0;
if (blank > 0) message += $" {blank} davon ohne Punkte und Note (noch nicht korrigiert).";
var overflowing = result.PageCounts.Count(pages => pages > 1);
if (overflowing > 0)
message += $" Achtung: {overflowing} Deckblätter passen nicht auf eine Seite — die Punktetabelle braucht mehr Platz, als die Vorlage lässt.";
StatusMessage = message;
return true;
}
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or TemplateValidationException)
{ StatusMessage = $"Deckblätter konnten nicht erzeugt werden: {ex.Message}"; return false; }
}
private static string SanitizeFileName(string value)
{ foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_'); return value; }
}
public partial class ExamCoverStudentChoice : ObservableObject
{
public Student Model { get; }
public ExamResult? Result { get; }
public Niveau? Niveau { get; }
public string FullName => Model.FullName;
public bool IsCorrected => ExamCoverSheetService.IsCorrected(Result);
public string Hint => Result is { Absent: true } ? "abwesend" : "";
[ObservableProperty] private bool _isIncluded;
public ExamCoverStudentChoice(Student model, ExamResult? result, Niveau? niveau = null)
{
Model = model; Result = result; Niveau = niveau;
// Wer gefehlt hat, bekommt kein Deckblatt — lässt sich im Dialog wieder anhaken.
_isIncluded = result is not { Absent: true };
}
}