Files
LehrerApp/LehrerApp.Desktop/ViewModels/Groups/ExamCoverSheetDialogViewModel.cs
T
adminandClaude Opus 5.5 24a30881f3
CI / build-and-test (push) Canceled after 0s
Klausur-Deckblatt: Titel im Dialog je Druck anpassbar
Der interne Klausurtitel passt nicht immer aufs Deckblatt. Das neue Feld
ist mit ihm vorbelegt, ändert nur Exam.Title für diesen Druck und fällt
leer gelassen auf den internen Titel zurück.

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

122 lines
6.6 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;
/// <summary>Vorbelegt mit dem internen Klausurtitel, hier nur für diesen Druck änderbar — der
/// Titel der Klausur bleibt unberührt. Leer gelassen gilt wieder der interne Titel.</summary>
[ObservableProperty] private string _coverTitle;
public string ExamTitle => _exam.Title;
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;
_coverTitle = exam.Title;
_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, CoverTitle))).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 };
}
}